Skip to content

M1-32 Signing keys, JWKS and rotation

No Date Ruling Reason
M1-32/1 2026-10-12 Base int/m1-wave7 at 8e30539, branch feat/M1-32-signing-keys in its own worktree; the branch is not merged locally and the worktree stays, the PR lane ports it. Text this ticket adds is English, also in German chapters and READMEs (their German parts stay). Instructions of the launcher, which override the brief's merge step and the brief's language.
M1-32/2 2026-10-12 audit_checkpoint is a valid purpose of rotateSigningKey (200), unlike the brief's test 7; terminal is refused with 400 validation_failed (/purpose, not_supported) until M2-39 manages it. SigningKeyPurpose in openapi.yaml lists audit_checkpoint and terminal, and 19 section 10 rotates audit_checkpoint keys only on owner request through signing_key.rotate; without it a leaked checkpoint key (case D of the disaster recovery runbook, "rotate all purposes") could never be replaced. The brief's premise that the schema lacks the purpose is wrong for the specification it cites.
M1-32/3 2026-10-12 An owner with a personal token gets 403 forbidden with reason: owner_only, not step_up_not_possible as the brief's test 7 says. 07 section 4.2 checks the requirement (step 6) before step-up (step 8), and a personal token never carries the owner role (SR5, authz.PrincipalFacts.IsOwner), so authz (M1-28) refuses at step 6. Both answers are 403; changing the order of authz is outside this ticket.
M1-32/4 2026-10-12 Audit details carry a kid (new_kid, previous_kid, kid) as the hex form of the thumbprint, 64 lower-case characters (app.KIDCode). A kid is base64url with upper-case letters, which audit_details.v1 refuses in a code (SEC-AUD-5: ^[a-z0-9][a-z0-9_.:-]*$); hex is the same SHA-256 value and converts back without loss, and the schema needs no new value kind.
M1-32/5 2026-10-12 Rotate, the job and EnsureActive run their key transaction in a unit of work of their own (txn.Detach), also inside the Idempotency-Key stage. Private files of retired keys are deleted after the commit; inside the stage's transaction a later rollback would restore rows whose files are gone. A repeat after a failed store of the response repeats the rotation: scheduled returns the same next key, emergency creates one more key, both harmless.
M1-32/6 2026-10-12 getJwks authorizes instance.discovery.read for the principal of the request (anonymous allowed) in Keys.JWKS. The proof of the check (02 "Proof of the check") turns any successful response without a check of the route's action into 500 internal_error (cause authz_not_checked in log and metric).
M1-32/7 2026-10-12 EnsureActive retires every unretired key whose private file is missing and creates a fresh active key when none is left (domain.LostTransitions), logging signing key file missing, key retired at level error. Narrowed by M1-32/16: a key store that holds none of the unretired keys is refused. After a restore without the key store (case B, an old bundle) the restored rows name files that do not exist: without the repair no access token could be signed, so no owner could sign in to rotate. 16 section 5.4 already names the consequence of lost signing keys (tokens invalid, devices sign in again). Every role must mount the same key store; the README says so.
M1-32/8 2026-10-12 bothy secrets export adds the key store under signing-keys/ with the flag --signing-key-dir (default /var/lib/bothy/signing-keys, skipped with a note on stderr when the default does not exist); secrets init skips signing-keys/ in its source. The command reads no configuration, so the default of secrets.signing_key_dir is repeated; Compose mounts the key store read-only in secrets-init at that path. A decrypted bundle is the source of init in case B, which would otherwise refuse the key files as unknown.
M1-32/9 2026-10-12 The private key of a loaded key sits in a secrets.Value (the seed); the expanded key exists only during Sign. Signer checks that the loaded key belongs to the public key of the row. SEC-SEC-3 and the redacting type of M1-09; a swapped file must not sign under another kid.
M1-32/10 2026-10-12 New telemetry attributes bothy.signing_key.purpose, bothy.signing_key.kid, bothy.signing_key.from_state, bothy.signing_key.to_state (logs of key transitions only). The log handlers drop every key outside the catalog; the brief asks for one line per transition with purpose, kid and both states.
M1-32/11 2026-10-12 Provider.Put and Provider.Delete return error only and take a parsed secrets.Ref and a secrets.Value; the file driver implements Writer (with Delete). Writes are allowed only directly in secrets.signing_key_dir, not in subdirectories. The names of M1-09 win over the brief's sketch (ref string, Secret); the key files never need a subdirectory, so the narrower rule costs nothing.
M1-32/12 2026-10-12 The migration is 00010_server_signing_keys.sql with the table exactly as 03, including the purpose terminal in its check and the EdDSA check for invocation, terminal and webhook_out. Next number on the base; PLAN row "Addendum of 2026-10-10" asks M1-32 to create the purpose terminal with its table without using it.
M1-32/13 2026-10-12 When the plan of a key change, recomputed under the advisory lock, asks for a key that was not generated before (another process changed the keys between the read and the lock), the transaction rolls back, a key is generated and the change runs again, at most three times, then 409 conflict. Red team round 1, major 1: EnsureActive could leave a purpose without an active key and still succeed (TestEnsureActive_LostNextPromotedConcurrently).
M1-32/14 2026-10-12 The readiness check signing_keys also fails for a purpose whose active key cannot sign (unreadable private file, or a file of another key), through Keys.Unready. Red team round 1, minor: a key store with unreadable files (a changed secrets.signing_key_dir, a role without the volume) looked ready while Signer failed.
M1-32/15 2026-10-13 Port onto main: the migration keeps 00010_server_signing_keys.sql with a checksum over the English comments, the chapter references name the English files and section titles, the start check of the key store stands in step 2 and the retirement of keys without a file in step 6 of 02 section 15.1, and M1-32/6 names the problem as 500 internal_error with the cause authz_not_checked. The specification is English on main, and the problem code gate accepts only codes of 06 section 5; no behaviour changes.
M1-32/16 2026-10-19 EnsureActive first checks the whole key store: when the database has unretired keys and secrets.signing_key_dir holds the private file of none of them, the start ends with an error naming secrets.signing_key_dir and signing_key.accept_lost_keys and changes no key. Only with the new key signing_key.accept_lost_keys = true (default false, 19 section 16) does the start retire them and create fresh keys as in M1-32/7. A partial loss (some files present) keeps the automatic repair; a file that exists but cannot be read counts as present. CodeRabbit on the pull request of M1-32, reproduced with TestEnsureActive_EmptyKeyStoreKeepsKeys: one role on a new or mistyped volume retired every key of all purposes, which cannot be undone, signed out every device and left the other roles with active rows whose files they lack. Chapter 02 (start step 6) and disaster recovery case B described the retirement as the consequence of a restore without the key store; the steering decided to fail closed and keep the stored keys, and both now name the consent key; the consent key keeps case B with a bundle without signing-keys/ possible.