0003. Key material lives in an external wallet
Cryptographic key material and signing operations live in an external wallet service.
0003. Key material lives in an external wallet
Status: Accepted | Date: 2026-08-31
Context
The node holds a decentralised identifier and has to sign with it: DID Documents, verifiable presentations, and eventually whatever the vocabulary layer needs to attest. Something has to hold the private key.
Holding it in the process is the shortest path and the one that was not taken.
A key in the process is a key in every heap dump, every core file and every
/debug/pprof/heap; it is a key that has to be mounted into the container,
rotated by whatever rotates files, and backed up by whoever backs up volumes.
It also makes the node the wrong shape for the dataspace it belongs to, where
participants are expected to have a wallet.
Decision
The node never holds a private key. It talks to an external SSI wallet — Fafnir
today, over HTTP — through an adapter in internal/ssi-auth/fafnir, behind a
port the wallet package owns. Signing and key generation happen there.
The link is established at startup and is allowed to fail. startup_link_timeout
bounds how long startup waits; past it the node comes up anyway, reports itself
not ready through /readyz, and keeps retrying in the background with a capped
backoff.
The wallet is a dependency this repository deploys but does not own. The image is published elsewhere; what lives here is only how it runs — a configuration file per deployment shape, and a database of its own. Every deployment can point at a wallet that already exists instead, and the production values do.
Consequences
The node's most sensitive dependency is a network call, which means it is also a failure mode: no wallet, no DID Document, no signing. Reporting not-ready rather than refusing to start is what makes that survivable — an orchestrator holds traffic off, the node keeps retrying, and it starts serving when the wallet does.
Fafnir is a value in the configuration and an adapter behind a port, so a
second wallet implementation is a second adapter. Nothing above internal/ssi-auth
knows which one is in use.
Local development needs a wallet running somewhere, which is why
docker-compose.dev.yaml brings one up. It is still the reason the node is
built to come up without one.
Where the wallet's keys sit is then a second decision, and it is the wallet's
rather than the node's. With is_vault_real false they are files on a volume;
with it true they are entries under a Vault mount. Both are deployed: the
compose stack carries a Vault behind a profile, and the chart takes the address
of one it does not deploy.
That Vault is sealed on every start and unsealed by a person, which is a cost accepted rather than designed around: auto-unseal needs a KMS a single host cannot assume, and unseal keys stored beside the data they open are not a secret store. The node is unaffected either way — it comes up, reports itself not ready, and waits, the same as for any wallet it cannot reach.
The node itself still has no Vault client, and is_vault_real in its own
configuration only names a mode in its startup banner. That is honest: it holds
nothing a Vault would protect.
What would change this
Nothing short of the dataspace itself deciding that participants hold their own keys, which would be a change to the model rather than to this node.
0002. One configuration document, overridden from the environment
Single configuration document loaded via Viper with hierarchical environment overrides.
0004. Authentication is terminated at the node, not at the client
OIDC authentication terminates at the Alexandria node instead of the frontend client.