archon

Archon Keymaster — Service Specification

This document is the language-agnostic contract that any Keymaster implementation must satisfy. It is what the canonical TypeScript service at services/keymaster/server/ — backed by the packages/keymaster/ SDK and the packages/cipher/ primitives — already implements, and what a third implementation in Go, Python, Java, etc. would need to honor to be a drop-in replacement.

Implementation tracking for the current Python effort lives in drop-in-parity-checklist.md.

The TypeScript implementation is the source of truth. Any divergence between this document and the TypeScript code is a bug in this document; file an issue. A new implementation is conformant when it can be swapped into docker-compose.yml and the rest of the Archon stack — the CLI, the React wallet, Drawbridge, mediators, the Herald name service — keeps working.

Conventions. All wire formats are JSON over HTTP. Field names are camelCase. Timestamps are RFC 3339 / ISO 8601 in UTC unless otherwise noted. DIDs follow the did:cid:<cid> form and are resolved through the Gatekeeper service. Cryptographic primitives use secp256k1 for ECDSA and ECDH-ES, SHA-256 for hashing, AES-256-GCM for symmetric encryption. “MUST”, “SHOULD”, “MAY” follow RFC 2119.

Related specs. This document references the Gatekeeper service spec for DID resolution, registries, IPFS interaction, and the Operation / DidCidDocument types. Read the Gatekeeper spec first.


Table of contents

  1. Service responsibilities
  2. HTTP API contract
  3. Wallet model
  4. Cryptographic primitives
  5. Identity (ID) lifecycle
  6. Aliases and addresses
  7. Asset lifecycle
  8. Encryption envelope
  9. Credentials and challenges
  10. Groups, schemas, polls, vaults
  11. Files, images, dmail, notices
  12. Nostr and Lightning passthrough
  13. Storage backends
  14. Prometheus metrics contract
  15. Container and runtime contract
  16. Error responses and logging
  17. Test fixtures and reference implementation

1. Service responsibilities

The Keymaster is the wallet service — the only Archon component that holds private key material. It is responsible for:

It is not responsible for:

A Keymaster server holds a single wallet file. The wallet file in turn contains many IDs. Multi-tenancy is achieved by running multiple Keymaster processes (one wallet per process), not by sharing one process.


2. HTTP API contract

