CRUMB a card from devarno-cloud

Audit Signing Key Source Selection

meridian intermediate 4 min read

ELI5

The signing pen has two forms: in production it lives in a locked drawer with serial numbers and rotation history, and in dev it is a disposable felt-tip you grab fresh every time the app starts. The disposable one is fine because dev has no permanent ledger anyway.

Technical Deep Dive

src/lib/audit-keypair.ts exposes getAuditKey(): Promise<KeyMaterial> returning { privateKey, publicKey, kid }. The branch is the presence of DATABASE_URL.

Selection Flow

flowchart TD
CALL[getAuditKey] --> ENV{DATABASE_URL set?}
ENV -- yes --> POOL{cachedStore?}
POOL -- no --> NEWPOOL[new pg.Pool<br/>new AuditKeys store]
POOL -- yes --> ACTIVE
NEWPOOL --> ACTIVE[store.activeKey]
ACTIVE --> RET1[return privateKey, publicKey, kid]
ENV -- no --> DEVCACHE{cachedDev set?}
DEVCACHE -- no --> GEN[generateKeypair<br/>kid = 'meridian-dev']
DEVCACHE -- yes --> RET2
GEN --> RET2[return cached dev key]

Production Path

AuditKeys (from @stratt/orchestrator/src/audit/keys.js) reads the active row from the audit_keys table. Rotations insert a new active row but preserve the previous rows so previously-minted tokens still verify against their original kid until their envelope exp. The kid is what lets a verifier pick the right historical key.

Dev Path

generateKeypair() from @stratt/signature runs once per process; cachedDev is a Promise<KeyMaterial> (so concurrent first calls share the same generation). The kid is the literal string meridian-dev. Restarting the process produces a fresh pair, so any token minted before the restart fails Ed25519 verification — which is correct because dev has no audit_keys row to look the old key up in either.

Back-Compat Alias

getAuditKeypair() returns just { privateKey, publicKey } for callers that predate the kid-aware API. New code should call getAuditKey() so the kid travels with the material.

Key Terms

  • kid → Key id; stamped on every minted token so verifiers can pick the right public key from a JWKS-equivalent.
  • AuditKeys.activeKey → Returns the currently-active row from the audit_keys table.
  • @stratt/signature → Workspace package providing the Ed25519 generateKeypair primitive.

Q&A

Q: Why is cachedDev a Promise rather than the resolved value? A: It serialises concurrent first callers — both await the same in-flight generateKeypair() instead of racing and producing two pairs.

Q: Can STRATT_KMS_KEY alone activate the production path? A: No — getAuditKey only checks DATABASE_URL. KMS encryption of the private key happens inside AuditKeys, but without a database there is nowhere to read encrypted material from.

Q: What kid do production tokens carry? A: Whatever audit_keys.activeKey returns — typically a UUID or human label assigned at rotation time, never meridian-dev.

Examples

Local bun dev with no DATABASE_URL: every minted audit URL works for the lifetime of the dev server. Restart bun dev, refresh an audit page from before, see “unable to verify — bad_signature”. Expected.

Production deploy with DATABASE_URL: rotate by inserting a new audit_keys row with active=true (and demoting the old one). Newly-minted tokens carry the new kid; tokens minted yesterday continue to verify against the demoted row until their exp.

neighbors on the map