ADR-028: Verification root-key rotation and signed-material re-enveloping
Status
Accepted — 2026-06-10 (operator-approved; the four open questions below are resolved)
Context
Recent work (commits 7a4f7921e2 backend, 7446116190 Console UI) made signature-verification material admin-settable per regional crypto profile (world|fips|gost|sm|kcmvp|eidas) via Platform EnvironmentSettings, under the reserved namespace Verification:<profile>:<service>:..., consumed by services at boot through PlatformVerificationSettingsConfigurationSource (a fail-closed boot-time pull from GET /platform/verification-settings/{service}). Today the in-scope material is:
- PacksRegistry —
PacksRegistry:Verification:PublicKeyPem(RSA public key verifying pack signatures). - VexHub —
VexHub:SignatureTrustRoots:<fingerprint>(DSSE/HMAC trust roots) +VexHub:EnableSignatureVerification. - By extension, the regional crypto signing roots that
attestor/evidence-lockeruse viaUseCryptoRegistry.
Those keys are currently seeded with test values for the world profile. Production needs:
- Operators to set real keys from both UI and CLI.
- A change to a verification root key to require an explicit “this has consequences” acknowledgment.
- A defined procedure to re-envelope existing signed material under the next key and then invalidate the old material.
Removing a verification root key makes every artifact signed under it stop verifying. A naive hard cutover would instantly break verification of all in-flight signed material (packs, DSSE attestations, VEX envelopes, evidence bundles). We must not do that.
Existing primitives we build on (verified in src/):
PackService.RotateSignatureAsync(packId, newSignature, verifierOverride?, …)re-signs a single pack with a new signature, re-verifies it against content+digest, and appends asignature.rotatedtimeline audit (src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/PackService.cs:100).- DSSE envelopes are keyId-based and support multiple signatures (
src/Attestor/StellaOps.Attestation/DsseHelper.cs,DsseVerifier.cswith akeyResolver(keyId)), so dual-signing (old+new) during a transition is native. - IssuerDirectory
OperatorSigningKeyEndpointsalready model an operator-key lifecycle — enroll → approve → rotate → revoke (keyId-scoped) (src/Authority/StellaOps.IssuerDirectory/.../Endpoints/OperatorSigningKeyEndpoints.cs). Reuse this shape. - Platform
EnvironmentSettingsregion-scoped store + Console Cryptography page (/setup/system/cryptography) + the auditedPUT/GET/DELETE /platform/envsettings/db[/{key}]endpoints. - Related prior art: ADR-025 (operator-signed decisions), SPRINT_20260608_015 (signed governance decisions).
Decision
Adopt a phased, dual-trust rotation with explicit re-enveloping and a consequences-gated invalidation — never a hard cutover. Three phases, applied per region profile and per material type:
Introduce (dual-trust). The operator adds the NEW verification root alongside the current one; both verify during a grace window. New material is signed with the new key; old material continues to verify under the old root. The new root is stored with status
pending/active; the old becomesretiring. A warning is shown that a rotation has begun. No old material is invalidated yet.Re-envelope. A resumable, offline-capable re-enveloping job re-signs existing signed material under the new key:
- Packs → bulk
RotateSignatureAsyncwith the new key (iterates the pack corpus; idempotent; emitssignature.rotated). - DSSE attestations / evidence bundles → add a new-keyId signature to each envelope (multi-sig) — or re-issue where re-signing is impossible.
- VEX → re-sign / re-ingest under the new trust root. Progress is tracked per material type (
total / re-enveloped / failed / skipped), is resumable, and runs without external services (air-gap-safe).
- Packs → bulk
Invalidate (consequences-gated). Once re-enveloping is complete (or the operator explicitly accepts the residual), the operator removes the OLD root. From this point, old signatures fail verification by design. This step requires an explicit typed consequences acknowledgment (UI confirmation dialog requiring the operator to type a confirmation phrase; CLI
--confirmplus an--i-understand-old-material-will-be-invalidatedflag) and is fully audited.
This applies to all roots — the env_settings verification roots and the regional crypto signing root (UseCryptoRegistry + KeyId) that attestor/evidence use for capsule/verdict signing. Signing-root rotation publishes both the old and new public keys in the JWKS during the grace window, re-envelopes (re-signs/re-seals) material under the new KeyId, then drops the old KeyId from the active JWKS — retaining the old public key as an archived root (see below).
Cross-cutting rules:
- Key versioning + metadata in the Platform store: each root carries
{ keyId/fingerprint, status (pending|active|retiring|archived|revoked), regionProfile, materialType, introducedAtUtc, graceDeadlineUtc, audit }. Mirror the IssuerDirectory enroll/approve/rotate/revoke lifecycle and keep the existingVerification:<profile>:<service>:...keys as the materialized “active set” that services pull.archived= verify-only, retained for immutable/transparency-anchored historical material; never used to sign or verify new material. - Storage — secrets are NEVER exposed in
env_settings.env_settingsholds only non-secret verification material: public keys (PacksRegistry RSA public key) and trust-root fingerprints. Secret material — notably VexHub’s HMAC-SHA256 trust roots (symmetric secrets pinned by the VEX feed contract) — is sealed in the platform’s existing encrypted credential store (ConnectorCredentialsAEAD viaICredentialStore/PostgresCredentialStore, encryption routed through the active regional crypto plugin, withConnectorCredentialReSealStorefor re-encrypt-on-rotation). It is never placed inenv_settings, and never depends on Vault (Vault is an optional integration, not a requirement). - Consequences gate is mandatory at Introduce (warn that a rotation is starting) and at Invalidate (warn that old material becomes unverifiable). No silent root-key change is possible from UI or CLI.
- Audit everything to the timeline — reuse the
signature.rotatedpattern and addverification.key.{introduced,reenvelope_started,reenvelope_completed,invalidated}events with correlation IDs. - Offline/air-gap first — re-enveloping operates on locally stored material + the operator-provided new key; no cloud/managed-KMS dependency (Vault is the only sanctioned backend for secret material; see open questions).
- UI/CLI parity — both surfaces drive the full lifecycle (set real key → introduce → monitor re-envelope → invalidate).
- Fail-closed preserved — the boot-time pull and the runtime guards remain fail-closed; a missing/active root for the current profile still aborts startup (unchanged behavior).
Consequences
- Operators get a safe, observable rotation with a grace window and a progress view; no instantaneous breakage.
- Old material becomes unverifiable only after a deliberate, acknowledged invalidation — the intended security outcome for retired/compromised keys.
- Cost: a re-enveloping job per material type, dual-trust verification paths (accept current+previous during grace), and key-versioning state. Re-enveloping a large corpus (many packs/attestations) is a long-running, resumable job that must be monitored (per the repo rule on long sweeps).
- HMAC trust roots (VexHub) are symmetric secrets, so their rotation uses the sealed credential-store path resolved below; they are never returned by the public verification-settings resolver.
Alternatives considered
- Hard cutover (replace key, no grace, no re-envelope): rejected — instantly breaks all existing signed material; unoperable.
- Never invalidate (only ever append trust roots): rejected — retired/compromised keys would validate forever (a security hole).
- Scheduled auto-rotation without an operator gate: rejected — a root-key change is consequential and must be operator-driven and explicitly acknowledged.
Resolved decisions (2026-06-10, operator)
- Secret storage — secrets are never exposed in
env_settings. Non-secret verification material (public keys, fingerprints) lives inenv_settings; secret material (VexHub HMAC trust roots) is sealed in the platform encrypted credential store (ConnectorCredentialsAEAD /ICredentialStore, regional-crypto-backed,ConnectorCredentialReSealStorefor rotation). Vault is NOT required (optional integration only). - Immutable / transparency-anchored material — verify it via a retained
archivedroot (verify-only). Do NOT re-issue. - Scope = all roots — rotate both the
env_settingsverification roots and the regional crypto signing root (UseCryptoRegistry/KeyId, JWKS-published) for attestor/evidence. - Grace deadline with re-envelope incomplete — warn-and-allow: invalidation proceeds with a residual-count warning; it is not blocked.
Pack trust publication addendum (2026-08-20)
The original single-valued materialization Verification:<profile>:PacksRegistry:PublicKeyPem is retained for compatibility, but it cannot by itself preserve dual trust across a process restart. The following rules complete the producer side of the Pack rotation contract without adding an endpoint:
- Every
PackPublicKeyledger record persists its exact, non-secretpublicMaterial, just as aSigningRootrecord does. Before a legacy active Pack record that lacks this field is demoted, Platform captures the current materialized scalar into that record. If the scalar is absent, inconsistent, or not valid RSA public-key PEM, introduce fails before its first store write and requires operator recovery. Exactly onePUBLIC KEYorRSA PUBLIC KEYPEM block (plus surrounding whitespace) is accepted; private-key or mixed content is rejected before persistence so secret material cannot enter this non-secret ledger/response surface. - A scalar-only legacy installation (no Pack ledger rows) remains readable. Platform publishes a synthetic active member named
legacy-pem-sha256-<sha256-of-the-exact-UTF8-PEM-text>. On the first lifecycle introduce, that same identity and exact PEM are persisted as the retiring root before the new scalar replaces it; the new record’ssupersedesKeyIdpoints to the synthetic identity. - The existing
GET /platform/verification-settings/{service}response for PacksRegistry carries all three flat configuration values:PacksRegistry:Verification:PublicKeyPem— unchanged active scalar;PacksRegistry:Verification:TrustSetJson— canonical JSON{"schema":"stellaops.packsregistry.verification-trust-set/v1","regionProfile":"<profile>","roots":[{"keyId":"...","status":"active|retiring","publicKeyPem":"..."}]};PacksRegistry:Verification:TrustEpoch—sha256:plus lowercase SHA-256 of UTF-8 without a BOM over<profile> + "\n" + <canonical-json>.
- Canonical roots use lowercase wire statuses and are sorted by ordinal
keyId, then status. Every PackkeyIdis stored in exact trimmed form, contains no control characters, and is at most 256 UTF-8 bytes. Introduce rejects an invalid id before any write, and publication fails closed when retained history violates that contract; operators must recover the exact history rather than truncate or relabel it. A real ledger must have exactly one active root; every retiring root is included. Missing or malformed ledger JSON, missing/invalid PEM, duplicate key ids or normalized RSA keys, case-insensitive key-id collisions or lifecycle-key variants, or an ordinal mismatch between the active ledger PEM and active scalar fails closed. No real-ledger member is silently omitted. Pack publication reads one direct coherent store snapshot on every pull, bypassing the general replica-local settings cache, so the first pull through any replica after invalidation commit cannot re-publish the revoked member. - Live Pack verification includes active + at most one retiring root only. A distinct introduce is rejected atomically while a prior root remains retiring; the operator must finish re-enveloping and invalidate it before starting another rotation. An exact retry of the already committed active id + PEM remains idempotent. Pack artifacts are mutable and re-enveloped, so an
archivedPack root is ledger/audit history, not live trust. Signing-root archived semantics remain unchanged: immutable/transparency-anchored material may continue to verify through archived signing-root JWKS members. Revoked roots are never published. - Pack introduce is one conditional store mutation, not a sequence of independently visible setting writes. Platform captures an expected-state token over the scoped and legacy-unscoped active scalars, every Pack ledger row, and Pack re-envelope progress. PostgreSQL serializes by a stable profile-scoped transaction advisory lock, directly re-reads and compares that state after taking the lock, and commits retiring metadata, new active metadata, the scoped scalar, and the initial re-envelope status in one transaction. When a unique pre-existing lifecycle row uses noncanonical casing, updates and deletes target that exact physical key; only an absent row is created with canonical spelling. The in-memory implementation mirrors this under one gate. A changed token raises a typed conflict and writes nothing, so concurrent different-key introduces cannot create two active roots or choose the scalar by last writer. Retrying the exact active key id and ordinal-equal PEM after a lost response returns the committed result without another transition; the same key id with different material is rejected. A different key id is also rejected when its RSA key matches any retained Pack ledger member after canonical
ExportSubjectPublicKeyInfonormalization and SHA-256, including alternate PKCS#1/SPKI PEM encodings and archived/revoked history. Lifecycle HTTP mutations surface the typed race as409with stable codeverification_root_state_conflict; other validation remains400. - Introduce, invalidate, Pack re-envelope progress, and legacy bootstrap are the only application writers of those rows and all use the same conditional mutation. Generic environment-setting
Set, batchSet, andDeletereject the unscoped/scoped Pack scalar, Pack metadata, and Pack re-envelope families. The existing admin PUT retains one narrow bootstrap: an exactVerification:<known-profile>:PacksRegistry:PublicKeyPemmay establish a strictly validated scalar only while the Pack ledger is empty. Exact material retry is idempotent; replacement, unknown-profile/unscoped writes, metadata/progress writes, and every generic delete are rejected. Rejected generic lifecycle-owned PUT/DELETE calls return409verification_setting_lifecycle_owned; a strict-bootstrap conditional race returns409verification_root_state_conflict. Pre-existing unscoped legacy rows remain readable and are captured by lifecycle introduce. - Direct Pack lifecycle snapshots are bounded before materialization: at most 256 retained root rows, at most 64 KiB UTF-8 per lifecycle value, and at most 4 MiB UTF-8 for the complete profile snapshot. PostgreSQL reads length columns first under sequential access and fetches at most one row beyond the row ceiling. Both supported mutation stores validate the complete projected state against the same bounds before their first write. Exceeding any bound fails publication and mutation closed for owner-approved recovery; no retained history is silently omitted or truncated.
- Invalidating the sole active Pack root deliberately leaves publication fail-closed; no retiring root is auto-promoted or rewritten. Recovery may introduce one cryptographically distinct new active root only when the real ledger has zero active members, no retiring member, and no scoped or unscoped active scalar row. Existing archived/revoked rows and their audit history remain unchanged and reserve their normalized RSA keys against relabeling/reintroduction. Any scalar-present or multi-active mismatch still requires operator recovery and writes nothing. A retained terminal row that predates
publicMaterialretention also blocks introduce: its identity cannot be reconstructed safely, the generic settings API cannot repair it, and the operator must recover the exact original public PEM for an owner-approved audited ledger repair or keep rotation fail-closed. - Platform and the startup consumer share one 64 KiB UTF-8 transport ceiling for the complete serialized PacksRegistry verification-settings response, not merely for each retained row. Introduce and legacy bootstrap serialize the exact projected outer dictionary with the endpoint JSON options before their first write; one byte over commits nothing. Publication independently applies the same check to retained state and fails closed for bounded operator recovery.
Offline-seed export trust addendum (2026-09-17)
The Pack trust publication addendum above is written around one material type and one algorithm. The offline-seed export key needs every rule in it and is neither. This addendum states how the mechanism generalizes and what stays different.
- A new material type
PackExportPublicKeyjoinsPackPublicKey,VexHmacTrustRootandSigningRoot. It governs the ES256 (NIST P-256) key that signscatalog-manifest.json.dsse.jsoninside an offline-seed export archive, which the importing estate verifies before admitting the archive’s packs. Its blast radius is the reason it is a separate family rather than another root in the Pack set: a pack key admits one pack, this key admits an entire catalog, so no document may ever present both as interchangeable trust. - Paragraphs 1–10 above apply unchanged to this family: retained
publicMaterial, canonical ordering and bounded key ids, exactly one active plus at most one retiring live root, the expected-state token, the profile advisory lock, one-transaction commit, the 256-row / 64 KiB-value / 4 MiB-snapshot ceilings, no auto-promotion on invalidating the sole active root, and no publication of revoked roots. They are applied by one implementation parameterized by a trust-family descriptor (VerificationTrustFamily), not by a second copy. A copied implementation of fail-closed rules does not fail loudly when it drifts; it silently admits what the original refused. - Exactly three things differ per family, and they are the descriptor’s contents:
- ledger/progress namespace segment —
pack-public-keyvspack-export-public-key; - materialized active scalar —
Verification:<profile>:PacksRegistry:PublicKeyPemvsVerification:<profile>:PacksRegistry:Export:PublicKeyPem; - the algorithm the public material must parse as — RSA vs ES256. Published keys follow:
PacksRegistry:Verification:{PublicKeyPem,TrustSetJson,TrustEpoch}andPacksRegistry:Export:Verification:{TrustSetJson,TrustEpoch}. The export names are exactly the section jobengine-web already binds, so the consumer needed no new configuration surface. The export family publishes no single-active compatibility scalar. Paragraph 3’s scalar exists for Pack consumers that predate the trust set; the export family has none, and the scalar would occupy the very configuration key an operator uses to configure a local key — leaving the consumer unable to distinguish a published root from a mounted one, which is exactly the ambiguity paragraph 8 below refuses. Pre-release, a new family gets no compatibility surface.
- ledger/progress namespace segment —
- The material validator is a sibling, not a reuse. Paragraph 1’s normalisation is RSA-specific (
ExportSubjectPublicKeyInfoover an RSA key, PKCS#1 and SPKI labels both accepted). The export family accepts only the SPKIPUBLIC KEYlabel and requires the P-256 curve specifically: a P-384 key imports cleanly as an EC key and would then fail to verify every archive the estate holds, so the curve is part of the material rule and not merely of the signature suite. The non-secret guarantees are identical and are enforced for both families before any store write — one PEM block plus surrounding whitespace, noPRIVATE KEYcontent, value within the 64 KiB bound — and the fingerprint is still taken overExportSubjectPublicKeyInfo, so alternate encodings of one key still collide and a revoked key still cannot be relabeled and reintroduced. - Serialization boundary. The advisory lock stays profile-scoped, so the two families’ rotations serialize against each other; rotations are rare operator actions and one ordering is worth more than the concurrency. The expected-state token is family-scoped and hashes the family id, so a pack rotation does not spuriously conflict a concurrent export rotation, and — because the id is inside the hashed material — two empty families do not produce the same token, which would otherwise let a stale token from one satisfy the other’s compare-and-swap.
- Bounds. Paragraph 8’s row and byte ceilings now apply per family snapshot: each family is read, bounded and committed independently, so each carries the stated ceiling rather than sharing it. Paragraph 10’s 64 KiB transport ceiling is the one that protects the consumer, and it applies to the complete serialized verification-settings response — both families together — as well as to each family’s publication as it is built. Exceeding either fails publication closed.
- No legacy bootstrap for the export family. Paragraph 7’s narrow generic-PUT bootstrap exists to rescue scalar-only installations that predate the Pack ledger. The export family has no such installed base, so it has no bootstrap path and no synthetic legacy identity: its first root arrives through lifecycle introduce. Generic
Set/Deletereject its rows exactly as they reject the Pack family’s, and that fencing is derived from the registered families rather than restated, so a future family is fenced off when it is registered rather than when someone remembers. - Consumer rule (jobengine-web).
PacksRegistry:Export:Verification:TrustSetJsonalready outranks a locally mountedPublicKeyPemFile, so a successful pull supersedes local configuration on its own. Configuring both at once is refused at startup. Resolving it by precedence would look correct while it ran and would let a key the operator believed they had revoked return the moment Platform sourcing was switched off; the failure being prevented is not “the wrong key is used today” but “the retired key comes back later”. An estate with no published export root keeps its local key and is unaffected, and an estate that never imports configures neither.
References
- Backend:
7a4f7921e2; Console UI:7446116190. PlatformVerificationSettingsConfigurationSource,VerificationSettingsResolver,GET /platform/verification-settings/{service}.- ADR-025 (operator-signed decisions);
docs/architecture/runtime-configuration-validation.md. - Original sprint (archived):
docs-archive/implplan/SPRINT_20260610_002_Platform_verification_key_rotation_reenveloping.md. - Pack trust producer repair:
docs/implplan/SPRINT_20260820_001_Platform_packsregistry_rotation_trust_set.md.
