Guild Seals are XYPH's cryptographic identity and signing system. Every agent gets an Ed25519 keypair. When an agent completes a quest, the resulting artifact (a Scroll) is signed with the agent's private key, producing a Guild Seal — a verifiable proof of authorship that lives in the WARP graph forever.
This document covers the full system: key generation, identity encoding, signing, verification, keyring management, and the versioned migration pipeline that keeps it all backward-compatible.
- Overview
- Key Generation
- DID Key Identifiers
- Signing
- Verification
- The Keyring
- Keyring Versioning and Migration
- Trust Directory Layout
- Security Considerations
- Code Map
- FAQ
┌──────────────┐
│ generate-key │
└──────┬───────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
~/.xyph/trust/<agent>.sk ~/.xyph/trust/keyring.json did:key:z6Mk...
(private key) (public key) (signing key ID)
│ │
│ ┌─────────┐ │
└────────►│ sign() ├───────────┘
└────┬────┘
▼
┌──────────┐
│ GuildSeal │──► WARP graph
└──────────┘
│
┌────┴────┐
│ verify()│◄── keyring.json
└─────────┘
The flow:
-
Generate —
generate-keycreates an Ed25519 keypair. The private key goes to~/.xyph/trust/<agentId>.skby default. The public key is registered in~/.xyph/trust/keyring.json. SetXYPH_TRUST_DIRto use a different location, such as a local vault checkout. -
Identify — Each agent has a stable
agentId(e.g.,agent.hal). The signing key identifier is a W3C DID Key:did:key:z6Mk..., derived deterministically from the current public key. On key rotation, thedid:keychanges but theagentIdremains stable. -
Sign — When sealing a quest,
sign()reads the private key, derives the public key, canonicalizes the payload, hashes it with BLAKE3, and produces an Ed25519 signature. The resultingGuildSealincludes thedid:keyidentifier. -
Verify —
verify()loads the keyring, looks up the seal'skeyIdin the Map (which includes legacy aliases), and checks the Ed25519 signature against the registered public key.
export XYPH_AGENT_ID=agent.hal
npx tsx xyph-actuator.ts generate-keyThis calls GuildSealService.generateKeypair(agentId), which:
- Generates 32 cryptographically random bytes (via
crypto.randomBytes). - Derives the Ed25519 public key using
@noble/ed25519. - Writes the private key hex to
~/.xyph/trust/<agentId>.skwith mode0600(owner-only read/write). UsesO_EXCLto prevent overwriting an existing key. - Derives the
did:key:z6Mk...identifier from the public key. - Appends the entry to
~/.xyph/trust/keyring.jsonwith the canonicalkeyId,publicKeyHex,alg, andagentIdfields.
If the keyring write fails, the private key file is rolled back (deleted) to prevent orphaned state.
Key generation is a one-time operation per agent. The keypair persists across sessions.
Agent identities use the W3C DID Key Method:
did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK
─┬─ ─────────────────────┬──────────────────────────
│ │
multibase prefix base58btc(0xed01 + publicKeyBytes)
(z = base58btc)
The encoding pipeline:
Ed25519 public key (32 bytes)
│
▼
Prepend multicodec prefix: 0xed, 0x01 (Ed25519-pub varint)
│
▼
Encode as base58btc (Bitcoin alphabet: 123456789ABCDEFGH...)
│
▼
Prepend 'z' (multibase identifier for base58btc)
│
▼
Prepend 'did:key:'
│
▼
did:key:z6Mk...
All Ed25519 did:key values start with z6Mk because the multicodec
prefix 0xed01 always base58-encodes to 6Mk as its leading characters. This
is a useful visual indicator that you're looking at an Ed25519 key.
The encoding is deterministic: the same public key always produces the same
did:key. Different keys always produce different did:key values.
encodeBase58btc(bytes)— pure base58btc encoder (src/validation/crypto.ts)publicKeyToDidKey(publicKeyHex)— full pipeline from hex pubkey todid:key:z6Mk...
When an agent seals a quest (via seal or merge), GuildSealService.sign()
produces a GuildSeal:
interface GuildSeal {
alg: 'ed25519'; // Algorithm identifier (lowercase literal — see note below)
keyId: string; // did:key:z6Mk... of the signer
payloadDigest: string; // blake3:<hex> of the canonical payload
sig: string; // 128 hex chars (64-byte Ed25519 signature)
sealedAt: number; // Unix timestamp
}Convention: The
algfield stores the lowercase string"ed25519", matching the graph schema (guild_seal_alginGRAPH_SCHEMA.md) and all code paths. Prose in this document uses the proper algorithm nameEd25519(capitalized) when referring to the algorithm itself, not the serialized value.
-
Canonicalize — The scroll payload (
artifactHash,questId,rationale,sealedBy,sealedAt) is serialized into deterministic canonical JSON: keys sorted lexicographically, no whitespace, arrays preserved in order. -
Digest — The canonical string is hashed with BLAKE3 and prefixed:
blake3:<hex>. -
Sign — The canonical string (not the digest) is signed with Ed25519 using the agent's private key.
-
Derive keyId — The public key is derived from the private key at sign time, ensuring the seal's
keyIdalways corresponds to the actual signing key. This prevents mismatches if the keyring has stale entries.
The digest is included in the seal for quick integrity checks (compare hashes without re-canonicalizing). But the signature covers the full canonical payload so that verification doesn't depend on trusting the digest field — it can be independently recomputed.
GuildSealService.verify(seal, scroll) checks:
-
Digest match — Re-canonicalize the scroll payload, re-hash with BLAKE3, and compare to
seal.payloadDigest. If they differ, the payload was tampered with. -
Key lookup — Load the keyring and look up
seal.keyId. Thanks to legacy alias resolution, this works for both old-format and new-format keyIds. -
Signature check — Verify the Ed25519 signature against the canonical payload using the looked-up public key.
All three checks must pass. If the keyring is missing or malformed, verification
fails gracefully (returns false, does not throw).
The same keyring lookup is used by validatePatchOps (invariant #12) to verify
signatures on PlanPatch documents. The keyringCache in validatePatchOps.ts
stores the Map returned by loadKeyring(), so alias resolution works
transparently for patch validation too.
The keyring is a JSON file at ~/.xyph/trust/keyring.json by default that maps key identifiers to
Ed25519 public keys. It is the single source of trust for signature
verification.
{
"version": "v3",
"keys": [
{
"keyId": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
"alg": "ed25519",
"publicKeyHex": "03dee5df0ac6c7e82d002ae6c8e525017647ff12f7a11c897c32c7732d9bb992",
"agentId": "agent.hal",
"active": true
}
]
}| Field | Required | Description |
|---|---|---|
version |
Yes | Schema version string ("v1", "v2", "v3", ...) |
keys |
Yes | Array of key entries |
keys[].keyId |
Yes | Canonical did:key:z6Mk... identifier (v2+: derived from public key) |
keys[].alg |
Yes | Algorithm identifier. Currently only "ed25519" is supported |
keys[].publicKeyHex |
Yes | 32-byte Ed25519 public key as 64 lowercase hex chars |
keys[].agentId |
No | The agent identity this key belongs to (e.g., "agent.hal"). Multiple entries may share an agentId (for key rotation), but at most one may be active: true per agent. |
keys[].active |
Yes | Whether this is the current signing key for its agent. keyIdForAgent() returns only active keys. Retired keys (false) remain for verification of historical signatures. |
keys[].legacyKeyIds |
No | Previous keyId values that should alias to this entry (migration artifact) |
The keyring format is versioned. loadKeyring() transparently migrates older
formats on read — the on-disk file is never mutated during reads, only during
explicit writes (generateKeypair()).
The migration pipeline is defined in src/validation/crypto.ts:
const KEYRING_MIGRATIONS: readonly KeyringMigration[] = [
{ from: "v1", to: "v2", migrate: migrateV1ToV2 },
{ from: "v2", to: "v3", migrate: migrateV2ToV3 },
];Migrations run sequentially. If the version doesn't match any migration's
from field and hasn't reached CURRENT_KEYRING_VERSION, loadKeyring()
throws an error (fail-closed).
Version 1 keyrings used placeholder keyId values that were not derived from
the public key:
{
"version": "v1",
"keys": [
{
"keyId": "did:key:agent.james",
"alg": "ed25519",
"publicKeyHex": "03dee5..."
}
]
}The migrateV1ToV2 function:
- Derives the canonical
keyIdfrompublicKeyHexusingpublicKeyToDidKey(). - Recovers
agentId— If the entry has an explicitagentIdfield, that wins. Otherwise, if the oldkeyIdmatches the patterndid:key:<something>where<something>doesn't start withz6Mk, the suffix is extracted as theagentId. - Tracks legacy aliases — If the old
keyIddiffers from the derived one, it's stored inlegacyKeyIdsfor alias resolution.
After migration, the v1 entry above becomes:
{
"keyId": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
"alg": "ed25519",
"publicKeyHex": "03dee5...",
"agentId": "agent.james",
"legacyKeyIds": ["did:key:agent.james"]
}The in-memory Map returned by loadKeyring() is indexed by both the
canonical keyId and any legacyKeyIds:
Map {
"did:key:z6Mk..." → entry, // canonical (derived)
"did:key:agent.james" → entry, // legacy alias → same object
}
This is critical because old patches in the WARP graph have legacy keyIds baked into their signatures permanently. Those signatures can never be rewritten. The alias Map ensures that both old patches (signed with placeholder keyIds) and new seals (signed with derived keyIds) resolve to the same public key entry.
Every consumer of loadKeyring() — verify(), keyIdForAgent(),
validatePatchOps — gets this backward compatibility for free.
Version 2 keyrings had no concept of key activity — every key was implicitly the
sole key for its agent. Version 3 adds an active boolean to each entry,
enabling key rotation where multiple keys can share an agentId.
The migrateV2ToV3 function adds active: true to every existing entry (since
all v2 keys are the sole key for their agent, they are implicitly active).
After migration, a v2 entry gains the field:
{
"keyId": "did:key:z6Mk...",
"alg": "ed25519",
"publicKeyHex": "03dee5...",
"agentId": "agent.james",
"active": true
}When an agent needs a new key (compromise, expiration, periodic rotation), use
GuildSealService.rotateKey(agentId):
- The current active key is marked
active: falsein the keyring. - A new Ed25519 keypair is generated and registered as
active: true. - The old private key file is renamed to
<agentId>.sk.retired.<suffix>. - The old public key stays in the keyring for verification of historical signatures.
Important: keyIdForAgent() only returns active keys. verify() works with
any key in the keyring (active or retired) — old signatures remain verifiable
after rotation.
To add a new keyring version (e.g., v4 for key expiration):
- Define the migration function in
src/validation/crypto.ts. - Append to
KEYRING_MIGRATIONS. - Update
CURRENT_KEYRING_VERSION.
The pipeline runs v1 → v2 → v3 → ... automatically. Old v1 keyrings still work.
~/.xyph/trust/
├── keyring.json # Public key registry (local machine state)
├── agent.hal.sk # Hal's Ed25519 private key
├── agent.james.sk # James's Ed25519 private key
└── ...
- Use
XYPH_TRUST_DIRto point XYPH at a different trust directory when you want machine-local state somewhere other than~/.xyph/trust. keyring.jsonis local machine state, not a repo artifact.*.skfiles contain 64-character hex-encoded Ed25519 private keys. File permissions are set to0600(owner-only).
| Concern | Mitigation |
|---|---|
| Private key storage | Stored as hex in ~/.xyph/trust/<agent>.sk with 0600 permissions by default. Use XYPH_TRUST_DIR for a different local path, including a vault-backed directory. |
| Key material in memory | Node.js strings are immutable and cannot be zeroed after use. Private key hex stays in memory until garbage collected. Acknowledged limitation (L-19). |
| Algorithm agility | Only ed25519 is supported. The alg field exists for future algorithm upgrades. loadKeyring() rejects entries with unsupported algorithms. |
| Canonical JSON stability | The canonicalize() function produces deterministic output: sorted keys, no whitespace, preserved array order. Any change would break existing signatures. |
| Keyring tampering | If someone modifies a local keyring.json to swap a public key, they can forge seals for that agent on that machine. Protect the trust directory like other local credentials. |
| Legacy alias collisions | If two entries have overlapping legacy keyIds, first-write-wins in the Map. This is unlikely in practice and logged via legacyKeyIds. |
| File | Role |
|---|---|
src/validation/crypto.ts |
Core crypto primitives: base58btc, did:key encoding, canonicalization, BLAKE3, Ed25519 verify, keyring loading + migration pipeline |
src/domain/services/GuildSealService.ts |
High-level service: generateKeypair(), sign(), verify(), keyIdForAgent() |
src/validation/signPatchFixture.ts |
Test utility: sign PlanPatch documents with a given private key |
src/validation/validatePatchOps.ts |
Patch validation: invariant #12 uses keyring for signature verification |
test/unit/multibase.test.ts |
Tests: base58btc encoding, did:key derivation, migration pipeline, legacy round-trip |
test/unit/GuildSealService.test.ts |
Tests: keypair generation, sign/verify, tamper detection |
test/unit/key-rotation.test.ts |
Tests: active/retired keys, rotation lifecycle, cross-rotation verification |
Q: What happens if I lose my private key?
A: Use rotateKey() (or generate a new keypair with generate-key). The old key
entry stays in the keyring as active: false (old seals remain verifiable), and
the new entry is added as the active key.
Q: How do I rotate a key?
A: Call GuildSealService.rotateKey(agentId). This generates a new keypair,
marks the old key as retired, and updates the keyring atomically. Old signatures
remain verifiable because retired keys stay in the keyring.
Q: Can two agents share a keypair? A: Technically yes, but it defeats the purpose. Each agent should have its own keypair so that seals are attributable to a specific agent.
Q: Do I need to manually migrate my keyring?
A: No. loadKeyring() migrates transparently on read. Your keyring.json file
is left as-is on disk. New keypairs generated with generate-key are written in
the current schema version.
Q: What if I edit keyring.json by hand and set the wrong version?
A: If the version doesn't match any known migration path, loadKeyring() will
throw an error. Fix the version field to match the actual schema.
Q: Why base58btc and not base64?
A: The DID Key specification requires multibase encoding.
Base58btc (Bitcoin alphabet) is the canonical encoding for did:key, identified
by the z prefix. It avoids ambiguous characters (0/O, l/I) which makes
identifiers safer to copy/paste.