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.
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.
