M1-32: Signing keys, JWKS and rotation¶
Status: ready | Depends on: M1-20, M1-23, M1-28 | Milestone: M1
Goal¶
After this ticket the server owns one Ed25519 signing key per purpose (access_token, invocation, webhook_out, audit_checkpoint) with the states next, active, retiring and retired. The private part lives only in the secret provider; the database holds the public JWK, the state and a reference. Keys rotate on schedule without invalidating any signature that is still valid, an owner with step-up can rotate on demand or in an emergency that withdraws the old key at once, and the public JWKS is served for clients and agent adapters. Access tokens (M1-34), invocation tokens (M2), outgoing webhooks (M2) and audit checkpoints (M1-50) sign through this ticket's port.
M1-20 (Idempotency-Key on rotateSigningKey) and M1-28 are the direct dependencies; M1-14 (jobs), M1-15 (instance) and M1-24 (audit of signing_key.rotate and signing_key.transition) come with them.
Read first¶
docs/PLAN.md: section 3, section 7 (M1), rows M1-09, M1-23, M1-32, M1-34, M1-37, M1-50, M1-73.docs/spec/03-data-model.md: 1 (principle 9), 4.1instanceandserver_signing_keys, 4.12audit_checkpoints(columnkid), 6.3.docs/spec/05-actions.md: table 4.1 (instance.discovery.read), table 4.2 (signing_key.rotatewithmode, system actionsigning_key.transition), 5 (invocation token header and claims), R-AZ-15.docs/spec/08-identity.md: 4 (bootstrap step 3), 7.1 and 7.2 (access tokens, verification withactiveandretiring).docs/spec/09-agents.md: 4.5 (Invocation token, keys and rotation).docs/spec/11-extensions.md: 7 (Outgoing webhooks, key purposewebhook_out).docs/spec/16-operations.md: 5.1 to 5.4 (secret provider, compose secrets, escrow), 4.2 (health), 14.docs/spec/19-security.md: SEC-CORS-2, 9 (SEC-LOG-1 to SEC-LOG-4), 10 (Secrets, SEC-SEC-1 to SEC-SEC-5), 12 (SEC-AUD-2), 16.docs/spec/02-architecture.md: 4.2 and 4.3 (rowinstance), 5, 12 (jobs), 13, 15.1 and 15.2.docs/spec/20-privacy.md: rowserver_signing_keys.docs/api/openapi.yaml: operationsgetJwks,rotateSigningKey; schemasJwks,SigningKeyRotate,SigningKeyRotation,SigningKeyPurpose.docs/operations/configuration.mdsection 5 (Secrets),docs/operations/disaster-recovery.md(Case D),docs/security/threat-model.md(T-TK-04),infra/compose/compose.yaml(serviceserver, volumesecrets),services/server/Dockerfile(M1-23).- ADRs: 0066 (writable key store), 0011, 0012, 0020, 0021, 0023, 0016.
- Merged code:
internal/platform/secretsandbothy secrets(M1-09),internal/platform/jobs(M1-14),internal/instancewithinstanceandsequence_counters(M1-15), the HTTP chain and problem writer (M1-18), idempotency (M1-20),auditing.Recorder(M1-24),authorize(M1-28), lifecycle and health (M1-11), config loader (M1-07) and generator (M1-08). Their names win where they differ from this brief only in naming. - RFC 7517 (JWK), RFC 7638 (JWK thumbprint), RFC 8037 (OKP keys,
EdDSA).
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: red before green, red output kept for the report.
- No en dash or em dash in any file; White Label.
- Configuration values only through named keys listed in
docs/operations/configuration.md; the new keys of this ticket are added to their chapter tables first and the reference is regenerated. - The private part of a signing key exists only in the key store
secrets.signing_key_dirof the secret provider (ADR 0066, 16 section 5.2);server_signing_keysholds the public JWK, the state andprivate_key_ref(03; 19 section 10; SEC-SEC-3). A private key never appears in the database, a log, telemetry, an audit entry, an error or a response. - States
next,active,retiring,retired; exactly oneactivekey per purpose (partial unique index of 03); a key staysretiringuntil the longest validity of anything signed with it has passed (19 section 10); the JWKS publishesnext,activeandretiringkeys (09 section 4.5). invocationandwebhook_outkeys use onlyEdDSA(03 check). This ticket generates Ed25519 keys for every purpose (08 section 7.1 namesEdDSAfor access tokens).- Scheduled rotation of
invocationkeys aftersigning_key.invocation_rotation_intervaland ofaccess_tokenkeys aftersigning_key.access_token_rotation_interval, with the new key published for at leastagent.jwks_max_agebefore it signs (09 section 4.5, 19 section 10).webhook_outandaudit_checkpointrotate on owner request only. - Emergency rotation (
mode = emergency) retires every key of the purpose at once (09 section 4.5 point 3, 19 section 10);signing_key.rotateis an owner action with step-up and audit (05 table 4.2). Every job-driven transition writes an audit entry with the system actionsigning_key.transition(05, R-AZ-15). - JWKS is public (
instance.discovery.read,security: []), carries no private member, and allows any origin without credentials (SEC-CORS-2).
Scope¶
1. Specification (already decided)¶
ADR 0066 and the chapters already describe this ticket: 16 sections 5.2 to 5.4 and 14
(secrets.signing_key_dir, Put only below it), 19 sections 10 and 16 (key format, file name,
states, windows, signing_key.cache_ttl, signing_key.rotation_check_interval,
signing_key.access_token_rotation_interval), 02 15.1 step 6 and 15.2 (signing_keys), 09 4.5
(modes, auth.clock_leeway as the retiring margin), 05 (signing_key.transition), openapi.yaml
(SigningKeyRotate.mode), configuration.md section 5, disaster-recovery.md Case D, the threat
model row T-TK-04 and the compose volume signing-keys. Change them only if the build proves a
statement wrong, in the same change; add this ticket's tests to the "Confirmation" of ADR 0066 if
their names differ.
2. Secret provider (internal/platform/secrets)¶
- Add
Put(ctx, ref string, value Secret) errorandDelete(ctx, ref string) error. Only thefiledriver supports them, and only for references belowsecrets.signing_key_dir; every other reference returnsErrReadOnly;envnever writes. Putcreates the file with mode 0600 in the same directory under a temporary name, writes, fsyncs, links it to the final name without overwriting (an existing name is an error), removes the temporary name, and fsyncs the directory.- Startup check (roles
api,worker,all):secrets.signing_key_direxists, is a directory owned by the process user with mode 0700, and is writable; otherwise the start aborts with a message naming the key and the expected mode (same style as SEC-SEC-1). bothy secrets export(M1-09) includes the files ofsecrets.signing_key_dirin the age-encrypted bundle.- Add
secrets.signing_key_dirto the configuration schema (M1-07).
3. Port (root package internal/instance, file keys.go)¶
// Purpose of a server signing key (03 server_signing_keys.purpose).
type Purpose string // access_token, invocation, webhook_out, audit_checkpoint
// KeyState of a server signing key: next, active, retiring, retired.
type KeyState string
// Signer signs with one fixed key; KeyID and Alg go into the JWS header before signing.
type Signer interface {
KeyID() string
Alg() string // always EdDSA
Sign(ctx context.Context, signingInput []byte) ([]byte, error)
}
// Keys is the port of the signing keys.
type Keys interface {
Signer(ctx context.Context, p Purpose) (Signer, error) // active key, read fresh
VerificationKey(ctx context.Context, p Purpose, kid string) (PublicKey, error) // next, active or retiring
PublicKey(ctx context.Context, kid string) (PublicKey, error) // any state, for checkpoint verification
JWKS(ctx context.Context) (JWKSet, error) // published set
Rotate(ctx context.Context, a actor.Actor, cmd RotateCommand) (Rotation, error)
EnsureActive(ctx context.Context) error
}
PublicKey{KID, Purpose, Alg, State, Key ed25519.PublicKey, NotAfter}, RotateCommand{Purpose, Mode, Reason}, Rotation{Purpose, NewKID, PreviousKID, PreviousNotAfter}. JOSE assembly (header, base64url, compact form) stays with the token issuers (M1-34 and later, through internal/kernel/jose of M1-33, ADR 0067); this port signs raw signing input with the standard library only and adds no JOSE library. Callers in M1-34, M1-37, M1-50 and M2 use this port; nobody else reads server_signing_keys.
4. Domain (internal/instance/domain/signingkey.go)¶
Pure functions over a list of keys and a time t, table tested:
| Transition | Condition | Effect |
|---|---|---|
create next (scheduled) |
purpose has a rotation interval (invocation: signing_key.invocation_rotation_interval; access_token: signing_key.access_token_rotation_interval), no next exists, active.created_at + interval <= t |
new key next |
| promote | a next exists and next.created_at + agent.jwks_max_age <= t |
old active to retiring with not_after = t; next to active |
| retire | retiring key with not_after + window(purpose) <= t |
retired |
| emergency | Rotate with mode = emergency |
every next, active, retiring key of the purpose to retired (not_after = t where unset); a fresh key active |
| manual scheduled | Rotate with mode = scheduled |
if a next exists, return it; else create next; the job promotes it |
Window per purpose (longest validity of a signature after the key stopped signing):
| Purpose | Window |
|---|---|
access_token |
auth.access_token_ttl + auth.clock_leeway |
invocation |
agent.invocation_token_ttl + auth.clock_leeway |
webhook_out |
webhook.delivery_timeout + auth.clock_leeway (every delivery attempt signs anew) |
audit_checkpoint |
zero; old checkpoints are verified through PublicKey in any state |
5. Application (internal/instance/app/keys.go) and outbound port¶
- Outbound port
KeyStoreininternal/instance/app:Generate(ctx, purpose) (kid string, pub ed25519.PublicKey, ref string, err error),Load(ctx, ref) (ed25519.PrivateKey, error),Remove(ctx, ref) error. Adapterinternal/instance/adapters/keystore: Ed25519 fromcrypto/rand; PEMPRIVATE KEY(PKCS#8,x509.MarshalPKCS8PrivateKey);kid= RFC 7638 thumbprint (SHA-256 over{crv, kty, x}in that order, base64url without padding);ref=file:<secrets.signing_key_dir>/<purpose>-<kid>.pem; writes throughsecrets.Put, reads throughsecrets.Get(cachedsecrets.cache_ttl). - Every transaction that changes keys of a purpose first takes
pg_advisory_xact_lock(hashtext('bothy.server_signing_keys'), hashtext(<purpose>)); it serializes creation, promotion and rotation, also when no row exists yet. - New key: generate and store the file first, then insert the row in the transaction; if the transaction fails, remove the file (best effort, logged with
kidonly). After a crash between both steps an unreferenced file may remain; it is never used and is documented in the README. - After the commit of any transition to
retired, remove the private file (best effort, logged withkidonly).private_key_refstays as a record. EnsureActive: for each of the four purposes without anactivekey, create one directly asactive. Called at start in every role (02 15.1) and later bybothy setup bootstrap-owner(M1-37).Signer: reads theactiverow of the purpose fresh on each call (one indexed row), loads the private key, returns aSignerfor that key.VerificationKey: per-process cache of allnext,activeandretiringkeys, refreshed aftersigning_key.cache_ttl; an unknownkidtriggers at most one reload persigning_key.cache_ttl(singleflight), otherwisenot found. Aretiredkey is never returned.Rotate:authorize(actor, signing_key.rotate, instance), then the transition, then the allow audit entry in the transaction (details = {purpose, mode, new_kid, previous_kid, reason}).PreviousNotAfter=next.created_at + agent.jwks_max_ageforscheduled,tforemergency.- Job
instance.key_rotation(queuemaintenance, periodsigning_key.rotation_check_interval, rolesworkerandall, no arguments): applies the due transitions of section 4 per purpose, each in its own transaction, logs each transition with purpose,kid, from and to state.
6. Persistence¶
Migration services/server/migrations/<next number>_server_signing_keys.sql (goose): table server_signing_keys exactly as 03, with every check (purpose, alg, state, EdDSA only for invocation and webhook_out), the partial unique index (purpose) WHERE state = 'active' and the index (purpose, state). sqlc queries in internal/instance/adapters/postgres/queries/keys.sql.
7. HTTP (internal/instance/adapters/httpapi)¶
getJwks: keys of the purposesaccess_tokenandinvocationin the statesnext,active,retiring, ordered by purpose, state (active,next,retiring) andkid; each JWK exactly{kty: OKP, crv: Ed25519, x, kid, alg: EdDSA, use: sig}; content typeapplication/jwk-set+json;Cache-Control: public, max-age=<agent.jwks_max_age in seconds>;Access-Control-Allow-Origin: *without credentials (SEC-CORS-2; use the mechanism of M1-18 or M1-21 for public routes if it exists, else set it in the handler).webhook_outkeys are published bygetWebhookKeys(M2);audit_checkpointkeys are not published.rotateSigningKey: mapsSigningKeyRotatetoRotateCommand, returns200 SigningKeyRotation.audit_checkpointis not inSigningKeyPurpose, so the schema rejects it with400.- Move
getJwksandrotateSigningKeyinto the built section ofservices/server/api/operations.yaml(P-04).
8. Start, health, image, compose¶
internal/wireandinternal/platform/lifecycle: start stepEnsureActiveafter loading the instance (02 15.1 as updated);/readyzchecksigning_keys.services/server/Dockerfile: create/var/lib/bothy/signing-keysowned by the unprivileged runtime user with mode 0700, so a named volume inherits owner and mode.infra/compose/compose.yamlalready has the named volumesigning-keys, read-write inapi,api-tailscaleandworkerand read-only insecrets-init(forbothy secrets export); keep it consistent if the build changes the path.
9. Configuration and documentation¶
- Schema entries (M1-07):
secrets.signing_key_dir,signing_key.cache_ttl,signing_key.rotation_check_interval,signing_key.access_token_rotation_interval; wiresigning_key.invocation_rotation_interval,agent.jwks_max_age,auth.access_token_ttl,auth.clock_leeway,agent.invocation_token_ttl,webhook.delivery_timeoutwhere not yet wired. Cross check at load:agent.jwks_max_agesmaller than both rotation intervals, else start error. services/server/internal/instance/README.md(keys section: states, windows, files, cache, job, failure modes, orphan files),docs/DECISIONS.md,services/server/CHANGELOG.mdunder Unreleased.
Out of scope¶
- Issuing and verifying access tokens: M1-34. Invocation tokens: M2-06. Webhook signatures and
getWebhookKeys: M2. Audit checkpoints: M1-50. Bootstrap command: M1-37 (it callsEnsureActive). - Revoking all sessions after an emergency rotation of
access_tokenkeys: an owner step in the runbook (Case D), not automatic. - Writable drivers
azkvandawssm: V3-12. Multi-node key store: M7-17. - Startable installation compose and the smoke overlay: M7 and M1-73 (the overlay mounts
signing-keys).
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M1-32-signing-keys, created from the base branch named by the launcher. One writer per worktree. - Conventional Commits with the module as scope (for example
feat(instance): signing keys with rotation and JWKS) and a DCO sign-off on every commit (git commit -s). Squash into meaningful commits before the merge. - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope of this brief, except
docs/DECISIONS.md,services/server/CHANGELOG.mdand the generated files named here. - Follow the host rules supplied by the launcher. Tests bind only
127.0.0.1and port 0 (P-10); PostgreSQL throughpgtest(P-09), never skipped. Key store directories in tests only viamktemp -dwith mode 0700, removed at the end. - Stop only processes you started, by PID.
- Never read or print secrets; test keys are generated in the test and never written outside the temporary directory. No network calls except package downloads for pinned tools.
- No gate may be weakened, no threshold lowered, no test deleted or skipped.
Design notes¶
kidis the RFC 7638 thumbprint (19 section 10); anyone can recompute it from the public JWK, it is unique per key and safe as a file name. The examples in openapi.yaml are only examples.modeis required inSigningKeyRotate; emergency rotation retires every unretired key of the purpose, because a compromise of the key store would exposenextandretiringkeys too (19 section 10).invocationandaccess_tokenrotate automatically;webhook_outandaudit_checkpointon owner request. The job is written generically over a purpose table so that a further interval needs only a configuration entry.- Job-driven transitions write one audit entry each with the system action
signing_key.transition, no actor,resource_kind = instance, details{purpose, from_state, to_state, kid}(codes only, 19 SEC-AUD-5), in the transaction of the transition. - Lock order: the advisory lock is the domain step of 03 section 6.3;
audit_seqfollows in phase 3.3. Key transactions never takepermission_version. - No network call and no slow work inside a transaction; key generation and the file write happen before the transaction starts.
- Logs name purpose and
kidonly (SEC-LOG-1, SEC-LOG-4); private keys use the redacting secret type of M1-09 in memory. - Problem codes:
400 validation_failed,401 step_up_required,403 forbidden(owner_only,step_up_not_possible),409 conflictfor a concurrent rotation that lost the race only if it cannot be serialized (the advisory lock normally prevents it),422 idempotency_key_reuse,503 unavailablewhen the key store is unusable at runtime.
Tests (write them first)¶
Controllable clock (kernel/clock), PostgreSQL 18 through pgtest, key store in a mktemp -d directory with mode 0700, test authenticator for HTTP from internal/platform/httpserver/httpservertest (M1-18, with the builders M1-31 added).
TestSigningKeyTransitions_*(domain, table): every row of the transition and window tables, boundaries exactly at the due time, no transition when not due, at most onenextper purpose.TestThumbprint_RFC7638: the thumbprint of the Ed25519 example key of RFC 8037 appendix A equals the value computed by hand in the test from the RFC 7638 rule.TestEnsureActive_*: an empty database gets exactly oneactivekey per purpose; a second call changes nothing; two concurrent calls from twoKeysinstances create exactly oneactivekey per purpose; the private files exist with mode 0600.TestScheduledRotation_EverySignatureStaysVerifiable: step the clock bysigning_key.rotation_check_intervalthrough at least two completeinvocationrotations and one owner-requestedaccess_tokenrotation, running the job at each step; at every step sign a sample withSignerand recordkid, signing time and the purpose window; assert that every recorded sample still inside its validity verifies throughVerificationKeyand that noretiredkey verifies.TestScheduledRotation_NextPublishedBeforeUse: anextkey appears inJWKSat leastagent.jwks_max_agebefore any signature uses it.TestEmergencyRotation_*: aftermode = emergencythe oldkidfailsVerificationKeyat once in the same process and, in a secondKeysinstance with a warm cache, at the latest aftersigning_key.cache_ttl;nextandretiringkeys of the purpose areretiredtoo; their private files are deleted; a freshactivekey signs.TestRotateSigningKey_HTTP: owner with fresh step-up gets200withnew_kid,previous_kid,previous_not_after; old step-up401 step_up_required; human without owner role403; owner with a personal token403 forbiddenwithreasonstep_up_not_possible; anonymous401; purposeaudit_checkpointand missingmode400 validation_failed; repeat with the sameIdempotency-Keyreturns the stored response; audit entry with purpose, mode, kids and reason.TestGetJwks_HTTP: anonymous200, content typeapplication/jwk-set+json,Cache-Controlwithmax-agefromagent.jwks_max_age,Access-Control-Allow-Origin: *; contains exactly thenext,activeandretiringkeys ofaccess_tokenandinvocation; every JWK has exactly the memberskty,crv,x,kid,alg,useand neverd; the body validates againstJwks.TestSigningKeyCanary: generate keys, run every transition and both HTTP operations, then search for the PEM body, the base64url private seed and its hex form in:row_to_jsonof every row ofserver_signing_keysandaudit_events, all captured log records, every HTTP response body and header, and every error string returned in the test; none may match.TestSecretsPut_*(platform):Putoutsidesecrets.signing_key_dirisErrReadOnly;env:refs areErrReadOnly; an existing name is not overwritten; the file has mode 0600;Deleteoutside the directory is refused; the startup check fails for a missing directory, mode 0755 and a foreign owner where the test can create one.TestSecretsExport_IncludesKeyStore: the exported bundle decrypted with the test identity contains the key files.TestStartup_SigningKeys: start fails when the key store is not writable;/readyzreportssigning_keyswhile a purpose lacks anactivekey and is ready afterEnsureActive.TestKeyRotationJob_Registration: queuemaintenance, periodsigning_key.rotation_check_interval, rolesworkerandall, no arguments.TestKeyRotationJob_AuditsTransitions: every transition of test 4 leaves exactly one audit rowsigning_key.transitionwith purpose, states andkid, in the same transaction.TestConfig_JwksMaxAgeBelowRotationInterval: a configuration withagent.jwks_max_agenot belowsigning_key.invocation_rotation_intervalaborts with a message naming both keys.
Commands that must pass:
cd services/server && go test -race ./internal/instance/... ./internal/platform/secrets/... ./internal/platform/config/... ./cmd/...make migrations-lint,make codegen-check,make arch,make api-lint,make config-doc-check,make docs,make diagramsdocker compose -f infra/compose/compose.yaml config --quiet,make image,make trivymake check
Acceptance criteria¶
- Specification, ADR 0066, configuration reference, runbook and threat model still match what was built;
make docsandmake config-doc-checkgreen. - Scheduled rotation: in every state every still valid signature verifies (test 4); a
nextkey is published at leastagent.jwks_max_agebefore use (test 5). - Emergency rotation withdraws the old key at once in the same process and within
signing_key.cache_ttlin others (test 6). -
rotateSigningKeyonly for owners in a session with fresh step-up, audited (test 7). - JWKS contains only public parts and only the documented purposes and states (test 8).
- Canary: no private key in database, log, audit, response or error (test 9).
- Exactly one
activekey per purpose after start, also with concurrent starts (test 3). - Image builds with the key store directory; compose validates.
- README, ADR, DECISIONS and CHANGELOG updated; doc comments on every exported symbol.
-
make checkgreen.
Definition of done¶
- Code, tests and documentation as listed in Scope; the module README is current (ports, tables, actions, jobs, files).
- A doc comment on every exported symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- ADR 0066 names this ticket's tests in "Confirmation" and the index is current (
make adr-index); further rulings go todocs/DECISIONS.md. - CHANGELOG entry under Unreleased in
services/server/CHANGELOG.md. make checkgreen on the final commit.- Independent review of the diff (
make reviewplus a code review by a fresh reviewer) and the red team ofdocs/quality/red-team-gate.mdover the diff, with attention to PLAN section 7 Red Team point 6 (secrets in logs, telemetry, audit and errors); verdict recorded in the report. OnNEIN: fix, add a test per finding, re-check the delta. - Merge locally into the base branch only when the review is clean, the red team says
JAandmake checkis green; thengit worktree remove.
Report¶
Write {{REPORT_PATH}}:
- first line exactly
Verdict: DONEorVerdict: BLOCKED <reason>; - commits (sha and subject), files changed with line counts, test results with counts (zero skipped), the red output of the first run of each new test file, what is not proven (for example multi-node key stores), follow-ups that could not be done in scope with the reason.
Then write {{DONE_PATH}} with exit=0 sha=<merge sha> or exit=1 <reason>.