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; their rotation and storage confidentiality (cleartext
env_settingsvs Vault-backed) must be decided (open question).
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.
References
- Backend:
7a4f7921e2; Console UI:7446116190. PlatformVerificationSettingsConfigurationSource,VerificationSettingsResolver,GET /platform/verification-settings/{service}.- ADR-025 (operator-signed decisions);
docs/architecture/runtime-configuration-validation.md. - Sprint:
docs/implplan/SPRINT_20260610_002_Platform_verification_key_rotation_reenveloping.md.
