Skip to content

0034 — krites-models signing-key rotation (v1 → v2)

  • Status: IMPLEMENTED (2026-07-25) — items 1–8 done; the Friday deadline is met. v0.1.1 is cut and dual-signed: the published checksums.txt.sig carries exactly two signature packets, issuers 5B648A6B…BEF6C1EC (v1) and 2CE99F15…E419A73C (v2), and TestLiveReleaseVerifies_integration passes against the real assets. krites resolves from v0.1.1 (MR !105) with provenance now bound by its own signed checksum. ⚠️ ONE ACTION REMAINS — after the v1 key is decommissioned, close the window (§3.3).
  • Owner:
  • Scope: spans two reposphpboyscout/krites-models (producer/signer) and phpboyscout/krites (consumer/verifier).
  • Realises: the krites-models slice of infra task B2b in infra/docs/development/specs/2026-07-24-prod-rebuild-and-rekey.md ("dual-trust verifier releases … pipelines dual-sign (v1+v2)"), completing the estate re-key for krites.
  • Depends on: 0031 (the signed model-provisioning chain this rotates the key of).
  • Reference implementation: go-tool-base already does this — scripts/sign-release.sh (gtb sign --append) + the _2 env block in its .gitlab-ci.yml, with both certs vendored in internal/trustkeys/keys/. Copy that pattern rather than inventing one.

1. Why (the breakage)

The 2026-07-24 estate rebuild moved signing from the doomed AWS account 049815585546 to prod 060730976733, re-keying every release-signing key to a -v2. infra/src/main.signing-kms.tf now provisions only krites-models-release-signing-v2; the v1 module is gone, and the old account is being closed.

krites was not carried through that rotation. As of today:

Component State Consequence
krites-models/.gitlab-ci.yml AWS_ROLE_ARN…049815585546…-v1-signer, GTB_SIGNING_KEY_IDalias/…-v1 Signs with the dying account's key; after Friday a release tag fails outright
krites-models/build/sign-release.sh single gtb sign, no --append No dual-sign capability
krites-models/keys/ v1 cert only (5B648A6B…) v2 cert not vendored
krites-models GTB_VERSION v0.31.0 gtb sign --append did not exist until v0.33.0
krites pkg/face/models/keys.go PinnedTrustFingerprint — a single const (v1) Structurally cannot trust two anchors; cannot verify a v2-signed release

Net: v0.1.1 cannot be cut safely today, and cannot be cut at all after Friday.

2. Key facts (verified 2026-07-25)

