Auth: key-material handling — DB-encrypted keys with a startup master key
closedGoal
Investigate and implement the simplest in-app key-material handling: private keys stored encrypted in the app DB, unlocked by a single master key provided at startup. No Vault, no AWS KMS, no third-party KMS service.
Current state
Keys are plaintext files generated at startup: OIDC signing key (<datadir>/oidc-key.pem, ES256 P-256) and OAuth attestation key (<datadir>/oauth-attestation.pem, ES256 P-256). The pattern would extend to future postmaster/space keys.
Task (investigation → ADR)
- Master key:
SOVRN_MASTER_KEY(env) orSOVRN_MASTER_KEY_FILE; reject empty/weak at startup; never logged. - Derive a per-key key via HKDF-SHA256(master, salt/key-name); encrypt with AES-256-GCM (random nonce stored with the ciphertext).
- Store ciphertext in the app DB; decrypt into memory only when needed.
- Document the threat model explicitly: protects key material at rest (DB-file exfiltration, backups); does not protect against full host compromise (master key readable from env/process memory) — accepted trade-off for “no external KMS”.
- Deliverable: an ADR (ADR-0009) recording the decision + a small
internal/keyspackage (encrypt/decrypt, load-from-DB) replacing the current*-key.pemapproach.
Acceptance
- Key round-trip test (encrypt → persist → decrypt).
- Wrong master key fails decryption.
- A DB dump contains no plaintext private key.
Depends on: 70790ed S3/S4 (tokenissuer + authbroker key use).
1 Comment
Direction changed: pre-provisioned vault files, no DB-encrypted keys
The DB-encrypted-keys + master-key plan in the description is superseded. New intent: all secrets are defined ahead of time, ansible-vault-encrypted in the repo, provisioned as files; config/env carries only paths; missing secret at startup = refuse to start. No production secret is created at runtime. The one-time Stalwart bootstrap stays Ansible-only.
Implemented
config.go:Validate—stalwart.secretfile,oidc.keyfile,oauth.keyfileall required (config orSOVRN_*env).router.go:NewApp— strict loading only:tokenissuer.LoadKey+ newauthbroker.LoadAttestationKey;<datadir>fallback paths deleted. Missing/empty/malformed file fails startup before any store/broker is constructed.tokenissuer.LoadOrGenerateandauthbroker.LoadOrGenerateAttestationKey(keptGenerate/Save/Loadprimitives for the offline generator).cmd/sovrn-gensecrets+just gen-secrets [DIR]— generates OIDC PEM + OAuth attestation key, 0600, refuses to overwrite. Deliberately no Stalwart credential: dev is owned by the container entrypoint (serve-stalwart.shgenerates/data/service-secreton first boot and the DB-side recovery admin must match it — verified in code); prod per-host credential is Ansible bootstrap.inventory/hosts.yml,group_vars/all/{sovrn,vault-shared}.yml,host_vars/<host>/vault.yml,roles/sovrn_secrets,roles/stalwart/tasks/secret.yml,playbooks/site.yml(details in the S5 comment on 70790ed).testConfigpre-provisions all three secrets; newTestNewAppFailsFastWithoutSecrets(6 cases),TestValidateRequiresSecretFiles,TestLoadAttestationKey*,TestLoadKeyMissingFailsWithoutCreating.docs/deployment.mdlocks ansible-vault + file pattern;docs/02/docs/08KMS wording updated to vault files.Verification
go test ./...all green;sovrndwith no secrets exits 1 with a naming error; generator verified 0600 + no-clobber.Leaving this issue open for you to close or retitle (the original DB/master-key acceptance criteria no longer apply).