0034 — krites-models signing-key rotation (v1 → v2)¶
- Status: IMPLEMENTED (2026-07-25) — items 1–8 done; the Friday deadline is met.
v0.1.1is cut and dual-signed: the publishedchecksums.txt.sigcarries exactly two signature packets, issuers5B648A6B…BEF6C1EC(v1) and2CE99F15…E419A73C(v2), andTestLiveReleaseVerifies_integrationpasses 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 repos —
phpboyscout/krites-models(producer/signer) andphpboyscout/krites(consumer/verifier). - Realises: the
krites-modelsslice of infra task B2b ininfra/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-basealready does this —scripts/sign-release.sh(gtb sign --append) + the_2env block in its.gitlab-ci.yml, with both certs vendored ininternal/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_ID → alias/…-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 VerifyManifestSignatureSigner → openpgp.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_ID → alias/…-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¶
- 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.ascfor symmetry only if the CI default path is updated in lockstep. .gitlab-ci.yml: bumpGTB_VERSIONto ≥ v0.33.0; addGTB_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.build/sign-release.sh: add the env-gated--appendblock (port from go-tool-base, adapted toshand this repo'sdist/layout); publish both certs intodist/for offline verify.
phpboyscout/krites¶
- Vendor the v2 cert into
pkg/face/models/keys/(the//go:embed keys/*.ascglob picks it up; no code change needed to load it). keys.go:PinnedTrustFingerprint→PinnedTrustFingerprints []string={v1, v2}.verify.go+models.go:verifyManifesttakes the set and does a membership check;resolveConfig.pinnedFingerprint→pinnedFingerprints.
Release¶
- Cut
v0.1.1(dual-signed) before Friday — this also activates the already-merged producer fix that foldsprovenance.jsonintochecksums.txt. - (Optional, post-tag) strengthen
verifyManifestto verifyprovenance.jsonby 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.gocontinues to verify the real published release end-to-end against the embedded set. - krites-models (CI):
shellcheckoverbuild/*.sh; the tag pipeline is the real proof — a successfulsignjob emitting a two-signaturechecksums.txt.sig. - Manual gate before tagging: confirm the v2 signature actually validates (
gpg --verifyor a krites INT_TEST run against thev0.1.1assets).
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
.ascis sourced from WKD staging, not exported directly from KMS. Mitigation: assert its fingerprint equals2CE99F15…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.