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/DidCidDocumenttypes. Read the Gatekeeper spec first.
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.
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.
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.
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.
POST /api/v1/login accepts { "passphrase": string } and returns
{ "adminApiKey": string }.
ARCHON_PASSPHRASE MUST be set. /login is reachable without
the admin key, because it is how a client obtains that key, so an unset
passphrase would disclose it to any caller. Implementations MUST refuse to
start, as for ARCHON_ADMIN_API_KEY below.{ "error": "Incorrect passphrase" }.{ "error": "Passphrase not configured" }./ready, /version, /login, and /metrics
require the X-Archon-Admin-Key header to match ARCHON_ADMIN_API_KEY.{ "error": "Unauthorized — valid admin
API key required" } (note the em dash; matches the Gatekeeper wording).ARCHON_ADMIN_API_KEY MUST be set. The header guard covers the whole v1
router rather than an admin subset, so an empty key would leave wallet,
identity, credential and Lightning operations reachable by anyone who can
open the port. Implementations MUST refuse to start, before binding the
port, and MUST exit non-zero. This matches Gatekeeper, which refuses on the
same variable in both its TypeScript and Rust ports.An implementation whose app object can be constructed programmatically
without a key MUST refuse those requests with HTTP 403
{ "error": "Admin API key not configured" } rather than serving them.
X-Archon-Admin-Key and MUST NOT send it as
Authorization: Bearer <key>. The TS service ignores Authorization
entirely, and docs/deployment.md reserves that header for
user/session/OAuth-style flows. The Python service also accepts a bearer
token, but relying on that is non-portable and would collide with any future
user auth on Authorization.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.
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.
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.
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.
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
}
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:
seed.mnemonicEnc with the passphrase to get the BIP39 mnemonic.hdkey.privateKey.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.
encryptWithPassphrase(plaintext, pass) and the matching decrypt produce
{ salt, iv, data }:
PBKDF2(SHA-512, passphrase, salt, c=100000, dkLen=32)
c overridable via PBKDF2_ITERATIONS env var (decryption uses
whatever c was used at encrypt time — there’s no parameter stored)c=100000 as the default to interop with
existing walletsdata is ciphertext || tag (16-byte tag appended, the AES-GCM
default), base64-encoded (standard, with padding)=
padding (NOT base64url)Implementation reference: packages/cipher/src/passphrase.ts.
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:
account = IDInfo.account (assigned to each ID at create time)index = IDInfo.indexm/44'/0'/<account>'/0/<index>cipher.generateJwk(privateKey) to produce the
{ publicJwk, privateJwk } pairThe 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.
| 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. |
The “seed bank” is a special DID that the wallet creates on first use to back itself up. Operations:
wallet.backup creates a new asset DID controlled by the seed-bank
DID, containing the encrypted wallet body, then updates the seed-bank DID
document data to point at that backup DID.wallet.recover uses the explicit did argument when provided; otherwise
it resolves the seed-bank DID, reads the wallet pointer from that DID
document’s data, then decrypts the referenced backup asset.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.
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).
The Keymaster’s cryptographic surface lives in packages/cipher/src/. Reference implementations:
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.
r||s) signature form, hex-encoded
for over-the-wire transport in the legacy signHash API; base64url-
encoded inside Proof.proofValue (see Gatekeeper spec §5).signSchnorr,
signNostrEvent). Output: 64-byte signature, hex-encoded.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.
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:
{ alg: "ECDH-ES", enc: "A256GCM", epk: { kty, crv, x, y } },
JSON-encode, base64url (the protected header headerB64).sharedSecret = ECDH(ephemeral_priv, recipientPubKey).cek = ConcatKDF(sharedSecret[1..], 256, "A256GCM")
(per RFC 7518 §4.6.2; for ECDH-ES direct, algorithmId == enc).ascii(headerB64). Output is ciphertext || tag (16-byte tag).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.
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.
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.
For every DID write (createId, asset create, asset update, asset
delete), the Keymaster:
Operation (per Gatekeeper spec §3.1).proof field (defensive).proofValue:
type = "DataIntegrityProof"cryptosuite = "archon-ecdsa-secp256k1-jcs-2026"created = now() in RFC 3339verificationMethod 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 ismsgHash = sha256( sha256(canonicalize(config)) ‖ sha256(canonicalize(op_without_proof)) ),
which is what puts created and proofPurpose inside the signature.sig = ECDSA(secp256k1, priv, msgHash), and
sets proofValue = base64url(sig).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.
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:
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.
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.
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. |
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.
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.
An asset’s registry is chosen in this order:
CreateAssetOptions.registry, when the caller supplies one.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).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
localregistry, the asset operation MUST also belocal.
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.
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).
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
}
POST /api/v1/credentials/bind — { "subject": DID, "options"?: { schema, validFrom, validUntil, claims, types } } → returns the { "credential": <unsigned VC> }.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 }.POST /api/v1/credentials/issued/:did/send — emits a notice to the subject (see §11).POST /api/v1/credentials/held to “accept” — decrypts the JWE, stores DID in IDInfo.held[].| 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). |
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:
cipher_hash and the two are equal, binding the presentation
to the credential DID’s current content;type array and a valid proof by its
issuer — a proof by any other key does not count;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;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).
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"})
Each of these is a typed asset whose didDocumentData follows a specific
shape.
{ "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). |
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. |
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. |
addPollVoter / removePollVoter /
listPollVoters are addVaultMember / removeVaultMember /
listVaultMembers. The owner cannot be a vault member, but may always
vote and counts as an eligible voter.votePoll checks eligibility (the caller is the owner or can
decrypt the vault), the deadline, and that vote is an integer from 0
(spoil) to the number of options. It returns the DID of an encrypted asset
holding { "poll": "<poll DID>", "vote": <int> }, readable by the owner
and the voter. sendBallot delivers the ballot DID to the owner as a
notice.updatePoll decrypts it,
checks that the ballot’s controller is a voter (or the owner), the
deadline and the vote range, and stores the ballot DID under the voter’s
ballot key. The ballot key is the vault member ID truncated to
maxAliasLength (default 32) hex characters: SHA-256 of the vault salt
concatenated with the voter DID’s method-specific ID (with the whole DID
for an unversioned vault). A voter who votes again replaces their earlier
ballot.viewPoll computes PollResults from the filed
ballots; other voters see the published results item, if any.
publishPoll requires the results to be final (deadline passed or every
eligible voter has voted). By default it removes the per-voter ballots
list before storing the results; { "reveal": true } keeps it.
unpublishPoll deletes the results item.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.
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). |
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. |
Same as files but with image-specific metadata (width, height).
| 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. |
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. |
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 }. |
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.
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[] }. |
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.
| Backend | Path |
|---|---|
json |
${dataFolder}/wallet.json (default data/wallet.json) |
sqlite |
${dataFolder}/wallet.db |
The container mounts ./data at /app/keymaster/data.
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.
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.
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.)
wallet_operations_totalIncremented 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.
4226 by default../data at /app/keymaster/data (set ARCHON_DATA_DIR to
override).GIT_COMMIT build arg / env populates the service_version_info commit
label and /version. Truncated to 7 chars.| 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. |
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 staleexport 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 toARCHON_ENCRYPTED_PASSPHRASE, or startup refuses a missing passphrase outright. The same applies to an export ofARCHON_ENCRYPTED_PASSPHRASEon 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 exportSubstitute
ARCHON_ENCRYPTED_PASSPHRASEon a deployment using the older name. Neither command prints the secret;docker compose configwould.
ARCHON_ADMIN_API_KEY and ARCHON_PASSPHRASE; exit
non-zero if either is unset (§2.2).waitUntilReady=true, polling every 5s).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.waitForNodeId():
ARCHON_NODE_ID MUST be set.keymaster.createId(ARCHON_NODE_ID)).serverReady = true./api/v1/ready returns { "ready": serverReady } and MUST return
{ "ready": false } until the node ID resolves.
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.)
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).
{ "error": "<message>" } with the
appropriate status code. Some legacy paths return text/plain
Error: <message> — new implementations SHOULD prefer JSON but MUST
accept either when consuming responses./api/*) returns HTTP 404
{ "message": "Endpoint not found" }.| 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. |
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:
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.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.
METHOD path status duration-ms - content-length).console.error
before the response is sent.ghcr.io/archetech/keymaster| 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. |
Recommended order:
encryptWithPassphrase
and the JWE envelope (§3.1, §3.2, §4.5) and prove you can read a
WalletEncFile produced by the TS implementation.tests/keymaster/ fixtures and the
tests/gatekeeper/proof-vectors.json
shared with the Gatekeeper.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.