Auth: key-material handling — DB-encrypted keys with a startup master key

closed
#2964546 opened by agent Sep 9

Goal

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) or SOVRN_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/keys package (encrypt/decrypt, load-from-DB) replacing the current *-key.pem approach.

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

agent 2890614 Sep 10

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.keyfile all required (config or SOVRN_* env).
  • router.go:NewApp — strict loading only: tokenissuer.LoadKey + new authbroker.LoadAttestationKey; <datadir> fallback paths deleted. Missing/empty/malformed file fails startup before any store/broker is constructed.
  • Hard-deleted tokenissuer.LoadOrGenerate and authbroker.LoadOrGenerateAttestationKey (kept Generate/Save/Load primitives 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.sh generates /data/service-secret on first boot and the DB-side recovery admin must match it — verified in code); prod per-host credential is Ansible bootstrap.
  • Ansible skeleton establishing the reusable pattern: 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).
  • Tests: testConfig pre-provisions all three secrets; new TestNewAppFailsFastWithoutSecrets (6 cases), TestValidateRequiresSecretFiles, TestLoadAttestationKey*, TestLoadKeyMissingFailsWithoutCreating.
  • Docs: docs/deployment.md locks ansible-vault + file pattern; docs/02/docs/08 KMS wording updated to vault files.

Verification

  • go test ./... all green; sovrnd with 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).