The service binds to ${ARCHON_BIND_ADDRESS}:${ARCHON_KEYMASTER_PORT} (default 0.0.0.0:4226). All API routes live under /api/v1. Two non-versioned routes exist: /metrics (Prometheus) and an /api/* catch-all for unhandled paths.

2.1 Response envelope

Most successful JSON responses are objects whose top-level keys are part of the contract. In practice, the exact key is route-specific and clients rely on the current KeymasterClient behavior. Examples:

Endpoint Response shape
GET /api/v1/wallet { "wallet": WalletFile }
GET /api/v1/ids { "ids": string[] }
POST /api/v1/ids { "did": string }
PUT /api/v1/wallet { "ok": boolean }
GET /api/v1/registries { "registries": string[] }
POST /api/v1/login { "adminApiKey": string }
GET /api/v1/wallet/mnemonic { "mnemonic": string }
GET /api/v1/addresses/check/:address AddressCheckResult (flat object)
POST /api/v1/lightning/invoice LightningInvoice (flat object)

Many status-style operations use { "ok": boolean }, and many create-style operations return { "did": "..." }, but there are shipped exceptions. For example, POST /api/v1/wallet/backup currently returns the backup DID in ok, and several Nostr / Lightning / address-check routes return flat objects rather than a single nested payload key.

A new implementation MUST honor every key the existing KeymasterClient parses (search the file for response.data.<key> to enumerate). The full key inventory, including routes that return unwrapped objects, is the contract.

2.1.1 Wallet provisioning

An empty wallet store and a lost one are the same read: every backend returns null for “no wallet here”, and none can distinguish a store that never held one from a store that used to. So a node cannot tell a genuine first run from a volume that failed to mount, a wiped database, or a changed ARCHON_KEYMASTER_DB.

Implementations MUST NOT create a wallet as a side effect of loading one. Provisioning MUST be a distinct operation, performed once at startup, and it MUST be observable: log a warning naming the store, and increment keymaster_wallets_created_total. A node mints a wallet once in its life, so a non-zero counter on a node that has been running is the signal that its store went missing.

Loading MUST NOT create. Provisioning is a separate call — loadOrCreateWallet in the TypeScript library, load_or_create_wallet in the Python one, or newWallet after a WalletNotFoundError — so it is visible at the call site rather than governed by configuration read somewhere else. The surfaces that provision are: the CLIs, in the create-wallet and create-id handlers (the only two on their allowlist of commands that may run before a wallet exists whose handlers assume one); the MCP server, in archon_create_wallet and archon_create_id; the browser wallets, in their first-run setup flow only; and the services, once at startup, where a WalletNotFoundError from loadWallet is answered with a warning, newWallet, and the counter. Every other caller reaching an empty store has lost one, and gets a WalletNotFoundError.

There is currently no way to make an empty store fatal instead; that is #1051.

2.2 Authentication

The “admin key” is the only auth boundary. There is no per-user authentication — the wallet itself is the identity vault, and possession of the admin key implies full access to everything in it.

2.3 CORS

The service MUST emit permissive CORS (Access-Control-Allow-Origin: *, -Methods: *, -Headers: *) and respond to preflight OPTIONS. This is required for the React wallet, Explorer, and any browser-based tooling.

2.4 Body-size limits

JSON body bounded by Express’s default unless overridden. Binary endpoints (/files, /images, IPFS attachments) are bounded by ARCHON_KEYMASTER_UPLOAD_LIMIT (default 10mb). Limit string format is the same as Gatekeeper’s: <digits>(b|kb|mb)?, case-insensitive.

2.5 Route inventory

There are 148 routes under /api/v1. Rather than list them all here, they are grouped into the functional sections below; complete and authoritative inventory:

grep -E "v1router\.(get|post|put|delete)\(" \
  services/keymaster/server/src/keymaster-api.ts

Each section below describes the contract for a route family (method/path/body/response semantics). The OpenAPI export at docs/keymaster-api.json lists every route with its swagger annotations.


3. Wallet model

The wallet is a single passphrase-encrypted JSON document stored in the configured backend. Its in-memory (decrypted) shape:

{
  "version": 2,
  "seed": {
    "mnemonicEnc": {                           // the BIP39 mnemonic encrypted with the passphrase
      "salt": "<base64(16 bytes)>",
      "iv":   "<base64(12 bytes)>",
      "data": "<base64(ciphertext + AES-GCM tag)>"
    }
  },
  "counter": <int>,                            // monotonically incremented per createId
  "current": "<id name>" | undefined,          // currently selected ID
  "ids": {
    "<id name>": IDInfo,
    ...
  },
  "aliases": { "<alias>": "<DID>", ... }
}

IDInfo:

{
  "did": "did:cid:...",
  "account": <int>,                            // BIP44-style account index
  "index": <int>,                              // key index inside the account
  "owned":   ["<did>", ...],                   // assets created by this ID
  "held":    ["<did>", ...],                   // credentials accepted by this ID
  "addresses": { "<address>": StoredAddressInfo, ... },
  "nostr":     { "nsec": "<nsec...>", ... } | undefined,
  "dmail":     { ... } | undefined,
  "notices":   { ... } | undefined
}

3.1 At-rest encryption

The on-disk wallet is WalletEncFile:

{
  "version": <int>,
  "seed": { "mnemonicEnc": {...} },            // same as decrypted form
  "enc":  "<JWE Compact string of the rest of the wallet>"
}

The enc field is built by:

  1. Decrypt seed.mnemonicEnc with the passphrase to get the BIP39 mnemonic.
  2. Derive the BIP32 root HDKey from the mnemonic.
  3. Generate a secp256k1 JWK pair from hdkey.privateKey.
  4. Take everything in the wallet except version and seed, JSON-encode it, and call cipher.encryptMessage(publicJwk, plaintext) (see §4.5).

To decrypt: derive the same JWK pair, JWE-decrypt enc, and merge the result back with version and seed.

3.2 Passphrase encryption

encryptWithPassphrase(plaintext, pass) and the matching decrypt produce { salt, iv, data }:

Implementation reference: packages/cipher/src/passphrase.ts.

3.3 Key derivation (BIP32 / BIP44-ish)

Each ID derives a private key from the wallet root using a derivation path that the TypeScript implementation defines. New implementations MUST match the path so that a wallet created by one can be loaded by another.

The current scheme:

The counter field on the wallet records how many IDs have been created overall and supplies the next account value. Removing an ID does NOT decrement the counter — account numbers are never reused.

3.4 Wallet operations

Route Behavior
GET /api/v1/wallet Returns the decrypted wallet object. Requires server to hold the passphrase (via ARCHON_PASSPHRASE).
PUT /api/v1/wallet Body: { "wallet": WalletFile }. Validates passphrase by decrypting or re-encrypting the submitted wallet and saves it. Returns { "ok": boolean }.
POST /api/v1/wallet/new Body: { "mnemonic"?: string, "overwrite"?: boolean }. Generates a new mnemonic if not supplied. Returns { "wallet": WalletFile }.
POST /api/v1/wallet/backup Encrypts the wallet to a new backup asset DID, records that DID on the seed bank document (see §3.5), and returns { "ok": "<backup DID>" }.
POST /api/v1/wallet/recover Restores the wallet from the seed bank using the current passphrase. Returns { "wallet": WalletFile }.
POST /api/v1/wallet/check Walks every DID owned/held/aliased and verifies it resolves. Returns { "check": { checked, invalid, deleted } }.
POST /api/v1/wallet/fix Removes invalid/deleted entries identified by check. Returns { "fix": { idsRemoved, ownedRemoved, heldRemoved, aliasesRemoved } }.
GET /api/v1/wallet/mnemonic Returns the decrypted BIP39 mnemonic as { "mnemonic": string }.
POST /api/v1/wallet/passphrase Body: { "passphrase": "<new>" }. Re-encrypts mnemonicEnc and the wallet body with a new passphrase. Returns { "ok": boolean }.
GET /api/v1/export/wallet/encrypted Returns the on-disk WalletEncFile (no decryption). Useful for manual backup.

3.5 Seed bank

The “seed bank” is a special DID that the wallet creates on first use to back itself up. Operations:

A new implementation MUST use the same anchor algorithm so a wallet backed up by the TS implementation can be recovered by the new one.

3.6 Concurrency

All wallet writes MUST be serialized. The TypeScript reference uses an async-promise lock (AbstractBase.updateWallet) to ensure a load-mutate- save cycle is atomic. Implementations MAY use any equivalent mechanism (file lock, mutex, optimistic concurrency with retry).


4. Cryptographic primitives

The Keymaster’s cryptographic surface lives in packages/cipher/src/. Reference implementations:

4.1 BIP39 / BIP32

4.2 secp256k1 JWK

Per the Gatekeeper spec §3.4:

{ "kty": "EC", "crv": "secp256k1",
  "x": "<base64url(32-byte X)>",
  "y": "<base64url(32-byte Y)>",
  "d": "<base64url(32-byte private)>"          // only on private JWKs
}

Convert JWK → SEC1-compressed (33 bytes): prefix is 0x02 if the last byte of Y is even, else 0x03, followed by the 32-byte X.

4.3 Signing and verification

4.4 Canonical JSON

The Keymaster uses the same canonical-JSON algorithm as the Gatekeeper — RFC 8785 JCS-equivalent. The TS implementation depends on the canonicalize npm package. DID generation, hashJSON, and Proof.proofValue signing all share this serialization. See Gatekeeper spec §4.1.

4.5 JWE envelope encryption

Encrypted messages use JWE Compact Serialization with ECDH-ES key agreement and A256GCM content encryption. Reference: packages/cipher/src/jwe.ts.

To encrypt for recipientPubKey:

  1. Generate ephemeral secp256k1 keypair.
  2. Build header { alg: "ECDH-ES", enc: "A256GCM", epk: { kty, crv, x, y } }, JSON-encode, base64url (the protected header headerB64).
  3. Compute sharedSecret = ECDH(ephemeral_priv, recipientPubKey).
  4. Derive content encryption key (CEK): cek = ConcatKDF(sharedSecret[1..], 256, "A256GCM") (per RFC 7518 §4.6.2; for ECDH-ES direct, algorithmId == enc).
  5. Generate random 96-bit IV.
  6. AEAD-encrypt plaintext with AES-256-GCM, key=cek, iv=iv, AAD=ascii(headerB64). Output is ciphertext || tag (16-byte tag).
  7. Assemble JWE Compact: headerB64 . "" . base64url(iv) . base64url(ciphertext) . base64url(tag) — the encrypted-key segment is empty for ECDH-ES direct.

Decryption: parse the five segments, derive CEK from epk + recipient private key + ConcatKDF, AEAD-decrypt with cipher_text || tag and AAD = ASCII bytes of the protected header.

A legacy XChaCha20-Poly1305 path exists (decryptMessageLegacy) for ciphertexts produced before the JWE migration. A conformant implementation MAY skip this path if it doesn’t need to read pre-JWE messages from existing wallets.

4.6 Proof of work

cipher.addProofOfWork(obj, difficulty) finds a nonce such that sha256(canonical_json(obj_with_nonce)) has at least difficulty leading zero bits. Used by some asset types (notably credentials) to deter spam. Difficulty is 0–256 inclusive; 0 is a no-op.


5. Identity (ID) lifecycle

An “ID” is a named, BIP32-derived agent DID. The wallet keeps a namespace of IDs; the current field selects which one is the “active” identity for asset-creating operations that don’t take an explicit owner.

Route Behavior
GET /api/v1/ids { "ids": string[] } — list of names, alphabetically.
POST /api/v1/ids Body: { "name": string, "options"?: { "registry"?: string } }. Derives a new keypair, creates an agent DID via Gatekeeper, stores IDInfo. Returns { "did": string }. Increments counter. Sets current if the wallet had none.
GET /api/v1/ids/:id Resolves the supplied ID name or DID through the same lookup path as GET /api/v1/did/:id and returns { "docs": DidCidDocument }.
DELETE /api/v1/ids/:id Removes the ID from the wallet (does NOT deactivate the DID on the gatekeeper). Returns { "ok": boolean }.
POST /api/v1/ids/:id/rename Body: { "name": string }. Returns { "ok": boolean }.
POST /api/v1/ids/:id/change-registry Body: { "registry": string }. Submits an update op moving the DID to the new registry. Returns { "ok": boolean }.
POST /api/v1/ids/:id/backup Anchors a JWE backup of the ID’s owned-asset list to its DID document. Returns { "ok": boolean }.
POST /api/v1/ids/:id/recover No body. The path :id IS the backup DID. Decrypts and re-imports an ID backup. Returns { "recovered": string } (the ID name).
GET /api/v1/ids/current { "current": string | undefined }
PUT /api/v1/ids/current Body: { "name": string }. Sets the active ID. Returns { "ok": boolean }.

DID resolution and document operations:

Route Behavior
GET /api/v1/did/:id Body-less; query params match Gatekeeper’s /did/:did (versionTime, versionSequence, confirm, verify). Accepts a DID or a wallet alias / ID name; resolves the alias first, then forwards to Gatekeeper. Returns { "docs": DidCidDocument }.
GET /api/v1/did/:id/check Read-only inspection. Returns { "report": CheckDIDResult }.
POST /api/v1/did/:id/repair Explicitly submit a supported repair. Returns { "report": RepairDIDResult }.
PUT /api/v1/did/:id Body: { "doc": DidCidDocument }. Submits an update operation signing it with the ID’s key. Returns { "ok": boolean }.
DELETE /api/v1/did/:id Submits a delete operation. Returns { "ok": boolean }.
POST /api/v1/agents/:id/test Tests whether the given DID resolves to a valid agent. Returns { "test": boolean }.

The :id parameter accepts (in this lookup order): wallet alias name, ID name, raw DID. Implementations MUST resolve aliases/names BEFORE calling the Gatekeeper.

5.1 Operation signing

For every DID write (createId, asset create, asset update, asset delete), the Keymaster:

  1. Builds the unsigned Operation (per Gatekeeper spec §3.1).
  2. Removes any existing proof field (defensive).
  3. Builds the proof configuration (Gatekeeper spec §3.2), which is the proof without its proofValue:
    • type = "DataIntegrityProof"
    • cryptosuite = "archon-ecdsa-secp256k1-jcs-2026"
    • created = now() in RFC 3339
    • verificationMethod per Gatekeeper §5.3 (relative #key-1 for agent create, otherwise <signerDid>#key-1)
    • proofPurpose = "capabilityInvocation" for the DID and asset write paths (create, update, delete), naming the relationship that authorizes a change to a DID document rather than one that proves who the signer is
  4. Computes msgHash = sha256( sha256(canonicalize(config)) ‖ sha256(canonicalize(op_without_proof)) ), which is what puts created and proofPurpose inside the signature.
  5. Signs with the ID’s private key: sig = ECDSA(secp256k1, priv, msgHash), and sets proofValue = base64url(sig).
  6. Submits via gatekeeper.createDID(op) / updateDID(op).

The seed bank’s create operation is the one exception: it keeps the legacy EcdsaSecp256k1Signature2019 proof and the sha256(canonicalize(op)) payload, because its DID is the CID of that operation including the proof. Its updates, and the wallet backup asset, use the suite above like everything else.


5.2 Checking and repairing DID documents

All three CLIs expose check-did <did> and repair-did <did>. The argument can also be a wallet identity name or alias. For example:

keymaster check-did Alice
keymaster repair-did Alice
# Service-backed CLI:
./scripts/archon-cli.js check-did Alice
./scripts/archon-cli.js repair-did Alice

check-did performs no document writes. Direct DID inspection works without owning that DID or having a wallet; names and aliases need the wallet that holds them. Both TypeScript and Python Keymaster expose the same feature (checkDID/repairDID, check_did/repair_did).

Wallets expose Repair… in the identity actions menu and in the DID viewer. In the standalone clients, use the button under Identities, or enter any agent/asset DID or alias under Aliases and choose Repair…. All surfaces use the same dialog:

  1. Opening it checks the latest document without submitting an operation.
  2. Review the findings and proposed component replacements. Healthy documents need no repair; unavailable repairs include an explanation.
  3. For an asset with a controller problem, Check controller DID inspects that agent separately. It does not repair either DID automatically.
  4. Repair DID explicitly submits the supported repair. The result separates acceptance from confirmation; an anchored-registry update may still await confirmation. Use Check DID again to refresh the status.

Changing the target clears its old report and requires a fresh check before repair. A failed submission also requires another check. The dialog always inspects the latest state even if the underlying viewer displays an older version.

The report contains did, type, versionId, confirmed, issues, changes, and canRepair, with a reason when signing authority or confirmation prevents repair. issues entries contain code, message, and optionally relatedDid. changes is the proposed component replacement, or null when there is no applicable repair this wallet can submit. Unresolvable DIDs return an error; deactivated DIDs report that repair is unavailable. An empty issues list means no known applicable problems.

The first supported repair handles agent operation permissions: missing capabilityInvocation membership for Keymaster’s operation-signing method and dangling references to unpublished methods. Keymaster currently signs operations with the first verification method. A repair requires the wallet’s usable signing key to match that method and the current document to be confirmed. It preserves valid permissions, other methods and relationships, data and registration. It does not grant every published key operation authority. Only #fragment references expand against the document DID; other method IDs remain distinct, matching Gatekeeper. A non-fragment relative operation-signing method is reported as unsupported rather than repairable. Both wallets search their derived keys for the confirmed operation key, including older keys still retained by the wallet. Rotation retitles existing operation permissions along with authentication and assertion references; it does not silently repair omitted permissions.

Assets do not gain signing permissions from their published methods. If an asset’s controller has a problem, the report identifies that agent as relatedDid. It recommends a separate repair only when this wallet can perform it; otherwise controller-repair-unavailable explains why, including deactivation or unavailable signing authority. No operation is submitted for an asset with no applicable repair.

repair-did is the explicit mutation action. It checks again and signs an ordinary update against the inspected version. If that version changes while repair is being prepared or signed, Gatekeeper rejects the stale predecessor; check the latest document and retry. Its report adds submitted: false for a no-op and true when Gatekeeper accepts the update. Check confirmed separately: an accepted chain-registry update can still be pending anchoring. Repeating repair after the changes are present is a no-op. Unavailable repairs return an error. These wallet tools do not change Gatekeeper acceptance or enable relationship enforcement under #1156.

6. Aliases and addresses

6.1 Aliases

An alias is a wallet-local short name for a DID. Aliases are not visible to the Gatekeeper or to other peers.

Route Behavior
GET /api/v1/aliases { "aliases": { "<alias>": "<DID>", ... } }
POST /api/v1/aliases Body: { "alias": string, "did": string }. Returns { "ok": boolean }.
GET /api/v1/aliases/:alias { "did": string } or 404.
DELETE /api/v1/aliases/:alias { "ok": boolean }.

Alias names: max length maxAliasLength (default 32 chars). Character set not enforced; clients SHOULD use ASCII identifiers.

6.2 Addresses (LUD-16)

An “address” is an external name@domain Lightning address (LUD-16) that the wallet has chosen to track for sending zaps.

Remote names, address discovery, and name-service API requests require public HTTPS destinations on every redirect hop. The @didcid/common/net package export automatically selects the DNS-protected transport in Node, including third-party Keymaster embeddings, without constructor configuration. DNS answers are validated inside the socket lookup, preserving the original Host and TLS hostname. Browser builds select the portable URL/hostname checks; browser JavaScript cannot validate or pin DNS/socket destinations.

Python validates all resolved IPv4/IPv6 answers, connects directly to a validated IP, and keeps the original Host header and TLS certificate hostname. Mixed public/private or empty DNS answers fail closed. Each redirect is resolved and validated again; Python’s guarded requests ignore environment proxies so a proxy cannot independently resolve the destination. TLS verification stays on.

Route Behavior
GET /api/v1/addresses { "addresses": { "<address>": StoredAddressInfo, ... } }
GET /api/v1/addresses/:domain Returns the locally stored current-ID address record for the supplied domain, or { "address": null } if none is stored. It does not resolve LNURL or remote DID anchors.
POST /api/v1/addresses/import Fetches https://<domain>/.well-known/names, imports any names that already point at the current ID’s DID, and returns { "addresses": Record<string, AddressInfo> }.
GET /api/v1/addresses/check/:address Probes whether the address is claimed/available/unsupported/unreachable. Returns a flat AddressCheckResult object: { "address": string, "status": ..., "available": boolean, "did": string | null }.
POST /api/v1/addresses Body: { "address": string }. Adds the address to the current ID.
POST /api/v1/addresses/publish Body: { "address": string, "name"?: string }. Publishes the address to the identity’s DID document.
DELETE /api/v1/addresses/publish Body: { "name"?: string }. Removes the published address from the identity’s DID document.
DELETE /api/v1/addresses/:address Removes the address.

7. Asset lifecycle

An “asset” is any DID owned by an agent that is not an agent itself. The DID type is asset, the controller is the owner agent, and the body of the DID document carries the asset payload.

Route Behavior
POST /api/v1/assets Body: { "data": <any JSON>, "options"?: CreateAssetOptions }. Creates an asset DID owned by the current ID. Returns { "did": string }.
GET /api/v1/assets { "assets": string[] } — DIDs the current ID owns.
GET /api/v1/assets/:id Resolves the asset; returns { "asset": <didDocumentData> }.
PUT /api/v1/assets/:id Body: { "data": <JSON> }. Submits an update op replacing the asset data.
POST /api/v1/assets/:id/transfer Body: { "controller": "<DID>" }. Reassigns ownership; the previous owner can no longer mutate the asset.
POST /api/v1/assets/:id/clone Creates a new asset DID with the same data, owned by the current ID.

CreateAssetOptions:

{
  "registry": "local" | "hyperswarm" | "BTC:..." | undefined,  // see 7.2 Registry selection
  "controller": "<DID>" | undefined,                            // defaults to current ID
  "validUntil": "<RFC 3339>" | undefined,                       // ephemeral expiry
  "alias": string | undefined                                   // also add this alias
}

Asset data MUST be valid JSON. createAsset itself does not enforce a size limit; the maxDataLength constant (8 KB) only gates inline storage of encrypted vault-item data.

7.1 Owned-list bookkeeping

When an ID creates an asset, its IDInfo.owned[] array is appended with the new DID. When an asset is transferred, the previous owner’s owned[] loses the DID and the new owner’s owned[] gains it (only for IDs in this wallet — transfers to external DIDs leave the local list shrunk).

wallet.check and wallet.fix rebuild this bookkeeping by walking every DID in owned and verifying it still resolves with the right controller.

7.2 Registry selection

An asset’s registry is chosen in this order:

  1. CreateAssetOptions.registry, when the caller supplies one.
  2. The ephemeral registry (hyperswarm) for short-lived assets the Keymaster creates on the caller’s behalf — challenges, challenge responses, credential offers, poll and ballot notices, and dmail notices. Keymaster never selects a chain registry for these, but the choice is only a default: a caller who supplies registry overrides it per (1).
  3. Otherwise the wallet’s default registry (ARCHON_DEFAULT_REGISTRY, hyperswarm when unset).

Any asset carrying validUntil — every ephemeral asset, plus any the caller marks that way — is omitted from IDInfo.owned[], since it will be garbage-collected when it expires.

Implementations MUST then apply this invariant:

If the controlling agent is registered on the local registry, the asset operation MUST also be local.

The check is one-directional — a non-local agent may own assets on any registry, including local — and applies to asset creation only; Gatekeeper does not re-check the controller’s registry on update or delete. It exists because Gatekeeper refuses an asset create whose controller resolves to a local registration when the operation itself is not local (Invalid operation: non-local registry=<registry>; see Gatekeeper spec §7.1, step 4). Without it, a local-only deployment cannot author challenges or credentials at all, since its agents can never author the ephemeral assets those flows depend on.

Implementations MUST enforce this before signing, by downgrading the registry to local — including when the caller asked for something else, because Gatekeeper would refuse that operation outright. Gatekeeper MUST NOT rewrite the registry itself: registration.registry is covered by the operation proof and by the CID the DID is derived from, so mutating it server-side would both invalidate the signature and change the DID.

Resolve the controller with confirm: true, as Gatekeeper does, so a pending change-registry update cannot let a mismatch through.

Because the controlling agent’s registry is what matters — not the wallet-wide default — this MUST be evaluated per operation against the agent that signs it. Deriving it from the default registry alone is insufficient: it misses a local agent under a non-local default, and wrongly downgrades a non-local agent under a local default.

Challenges additionally default to a validUntil one hour in the future when the caller does not supply one.


8. Encryption envelope

The Keymaster’s encrypt/decrypt routes are thin wrappers around JWE (§4.5) that store the ciphertext as an asset DID owned by the sender:

Route Behavior
POST /api/v1/keys/encrypt/message Body: { "msg": string, "receiver": "<DID>", "options"?: EncryptOptions }. Resolves the receiver, JWE-encrypts the UTF-8 message bytes to the receiver’s verificationMethod[0].publicKeyJwk, creates an asset DID containing the ciphertext envelope, returns { "did": string }.
POST /api/v1/keys/decrypt/message Body: { "did": string }. Resolves the asset, locates the receiver’s private key in the wallet, decrypts the JWE, returns { "message": string }.
POST /api/v1/keys/encrypt/json Same as encrypt/message but JSON.stringifys the input first.
POST /api/v1/keys/decrypt/json Same as decrypt/message but JSON.parses the result.
POST /api/v1/keys/sign Body: { "contents": "<JSON string>" }. The server parses contents, canonicalizes the resulting JSON value, signs it with the current ID’s key, and returns { "signed": <input with .proof> }.
POST /api/v1/keys/verify Body: { "json": <signed JSON> }. Verifies json.proof.proofValue. Returns { "ok": boolean }.
POST /api/v1/keys/rotate Generates a new keypair for the current ID, submits an update op replacing the verification method.

EncryptOptions extends CreateAssetOptions:

{
  // CreateAssetOptions fields plus:
  "encryptForSender": true | false | undefined,   // encrypt twice so sender can re-read later
  "includeHash":      true | false | undefined    // record sha256(plaintext) on the envelope
}

The on-asset envelope shape (didDocumentData):

{
  "encrypted": {
    "sender":          "<DID>",
    "created":         "<RFC 3339>",
    "cipher_hash":     "<hex>" | null,            // present iff includeHash
    "cipher_sender":   "<JWE>" | null,            // present iff encryptForSender
    "cipher_receiver": "<JWE>"
  }
}

decryptMessage chooses the ciphertext by sender identity: if the current ID is the sender and cipher_sender is present, it uses cipher_sender (sender re-reading own message); otherwise it uses cipher_receiver (current ID is receiver).


9. Credentials and challenges

W3C-compliant verifiable credentials, encoded as W3C VC v2 JSON.

VerifiableCredential = {
  "@context": ["https://www.w3.org/ns/credentials/v2", ...],
  "type": ["VerifiableCredential", ...],
  "issuer": "<DID>",
  "validFrom":  "<RFC 3339>",
  "validUntil": "<RFC 3339>" | undefined,
  "credentialSchema": { "id": "<schema DID>", "type": "JsonSchema" },
  "credentialSubject": { "id": "<DID>", ...claims },
  "proof": Proof
}

9.1 Issuing flow

  1. POST /api/v1/credentials/bind — { "subject": DID, "options"?: { schema, validFrom, validUntil, claims, types } } → returns the { "credential": <unsigned VC> }.
  2. POST /api/v1/credentials/issued — { "credential": <VC>, "options"?: IssueCredentialsOptions } → signs (via the issuer ID’s key into proof), encrypts to the subject (JWE), creates an asset DID, returns { "did": string }.
  3. POST /api/v1/credentials/issued/:did/send — emits a notice to the subject (see §11).
  4. Subject calls POST /api/v1/credentials/held to “accept” — decrypts the JWE, stores DID in IDInfo.held[].

9.2 Held / Issued endpoints

Route Behavior
GET /api/v1/credentials/held { "held": string[] } of accepted credential DIDs.
POST /api/v1/credentials/held Body: { "did": string }. Accepts/imports a credential.
GET /api/v1/credentials/held/:did { "credential": VerifiableCredential }.
DELETE /api/v1/credentials/held/:did Removes from held[] (does not revoke).
POST /api/v1/credentials/held/:did/publish Body: { "options"?: { reveal?: boolean } }. Anchors a stripped (or full, if reveal=true) credential onto the holder’s DID document.
POST /api/v1/credentials/held/:did/unpublish Removes a published credential from the holder’s DID document.
GET /api/v1/credentials/issued { "issued": string[] } of credentials the current ID has issued.
POST /api/v1/credentials/issued Issue (see above).
GET /api/v1/credentials/issued/:did { "credential": VerifiableCredential }.
POST /api/v1/credentials/issued/:did Body: { "credential": VC }. Update an in-flight issued credential (e.g. rotate claims).
POST /api/v1/credentials/issued/:did/send Send/notify the subject.
DELETE /api/v1/credentials/issued/:did Revoke (deactivates the asset DID).

9.3 Challenges and responses

A “challenge” is a JSON spec from a verifier asking the holder to present specific credentials (by schema and optionally by issuer). The verifier-prover protocol:

Route Behavior
GET /api/v1/challenge Creates a challenge asset using default parameters and returns { "did": string }. There is no separate template-returning HTTP route in the current TS server.
POST /api/v1/challenge Body: { "challenge"?: Challenge, "options"?: { registry?, validUntil? } }. Persists the challenge as an asset DID and returns { "did": string }.
POST /api/v1/response Body: { "challenge": string, "options"?: CreateResponseOptions }. Holder gathers matching held credentials, builds a ChallengeResponse, encrypts to the challenger, and creates a response asset. Returns { "did": string }.
POST /api/v1/response/verify Body: { "response": string, "options"?: { retries?, delay?, versionTime?, versionSequence? } }. Verifier decrypts, validates each presented VC’s signature against its issuer’s key, and returns { "verify": ChallengeResponse }. See §9.4 for the version selectors.

Challenge:

{
  "credentials": [
    { "schema": "<schema DID>", "issuers"?: ["<DID>", ...] }
  ]
}

ChallengeResponse:

{
  "challenge": "<challenge DID>",
  "credentials": [{ "vc": "<DID of the VC>", "vp": "<DID of the VP envelope>" }],
  "requested": <int>,
  "fulfilled": <int>,
  "match":     <bool>,
  "vps":       <unknown[]>,                     // verified-presentation payloads
  "responder": "<DID>"
}

Each presentation (vp) is a separate asset that re-encrypts the credential’s exact plaintext to the challenger with includeHash, so its cipher_hash equals the credential’s. Verification counts a presented credential only when:

  1. the credential and presentation both resolve to live encrypted assets (a revoked credential or presentation is not counted);
  2. both carry a cipher_hash and the two are equal, binding the presentation to the credential DID’s current content;
  3. the decrypted presentation has a type array and a valid proof by its issuer — a proof by any other key does not count;
  4. the credential lives at the DID presented for it: its signed id equals that DID, or, for credentials issued before they carried an id (#948), the issuer controls the credential asset. A holder therefore cannot copy a revoked credential to a fresh asset;
  5. it meets a challenge request: its credentialSchema is the request’s schema and, when the request lists issuers, its issuer is among them.

Requests and credentials are paired one to one: each request is met by at most one credential, and each credential DID meets at most one request. Both the holder choosing what to present and the verifier checking it find a pairing that meets as many requests as possible (a maximum bipartite matching), so the result does not depend on the order credentials are held or presented. vps holds the credentials in that pairing, and match is whether every request is met. requested and fulfilled are the responder’s own counts, returned as sent.

An entry whose vc or vp is not a DID string is not counted. Python keymasters before #1300 embedded the credential itself as vp; nothing binds that copy to the credential DID, so such responses do not verify. Responses default to a one-hour validUntil, so these do not persist.

Both ports create and verify this format. tests/fixtures/response-interop.json holds responses each port created and the other verified against a real Gatekeeper; each unit suite re-verifies the other port’s responses from it (regenerate with tests/keymaster/generate-response-interop-vectors.mjs).

9.4 Historical response verification

By default verifyResponse checks the current state of every DID involved, so a response that verified before its credential was revoked fails afterward. The versionTime and versionSequence options verify the response as it stood at an earlier point instead, using Gatekeeper’s existing historical resolution.

Option Meaning
versionTime RFC 3339 date-time with an offset (for example 2026-09-01T00:15:00Z) that sets the verification context. Other date formats, times without an offset, and impossible dates are rejected. The response, its challenge, and each credential and presentation DID — including the documents decrypted during verification — are resolved as of this time.
versionSequence Version of the response DID to verify. It requires versionTime: a response version says nothing about when its references were checked, so versionTime still selects the context of the challenge, credential and presentation DIDs, and must not precede the selected response version.

Historical reads never fall back to current state. A DID created after versionTime, a response version later than versionTime, or a versionSequence the response does not have is an Invalid parameter error. Gatekeeper reports created, updated and deleted to the second, and Keymaster’s own checks — whether a DID existed by versionTime, and whether the selected response version precedes it — compare those reported times. A cutoff inside the same second as a creation or the selected response version is therefore treated as including it. Gatekeeper applies versionTime to each DID’s later updates at full precision.

Proof-key selection is unchanged: each credential signature is still checked against the issuer’s key at the proof’s created time, and each encrypted document against its sender’s key at the document’s creation. Selecting a credential-status cutoff does not move those.

For chain-registered DIDs, each update’s time is its anchoring block’s time once the anchor is imported, so a revocation takes effect at the block that recorded it. Before the anchor arrives, the revocation’s own signed time bounds it.

A caller-selected versionTime describes the verification context. It is not by itself evidence that an external action — an access grant, a login — happened then; establishing that timing, and having the relevant chain history available, remains the caller’s responsibility.

# Current state
keymaster verify-response did:cid:bagaaiera...
# As of a past time
keymaster verify-response did:cid:bagaaiera... --version-time 2026-09-01T00:15:00Z
# Response version 1, with references as of that time
keymaster verify-response did:cid:bagaaiera... -t 2026-09-01T00:15:00Z -s 1
await keymaster.verifyResponse(responseDid, { versionTime: '2026-09-01T00:15:00Z' });
keymaster.verify_response(response_did, {"versionTime": "2026-09-01T00:15:00Z"})

10. Groups, schemas, polls, vaults

Each of these is a typed asset whose didDocumentData follows a specific shape.

10.1 Groups

{ "version": 2, "members": ["<DID>", ...] }
Route Behavior
POST /api/v1/groups { "name": string, "options"?: CreateAssetOptions } → { "did": string }.
GET /api/v1/groups { "groups": string[] }.
GET /api/v1/groups/:name { "group": Group }.
POST /api/v1/groups/:name/add { "member": string } → { "ok": boolean }.
POST /api/v1/groups/:name/remove { "member": string } → { "ok": boolean }.
POST /api/v1/groups/:name/test { "member"?: string }. Returns { "test": boolean } indicating membership (or whether it is a valid group).

10.2 Schemas

A schema is a JSON Schema document used as a credential template.

Route Behavior
POST /api/v1/schemas { "schema"?: <JSON>, "options"?: CreateAssetOptions } → { "did": string }.
GET /api/v1/schemas { "schemas": string[] }.
GET /api/v1/schemas/:id { "schema": <JSON> }.
PUT /api/v1/schemas/:id { "schema": <JSON> } → { "ok": boolean }.
POST /api/v1/schemas/:id/test Returns { "test": boolean }.
POST /api/v1/schemas/:id/template Returns { "template": <JSON> } — an empty instance matching the schema.

10.3 Polls

PollConfig = {
  "version": 2,
  "name":        string,
  "description": string,
  "options":     string[],      // 2 to 10 choices
  "deadline":    "<RFC 3339>"   // must be in the future at creation
}

A poll is a vault (§10.4); the poll DID is the vault DID and its owner is the poll owner. It has no separate voter group, ballot list or poll asset data. Everything lives in the vault’s items:

Vault item Content
poll The PollConfig, as UTF-8 JSON. A vault is a poll exactly when this item exists.
<ballot key> One per voter: the voter’s ballot DID, as UTF-8 text.
results The published PollResults, as UTF-8 JSON. Present only after publishPoll.
PollResults = {
  "tally":   [{ "vote": int, "option": string, "count": int }],   // vote 0 is "spoil"
  "ballots"?: [{ "voter": "<DID>", "vote": int, "option": string, "received": "<RFC 3339>" }],
  "votes"?:  { "eligible": int, "received": int, "pending": int },
  "final"?:  boolean
}
Route Behavior
GET /api/v1/templates/poll { "template": PollConfig } — an example config.
GET /api/v1/polls ?owner=<DID> optional. { "polls": string[] }.
POST /api/v1/polls { "poll": PollConfig, "options"?: VaultOptions } → { "did": string }.
GET /api/v1/polls/:poll { "poll": PollConfig }.
GET /api/v1/polls/:poll/test { "test": boolean }.
GET /api/v1/polls/:poll/view { "poll": ViewPollResult }.
POST /api/v1/polls/:poll/send Notifies the vault members (not the owner) of the poll. { "did": string } (the notice).
POST /api/v1/polls/:poll/vote { "vote": int, "options"?: { "registry"?, "validUntil"? } } → { "did": string } (the ballot).
POST /api/v1/polls/ballot/send { "ballot": string, "poll": string } → { "did": string } (the notice).
GET /api/v1/polls/ballot/:did { "ballot": ViewBallotResult }.
PUT /api/v1/polls/update Owner only. { "ballot": string } → { "ok": boolean }.
POST /api/v1/polls/:poll/publish Owner only. { "options"?: { "reveal"?: boolean } } → { "ok": boolean }.
POST /api/v1/polls/:poll/unpublish Owner only. { "ok": boolean }.
POST /api/v1/polls/:poll/voters { "memberId": string } → { "ok": boolean }.
DELETE /api/v1/polls/:poll/voters/:voter { "ok": boolean }.
GET /api/v1/polls/:poll/voters { "voters": Record<string, …> }.

See the KeymasterInterface for exact method signatures.

10.4 Vaults

A vault is an end-to-end-encrypted shared key-value store with multi-member access. Access control uses per-member ECDH-ES key wrapping.

Vault = {
  "version": <int>,
  "publicJwk": EcdsaJwkPublic,                   // vault's owning public key
  "salt":      "<base64>",
  "config":    "<JWE>",                          // encrypted metadata
  "members":   "<JWE>",                          // encrypted member list
  "keys":      { "<memberDid>": "<JWE-wrapped CEK>" },
  "items":     "<JWE>",                          // encrypted item map
  "sha256":    "<hex>"                           // tamper detection
}
Route Behavior
POST /api/v1/vaults { "options"?: VaultOptions } → { "did": string }.
GET /api/v1/vaults/:id { "vault": Vault }.
POST /api/v1/vaults/:id/test Membership / sanity test.
POST /api/v1/vaults/:id/members Add member.
DELETE /api/v1/vaults/:id/members/:member Remove member.
GET /api/v1/vaults/:id/members { "members": string[] }.
POST /api/v1/vaults/:id/items Body: raw application/octet-stream bytes. The item name is supplied via the X-Options header (JSON, e.g. {"name":"..."}).
DELETE /api/v1/vaults/:id/items/:name  
GET /api/v1/vaults/:id/items { "items": Record<string, ...> }.
GET /api/v1/vaults/:id/items/:name Returns the binary item (decrypted).

11. Files, images, dmail, notices

11.1 Files

Binary blobs stored on IPFS via the Gatekeeper, with metadata wrapped as an asset DID.

Route Behavior
POST /api/v1/files Body: application/octet-stream (up to ARCHON_KEYMASTER_UPLOAD_LIMIT). Options (filename, contentType, bytes, etc.) are supplied as a JSON string in the X-Options header; bytes is inferred from Content-Length when not provided. Pushes bytes to Gatekeeper /ipfs/data, creates asset, returns { "did": string }.
PUT /api/v1/files/:id Replace the file under the same DID.
GET /api/v1/files/:id { "file": FileAsset } (metadata + base64 of bytes).
POST /api/v1/files/:id/test Sanity check.

11.2 Images

Same as files but with image-specific metadata (width, height).

11.3 IPFS passthrough

Route Behavior
GET /api/v1/ipfs/data/:cid Streams the raw bytes from Gatekeeper’s IPFS proxy. The Keymaster does NOT expose JSON / text / stream IPFS endpoints — only this byte-level read for clients that already have a CID.

11.4 Dmail (decentralized mail)

Encrypted DM with optional file attachments.

Route Behavior
POST /api/v1/dmail { "message": DmailMessage, "options"?: CreateAssetOptions } → { "did": string }. Creates the asset; does NOT send (no notice yet).
PUT /api/v1/dmail/:id Update message content.
DELETE /api/v1/dmail/:id Remove from local index.
POST /api/v1/dmail/:id/send Notifies recipients (creates notice asset DIDs).
POST /api/v1/dmail/:id/file File the message under tags. Body: { "tags": string[] }.
POST /api/v1/dmail/import { "did": string } — import a dmail received via notice.
GET /api/v1/dmail { "dmail": Record<DID, DmailItem> }.
GET /api/v1/dmail/:id { "message": DmailMessage }.
GET /api/v1/dmail/:id/attachments { "attachments": Record<string, ...> }.
POST /api/v1/dmail/:id/attachments Add an attachment. Body: raw application/octet-stream bytes. The attachment name is supplied via the X-Options header (JSON, e.g. {"name":"..."}).
DELETE /api/v1/dmail/:id/attachments/:name  
GET /api/v1/dmail/:id/attachments/:name Returns the binary attachment.

11.5 Notices

A notice is a small asset DID created by one ID and delivered to one or more recipients. It points at a payload DID (a credential, dmail, etc.).

NoticeMessage = { "to": ["<DID>", ...], "dids": ["<DID>", ...] }
Route Behavior
POST /api/v1/notices { "message": NoticeMessage, "options": CreateAssetOptions } → { "did": string }.
PUT /api/v1/notices/:id Update notice content.
POST /api/v1/notices/refresh Walk each ID’s address book and discover/import any new notices addressed to it. Returns { "ok": boolean }.

12. Nostr and Lightning passthrough

12.1 Nostr

The wallet can host a Nostr identity for each ID (one nsec per ID, derived from the same secp256k1 key as the DID).

Route Behavior
POST /api/v1/nostr Body: { "id"?: string }. Generates Nostr keys for the named ID (or current). Returns NostrKeys (flat object: { npub, pubkey }).
DELETE /api/v1/nostr Removes Nostr keys from the ID.
POST /api/v1/nostr/import Body: { "nsec": string, "id"?: string }. Imports an externally-generated nsec.
POST /api/v1/nostr/nsec Body: { "id"?: string }. Returns { "nsec": string } (export).
POST /api/v1/nostr/sign Body: { "event": NostrEvent, "id"?: string }. Signs the event (Schnorr / BIP340) and returns the signed NostrEvent (flat object) with id/pubkey/sig populated.

NostrKeys = { npub: <bech32>, pubkey: <hex> }. nsec is a bech32-encoded 32-byte private key with prefix nsec.

12.2 Lightning

Lightning routes are passthrough: the Keymaster forwards them to the Drawbridge service, which holds the actual LNbits credentials. The Drawbridge URL is implicit in the gatekeeper client (it is implemented as an extension of the gatekeeper client); a compliant Keymaster MUST reach Lightning functionality via the same DrawbridgeClient.

Route Behavior
POST /api/v1/lightning { "id"?: string }. Provisions an LNbits wallet for the ID, stores LightningConfig in IDInfo.
DELETE /api/v1/lightning Decommissions the Lightning wallet.
POST /api/v1/lightning/balance Returns LightningBalance (flat object).
POST /api/v1/lightning/invoice { "amount": number, "memo": string, "id"?: string } → LightningInvoice (flat object).
POST /api/v1/lightning/pay { "bolt11": string, "id"?: string } → LightningPayment (flat object).
POST /api/v1/lightning/payment { "paymentHash": string, "id"?: string } → LightningPaymentStatus (flat object).
POST /api/v1/lightning/decode { "bolt11": string } → DecodedLightningInvoice (flat object).
POST /api/v1/lightning/publish Publishes the ID’s invoice key to its DID document (so others can zap by DID).
POST /api/v1/lightning/unpublish  
POST /api/v1/lightning/zap { "did": string, "amount": number, "memo"?: string, "id"?: string } where did is the recipient (DID, alias, or LUD-16) and id is the optional sender identity. Resolves recipient and pays.
POST /api/v1/lightning/payments { "payments": LightningPaymentRecord[] }.

13. Storage backends

Reference implementations: JSON file (default), SQLite, Redis, MongoDB. Selector: ARCHON_KEYMASTER_DB ∈ { json, sqlite, redis, mongodb }.

A storage backend implements:

interface WalletBase {
  saveWallet(wallet: StoredWallet, overwrite?: boolean): Promise<boolean>;
  loadWallet(): Promise<StoredWallet | null>;
  updateWallet(mutator: (wallet: StoredWallet) => void): Promise<void>;
}

updateWallet MUST be atomic and serialize concurrent callers. Implementations MAY also wrap the chosen backend in a write-through in-memory WalletCache (enabled via ARCHON_WALLET_CACHE=true) to avoid re-reading the entire wallet on every request.

13.1 Filesystem layout

Backend Path
json ${dataFolder}/wallet.json (default data/wallet.json)
sqlite ${dataFolder}/wallet.db

The container mounts ./data at /app/keymaster/data.

13.2 Wire shape

The stored payload is always the JSON-serialized WalletEncFile (see §3.1). Backends that store rows/documents use the wallet’s version as a discriminator; everything else is opaque JSON to the storage layer.


14. Prometheus metrics contract

Exposed at GET /metrics.

Metric Type Labels
http_requests_total counter method, route, status
http_request_duration_seconds histogram (buckets: 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5) method, route, status
wallet_operations_total counter operation, status
keymaster_wallets_created_total counter —
service_version_info gauge version, commit

Plus Prometheus default process metrics (process_resident_memory_bytes, process_start_time_seconds, etc.) — implementations SHOULD emit them so the existing Grafana dashboards keep working.

14.1 Route normalization

The route label collapses dynamic segments. Required:

/did/<id>          -> /did/:id
/ids/<id>          -> /ids/:id
/aliases/<alias>   -> /aliases/:alias
/addresses/check/<x>     -> /addresses/check/:address
/addresses/<x>     -> /addresses/:address
/groups/<name>     -> /groups/:name
/schemas/<id>      -> /schemas/:id
/agents/<id>       -> /agents/:id
/credentials/held/<did>   -> /credentials/held/:did
/credentials/issued/<did> -> /credentials/issued/:did
/assets/<id>       -> /assets/:id
/polls/<p>/voters/<v>     -> /polls/:poll/voters/:voter
/polls/ballot/<did>       -> /polls/ballot/:did
/polls/<p>         -> /polls/:poll
/images/<id>       -> /images/:id
/files/<id>        -> /files/:id
/ipfs/data/<cid>   -> /ipfs/data/:cid
/vaults/<id>/members/<m>  -> /vaults/:id/members/:member
/vaults/<id>/items/<n>    -> /vaults/:id/items/:name
/vaults/<id>       -> /vaults/:id
/dmail/<id>/attachments/<n>  -> /dmail/:id/attachments/:name
/dmail/<id>        -> /dmail/:id
/notices/<id>      -> /notices/:id

Unlike the Gatekeeper, the Keymaster TS implementation passes req.path to the metric label, which does not include the /api/v1 prefix in the current version. A new implementation SHOULD match this for backward compatibility with existing dashboards. (If you add the /api/v1 prefix, audit dashboard queries first.)

14.2 wallet_operations_total

Incremented by the implementation on key wallet-mutation paths (createId, removeId, etc.) with status: "success" | "error". The TS implementation increments this on createId only at present; new implementations MAY cover more operations but MUST NOT remove existing labels.


15. Container and runtime contract

15.1 Image

15.2 Environment variables

Variable Default Meaning
ARCHON_KEYMASTER_PORT 4226 HTTP listen port.
ARCHON_BIND_ADDRESS 0.0.0.0 HTTP bind address.
ARCHON_GATEKEEPER_API_KEY unset Optional X-Archon-Admin-Key sent to the configured trusted upstream; a matching Drawbridge key bypasses L402. Never defaults to the local admin key.
ARCHON_KEYMASTER_GATEKEEPER_API_KEY unset Compose-only mapping to ARCHON_GATEKEEPER_API_KEY; set to ${ARCHON_ADMIN_API_KEY} only when the upstream is your trusted Drawbridge.
ARCHON_GATEKEEPER_URL http://localhost:4224 Gatekeeper base URL.
ARCHON_KEYMASTER_GATEKEEPER_URL unset Compose-only override for Keymaster’s internal ARCHON_GATEKEEPER_URL; set to http://drawbridge:4222 when server-side DIDComm should use Drawbridge’s /didcomm gateway.
ARCHON_NODE_ID empty Required. Name of the canonical agent ID this server provisions on startup.
ARCHON_KEYMASTER_DB json Storage backend (json, sqlite, redis, mongodb).
ARCHON_PASSPHRASE empty Wallet passphrase, and the credential /login checks. The same value the CLIs, the lightning scripts and the MCP server read. Required — the service refuses to start without it.
ARCHON_ENCRYPTED_PASSPHRASE empty Older name for ARCHON_PASSPHRASE, still read. Set only one; the newer name wins. Both set to different values is warned about at startup — see below.
ARCHON_WALLET_CACHE false Enables the in-memory write-through cache.
ARCHON_DEFAULT_REGISTRY unset (uses hyperswarm in code) Default registry for created DIDs.
ARCHON_KEYMASTER_UPLOAD_LIMIT 10mb Body cap for /files, /images, dmail attachments.
ARCHON_ADMIN_API_KEY empty Admin auth header value.
GIT_COMMIT unknown Build commit.
PBKDF2_ITERATIONS 100000 Override PBKDF2 cost for encryptWithPassphrase. Implementations MUST keep 100000 as the default to interop with existing wallets.

15.3 Startup sequence

Where the value comes from. Under Docker Compose, a variable exported in the shell is substituted in preference to the same name in .env, so a stale export ARCHON_PASSPHRASE=<old value> left in a shell profile displaces the value in the file with nothing in either file changed — and the wallet then refuses to decrypt. An empty export does not do this: the reader treats it as unset and falls back to ARCHON_ENCRYPTED_PASSPHRASE, or startup refuses a missing passphrase outright. The same applies to an export of ARCHON_ENCRYPTED_PASSPHRASE on a deployment that uses the older name.

The service warns at startup when both names hold different values. To find out whether this shell is the source, and to ignore the export for one run:

env | grep -c '^ARCHON_PASSPHRASE='            # 1 means this shell exports it
env -u ARCHON_PASSPHRASE docker compose up -d  # start without the export

Substitute ARCHON_ENCRYPTED_PASSPHRASE on a deployment using the older name. Neither command prints the secret; docker compose config would.

  1. Validate ARCHON_ADMIN_API_KEY and ARCHON_PASSPHRASE; exit non-zero if either is unset (§2.2).
  2. Bind HTTP listener.
  3. Connect to Gatekeeper (waitUntilReady=true, polling every 5s).
  4. Initialize the wallet backend and construct the in-process Keymaster with it, the Gatekeeper client, and the cipher implementation.
  5. Load the wallet. If the store is empty, provision one: log the warning and increment keymaster_wallets_created_total (§2.1.1). Any other failure to load — an unreadable store, a wrong passphrase — ends the process with a non-zero exit rather than leaving a listener with no Keymaster behind it.
  6. Run waitForNodeId():
    • ARCHON_NODE_ID MUST be set.
    • If the wallet doesn’t have an ID with that name, create one (keymaster.createId(ARCHON_NODE_ID)).
    • Loop until the new ID resolves on the Gatekeeper (10s between polls).
  7. Mark serverReady = true.

/api/v1/ready returns { "ready": serverReady } and MUST return { "ready": false } until the node ID resolves.

15.4 Healthcheck

test "$(wget -qO- http://127.0.0.1:4226/api/v1/ready | jq -r .ready)" = "true"

(The TS implementation lets the /ready body be { "ready": true } JSON. A simple grep for "ready":true is also acceptable.)

15.5 Graceful shutdown

On SIGTERM / SIGINT, stop accepting connections, drain in-flight requests, close the wallet backend, exit. The TS reference uses server.close() then process.exit(0).


16. Error responses and logging

16.1 Error envelope

16.2 Common errors

Status Error string Meaning
401 Incorrect passphrase /login body’s passphrase didn’t match.
401 Unauthorized — valid admin API key required Missing/wrong X-Archon-Admin-Key.
404 DID not found Resolution miss.
500 Incorrect passphrase. decryptWalletFromStorage couldn’t decrypt with the configured passphrase. (Note: trailing period; Keymaster wraps the cipher error.)
500 <thrown error toString> Unhandled exception.

16.3 Status classification

Error status is assigned by the kind of failure, not per route:

Failure Status
Bad or invalid input (InvalidParameterError, InvalidDIDError, InvalidOperationError, KeymasterError) 400
Not found — an unknown ID (UnknownIDError) or an empty wallet store (WalletNotFoundError) 404
Missing/incorrect passphrase or admin key 401 / 403
An unavailable upstream service (LightningUnavailableError), or a Gatekeeper that cannot be reached (GatekeeperUnavailableError) 503
Gatekeeper did not respond within 30 seconds (GatekeeperTimeoutError) 504
Unexpected fault — IPFS unreachable, any unhandled exception 500

Both services give their Gatekeeper client the same 30-second request timeout, and both clients raise GatekeeperTimeoutError or GatekeeperUnavailableError when no response arrives, so a Gatekeeper outage is reported as such rather than as an unexplained 500 or a request that never returns.

Both services classify this way: the Python service as of #1107, the TypeScript service as of #1108. Before #1108 the TypeScript routes hardcoded a status in each catch block, inconsistently — 57 answered 400, 79 answered 500, 22 answered 404 for the same class of error — and a shared sendError helper (keymaster-error-status.ts) now replaces those with the classification above.

Two deliberate exceptions to this classification remain:

  1. Resolve-style GETs answer 404. In the TypeScript service, where the not-found semantics are the point of the route, GET /did/{id}, /ids/{id}, /assets/{id}, /groups/{name}, /schemas/{id} and /dmail/{id} collapse a resolution failure to 404. A Gatekeeper timeout or outage is not collapsed: it keeps its 504 or 503, since it is no evidence that the ID is unknown. /aliases/{alias} answers 404 unconditionally, because it reads only the wallet and never calls Gatekeeper. The Python service applies the table above to these routes as well, so, for example, an invalid DID there answers 400.
  2. A malformed request body answers 500. A body that fails JSON parsing before reaching the keymaster (e.g. POST /keys/sign) surfaces as 500, since the parse error is not an Archon error.

One nuance in the not-found row: resolving an explicit did: that the gatekeeper cannot find raises the base KeymasterError (→ 400) rather than UnknownIDError (→ 404) at the library level — only an unresolved name raises UnknownIDError. The resolve-style routes above paper over this by returning 404 for any resolution failure; making the library itself distinguish them is future work.

16.4 Logging


17. Test fixtures and reference implementation

17.1 Reference

17.2 Conformance tests

Suite What it covers
tests/keymaster/ Jest unit tests of the in-process Keymaster class. Many of them mock the Gatekeeper client with nock and exercise the full wallet/DID flows.
tests/cli/ End-to-end CLI tests that shell into a running CLI container and round-trip through Keymaster → Gatekeeper. Run on every PR by docker-build-test.yml.
python/keymaster_sdk/tests/ Python SDK integration tests against a running Keymaster (via python-sdk-tests.yml). A new HTTP-compatible Keymaster MUST pass these.

17.3 Adding a third implementation

Recommended order:

  1. Wallet at-rest format first. Implement encryptWithPassphrase and the JWE envelope (§3.1, §3.2, §4.5) and prove you can read a WalletEncFile produced by the TS implementation.
  2. Crypto primitives next. Build secp256k1 sign / verify, JWK conversion, BIP39, and BIP32 derivation. Verify against the tests/keymaster/ fixtures and the tests/gatekeeper/proof-vectors.json shared with the Gatekeeper.
  3. Wallet backend. Implement at least the JSON file backend; SQLite, Redis, Mongo are optional.
  4. HTTP routes. Stand up an HTTP server with the §2 contract and the route-by-route semantics in §5–§12. Use the OpenAPI export at docs/keymaster-api.json as a checklist.
  5. CI. Add a docker compose flavor and a matrix entry in docker-build-test.yml so the 27-test CLI suite runs against your image on every PR. (Same pattern the Rust Gatekeeper port uses; see docs/services/gatekeeper/README.md §17.)

A new implementation is considered drop-in when it can be substituted into docker-compose.yml and the CLI test suite passes against it.