Fact Value
v1 fingerprint 5B648A6B534748ED0FB630E0F32E6A9ABEF6C1EC (old acct 049815585546, dies Friday)
v2 fingerprint 2CE99F1540FA0550EE5D90F061B4A9D0E419A73C (prod 060730976733)
v2 cert source wkd-staging/.well-known/openpgpkey/phpboyscout.uk/hu/6nznzczi3pgr6j4y1h5zd4gp3jwhy6xr (UID krites-models Release Signing <[email protected]>)
v2 alias / role alias/krites-models-release-signing-v2 / arn:aws:iam::060730976733:role/krites-models-release-signing-v2-signer
--append availability gtb ≥ v0.33.0 (feat(sign): add --append to merge signatures for dual-sign windows, 2026-07-24)
Multi-sig verification Works. signing v0.2.2's VerifyManifestSignatureSigneropenpgp.CheckArmoredDetachedSignature, which iterates signature packets and skips unknown issuers, returning the first trusted signer. One .sig therefore verifies under either key generation.
krites trust model Embedded-only (LoadTrustSet over //go:embed keys/*.asc) — no WKD crosscheck, so the WKD bucket is not load-bearing for krites and the keys/*.asc glob picks up a new cert automatically.

3. Design

3.1 Producer — dual-sign the overlap window

Mirror go-tool-base: the primary signature stays v1 (old account, while it lives) and a v2 signature is merged into the same armored checksums.txt.sig via gtb sign --append. Because verification skips unknown issuers, one file satisfies both generations.

3.2 Consumer — a trust set, not a single pin

PinnedTrustFingerprint (one string) becomes PinnedTrustFingerprints (a set), and verifyManifest asserts membership rather than equality. Both certs are embedded.

Why a set rather than simply re-pinning to v2: krites currently resolves models from v0.1.0, which is v1-signed. A v2-only cut would force us to tag v0.1.1 and re-pin krites in the same breath, and would break any already-built krites binary. A trust set verifies the existing release and every future one — no flag day, and the change is independently shippable.

3.3 Closing the window (after Friday)

Once v1 is decommissioned: delete GTB_SIGNING_KEY_ID_2 / _PUBLIC_2 / AWS_ROLE_ARN_2 and promote v2 to primary (GTB_SIGNING_KEY_IDalias/…-v2, AWS_ROLE_ARN → the prod role). The dual-sign block in sign-release.sh goes inert on its own (it is env-gated), so no script change is needed. krites keeps both fingerprints — v0.1.0 remains v1-signed forever, so dropping v1 from the trust set would break resolution of that release.

4. Work items

phpboyscout/krites-models

  1. Vendor the v2 cert as keys/krites-models-release-signing-v2.asc (export from the WKD staging bucket above); rename the v1 file to …-v1.asc for symmetry only if the CI default path is updated in lockstep.
  2. .gitlab-ci.yml: bump GTB_VERSION to ≥ v0.33.0; add GTB_SIGNING_KEY_ID_2, GTB_SIGNING_KEY_PUBLIC_2, AWS_ROLE_ARN_2 (v2, prod account), commented as the overlap window with a pointer to §3.3.
  3. build/sign-release.sh: add the env-gated --append block (port from go-tool-base, adapted to sh and this repo's dist/ layout); publish both certs into dist/ for offline verify.

phpboyscout/krites

  1. Vendor the v2 cert into pkg/face/models/keys/ (the //go:embed keys/*.asc glob picks it up; no code change needed to load it).
  2. keys.go: PinnedTrustFingerprintPinnedTrustFingerprints []string = {v1, v2}.
  3. verify.go + models.go: verifyManifest takes the set and does a membership check; resolveConfig.pinnedFingerprintpinnedFingerprints.

Release

  1. Cut v0.1.1 (dual-signed) before Friday — this also activates the already-merged producer fix that folds provenance.json into checksums.txt.
  2. (Optional, post-tag) strengthen verifyManifest to verify provenance.json by its own signed checksum, and repoint krites' model pins to v0.1.1.

5. Tests

  • krites (unit): the existing tamper suite must still pass, extended with — a v2-signed manifest verifies; a dual-signed manifest verifies; a manifest signed by an untrusted key still fails closed (the set must not become a wildcard).
  • krites (INT_TEST): verify_integration_test.go continues to verify the real published release end-to-end against the embedded set.
  • krites-models (CI): shellcheck over build/*.sh; the tag pipeline is the real proof — a successful sign job emitting a two-signature checksums.txt.sig.
  • Manual gate before tagging: confirm the v2 signature actually validates (gpg --verify or a krites INT_TEST run against the v0.1.1 assets).

6. Risks

  • Hard deadline. v1 dies Friday. If the window is missed, the fallback is v2-only signing plus a krites re-pin — recoverable, but it must ship in lockstep with the release.
  • Wrong-cert risk. The v2 .asc is sourced from WKD staging, not exported directly from KMS. Mitigation: assert its fingerprint equals 2CE99F15…E419A73C (§2) before vendoring; the pinned-set check then fails closed on any mismatch.
  • Trust-set widening. Accepting two fingerprints slightly enlarges the trust surface. Both are our own KMS-held keys, and the set is explicit + fingerprint-pinned (never "any key in the file"), so this is bounded and reverts to one entry when v1 is finally dropped.