Runbook — Verification root-key rotation & re-enveloping
Audience: Platform / Crypto operators. Applies to: the signature-verification root keys, per regional crypto profile (world | fips | gost | sm | kcmvp | eidas), for three material types:
- PacksRegistry pack public key (
pack-public-key) — non-secret RSA public key. - VexHub HMAC-SHA256 trust roots (
vex-hmac-trust-root) — symmetric secrets, sealed in the encrypted credential store (neverenv_settings). - Regional crypto signing root (
signing-root,KeyId, JWKS-published) used by Attestor / EvidenceLocker for capsule/verdict signing.
Related ADRs: ADR-028 (this procedure + the four binding decisions D1–D4), ADR-007 (KEK rotation — distinct concern; the AEAD that seals VexHub secrets), ADR-019 (VEX verification as a mandatory ingest gate). Related runbooks: kek-rotation.md, credential-storage-algorithm-switch.md, operator-verify.md.
What this rotates vs. what it does NOT. This runbook rotates the keys used to verify existing signed material. It is not the master-KEK rotation (kek-rotation.md) — though the two interact: VexHub’s HMAC trust-root secrets are sealed by the master KEK, so if you are doing both, re-seal VexHub secrets after the new signing root is active (ADR-028 Decisions & Risks).
Required scopes. Read verbs (list roots / get / re-envelope status) require
platform.crypto.read(policyCryptoProviderRead). Consequential mutations (introduce / invalidate) requireplatform.crypto.profile.admin(policyCryptoProfileAdmin) — the same region-changing scope used for compliance-profile mutation.ops.adminis the operator escape hatch for both. Scope catalog:src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs; policy constants:src/Platform/StellaOps.Platform.WebService/Constants/PlatformPolicies.cs.
The model (read this first)
A verification root moves through a small lifecycle (mirrors the IssuerDirectory operator-key lifecycle):
introduce re-envelope invalidate
(none) ────────────────▶ active ──────────────────▶ active ───────────────▶ active
│ ▲
prior active ────────────┘ │
│ demoted to │
▼ │
retiring ──────────── (dual-trust grace: BOTH verify) ───────────────────┤
│
┌─ archived (D2: verify-only) ─┘ (RetainArchived=true)
└─ revoked (D3: old material FAILS) (RetainArchived=false)
active— the current root; new material is signed/verified against it. Exactly one per (profile, material type).retiring— the prior active root, kept verifying during the dual-trust grace window. New material is signed with the active root; old material still verifies under the retiring root.archived— verify-only (D2). Retained for immutable / transparency-anchored historical material that cannot be re-signed (e.g. a Rekor-anchored DSSE entry). Never used for new material. For signing roots this stays published in the JWKS with astatus: archivedmarker.revoked— dropped from the active set (D3). Material signed only under it fails verification by design — the intended security outcome for a retired/compromised key.
Three phases, applied per profile + per material type:
- Introduce (dual-trust) — add the NEW root; the current active root becomes
retiring. Both verify during the grace window. Consequences-gated. - Re-envelope — re-sign existing material under the new root. Resumable, offline-capable, idempotent.
- Invalidate (consequences-gated) — remove the OLD root (
archivedverify-only, orrevoked). After this, old signatures fail by design.
Fail-closed invariant (do not break it): a botched rotation may only ever degrade to “verification FAILS” (safe), never “verification skipped”. The boot-time verification-settings pull and every runtime guard stay fail-closed — a missing/empty active root for the current profile still aborts service startup.
Before you start
Confirm the active region profile. Rotation is per-profile; rotate the profile your install actually verifies under. Check it via the Console Cryptography page (
/setup/system/cryptography) orPUT/GET /api/v1/admin/crypto-providers/compliance-profile(tenanttenant_compliance_profile).List the current roots for the profile + material type you are rotating:
stella crypto verification-key list --profile world --material-type pack-public-keyor via the API (read scope
platform.crypto.read):curl -sH "Authorization: Bearer $TOKEN" \ https://stella-ops.local/platform/verification-keys/world | jqYou should see exactly one
activeroot and nopending/retiringleftovers from a prior incomplete rotation. If a prior rotation is mid-flight (aretiringroot still present, or a non-zeroresidualon its re-envelope status), finish or invalidate that first — do not stack rotations.Have the NEW key material ready.
pack-public-key: the new RSA public key PEM (for the active set) and the matching private key PEM (offline re-signing only — never persisted).vex-hmac-trust-root: the new HMAC-SHA256 secret (in a file; it is sealed, never echoed) and itssha256-fingerprint.signing-root: the new signing key enrolled in the regional crypto registry (KeyId), and its public PEM.
Fingerprint form. Key-ids / fingerprints MUST use the delimiter-safe
sha256-form, neversha256:. The colon form is parsed by the .NET configuration binder as an extra nesting level and the trust root is silently dropped (docs/architecture/runtime-configuration-validation.md §6).verification-key introducerejectssha256:up front.
Phase 1 — Introduce the new root (begins dual-trust grace)
This is consequences-gated. There is no silent root change from any surface.
CLI
stella crypto verification-key introduce \
--profile world \
--material-type pack-public-key \
--key-id sha256-<new-fingerprint> \
--public-material-file ./new-pack-public.pem \
--grace-deadline 2026-07-01T00:00:00Z \
--confirm \
--i-understand-old-material-will-be-invalidated
Without both --confirm AND --i-understand-old-material-will-be-invalidated, the command refuses, prints the exact typed consequences phrase, and exits non-zero (13) — no mutation.
For a VexHub HMAC trust root the secret is sealed, never inlined:
stella crypto verification-key introduce \
--profile world --material-type vex-hmac-trust-root \
--key-id sha256-<new-fingerprint> \
--secret-file ./new-vex-hmac.secret \
--confirm --i-understand-old-material-will-be-invalidated
For a signing root pass the public PEM via --public-material-file (the private key lives in the regional crypto registry, not here).
API
curl -sX POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
https://stella-ops.local/platform/verification-keys/world/introduce \
-d '{
"keyId": "sha256-<new-fingerprint>",
"materialType": "pack-public-key",
"publicMaterial": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
"graceDeadlineUtc": "2026-07-01T00:00:00Z",
"acknowledgement": "<typed phrase>"
}'
If the acknowledgement is missing or wrong, the endpoint returns 422 consequences_acknowledgement_required with the exact requiredPhrase you must echo. (For VexHub HMAC, send secretMaterial instead of publicMaterial; the secret is sealed and never echoed back.)
What happens on introduce:
- The new root is recorded
active; the prior active root becomesretiring. - For
pack-public-key, the materialized active-set value (Verification:<profile>:PacksRegistry:PublicKeyPem) is overwritten with the new public key — services pulling at boot now verify under the new key. - For
vex-hmac-trust-root, the secret is sealed in the encrypted credential store and its fingerprint is added to the non-secret active index (Verification:<profile>:VexHub:TrustRootFingerprints). During grace the index holds both fingerprints, so the VexHub boot pull serves both (dual-trust). - For
signing-root, the public PEM is retained on the version-ledger record and the JWKS set served to EvidenceLocker/Attestor now dual-publishes the active- retiring public keys during grace.
- Audit:
verification.key.introduced(AuditActions.Platform.VerificationKeyIntroduced).
Verify the grace state:
stella crypto verification-key list --profile world --material-type pack-public-key
# expect: one active (new key), one retiring (old key) with the grace deadline.
At this point both old and new material verify. Do not invalidate yet.
Phase 2 — Re-envelope existing material under the new root
Re-enveloping re-signs/re-seals the existing corpus so it verifies under the new root. It is resumable, idempotent, and offline-capable (no external services — see the air-gap section). Run it per material type.
Packs
stella crypto verification-key reenvelope \
--profile world \
--new-key-id sha256-<new-fingerprint> \
--new-public-key-file ./new-pack-public.pem \
--new-private-key-file ./new-pack-private.pem
This launches the bulk job (POST /api/v1/packs/re-envelope, PackReEnvelopeJob): it iterates the pack corpus, re-signs each pack under the new key via RotateSignatureAsync (re-verifies + emits signature.rotated), and reports total / re-enveloped / failed / skipped progress back to the foundation status endpoint. A pack whose current signature already verifies under the new key is skipped (idempotent), so a second run does no work. Progress persists to a durable per-(tenant, profile, newKeyId) checkpoint, so an interrupted run resumes without re-processing converted packs.
DSSE attestations / evidence bundles (signing root)
The CapsuleReEnvelopeJob (EvidenceLocker) adds a new-keyId signature to each capsule’s DSSE envelope (multi-sig — the old signature is preserved so already-distributed bundles still verify; the payload is never mutated), re-verifies the merged envelope under the new key before persisting (a bad signature is a failure, never persisted = fail-closed), is idempotent, resumable, and offline.
D2 — immutable / transparency-anchored material is NOT re-signed. A capsule flagged immutable (e.g. a Rekor-anchored entry) is recorded
Immutable/ skipped, not failed. It keeps verifying via the archived root you retain at invalidation. Do not try to re-issue it.
VEX
Re-sign / re-ingest the VEX statements under the new trust root (the publisher re-signs with the new HMAC secret; VexHub then verifies under the new fingerprint). The new fingerprint is already served alongside the old during grace.
Monitor progress
stella crypto verification-key reenvelope-status \
--profile world --material-type pack-public-key
or:
curl -sH "Authorization: Bearer $TOKEN" \
https://stella-ops.local/platform/verification-keys/world/pack-public-key/reenvelope-status | jq
# { total, reEnveloped, failed, skipped, completed, residual }
The same progress is rendered in the Console Cryptography page’s “Root rotation lifecycle” view (progress bar + residual banner). residual = total − reEnveloped − skipped is the count of not-yet-re-enveloped artifacts.
Long-sweep discipline. Re-enveloping a large corpus is a long, resumable job — monitor it; never run it unmonitored (repo §long-sweep rule). It is safe to interrupt and resume.
Re-envelope is complete when completed: true and residual: 0.
Phase 3 — Invalidate the old root (consequences-gated)
Once re-enveloping is complete (or you explicitly accept the residual — D4), remove the OLD root. From this point, old signatures fail verification by design.
Choose archive vs. revoke
--retain-archived/retainArchived: true(D2) — move the old root toarchived(verify-only). Use this when immutable / transparency-anchored material still needs to verify under the old key (it was not re-signed). The archived public key stays in the signing-root JWKS with astatus: archivedmarker; it is never used for new material.- revoke (
retainArchived: false) — drop the old root entirely. Use this for packs (mutable, fully re-enveloped) or when the old key is compromised and must validate nothing further.
CLI
stella crypto verification-key invalidate \
--profile world \
--material-type pack-public-key \
--key-id sha256-<OLD-fingerprint> \
--confirm \
--i-understand-old-material-will-be-invalidated
(Add --retain-archived to archive instead of revoke.) Without both consequence flags, the command refuses, prints the consequences, and exits non-zero.
API
curl -sX POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
https://stella-ops.local/platform/verification-keys/world/invalidate \
-d '{
"keyId": "sha256-<OLD-fingerprint>",
"materialType": "pack-public-key",
"retainArchived": false,
"acknowledgement": "<typed phrase>"
}'
A wrong/missing acknowledgement returns 422 with the requiredPhrase.
What happens on invalidate:
- The old root moves to
archived(D2) orrevoked(D3). - For
pack-public-keyrevoke: the materialized active-set value is left carrying the NEW key (the introduce already overwrote it); if you somehow revoke the sole active root with no replacement, the slot is cleared and services fail closed at boot (no key → abort, never verify-skipped). - For
vex-hmac-trust-root: the old fingerprint is dropped from the active index first (authoritative fail-closed gate) and its sealed credential row is revoked; survivors are untouched. - For
signing-rootrevoke: the oldKeyIdis dropped from the active JWKS set (excluded fromListSigningRootSetAsync); archived roots stay published. - D4 — warn-and-allow. If the grace deadline has passed with re-envelope incomplete, invalidation still proceeds and surfaces a residual count (
residual,graceExpired) in the result and audit — it is not blocked. - Audit:
verification.key.invalidated(AuditActions.Platform.VerificationKeyInvalidated).
Post-rotation verification
List shows the new state:
stella crypto verification-key list --profile world --material-type pack-public-key # expect: one active (new key); the old key archived OR absent (revoked).New material verifies; old material fails. This is the security outcome the e2e test proves (
VerificationKeyRotationE2ETests,src/Platform/__Tests/StellaOps.Platform.WebService.Tests/): post-invalidation, material signed under the new root verifies and material signed only under the revoked root fails — for packs (RSA), signing roots (DSSE), and VEX (HMAC).Immutable material still verifies (D2). If you archived (not revoked), a transparency-anchored DSSE entry signed under the old key still verifies via the archived JWKS entry — no re-issue.
Signing-root JWKS reflects the change. EvidenceLocker/Attestor pull the set at
GET /platform/verification-keys/{profile}/signing-root/jwks-set(30s cache, fail-closed to empty on error). After a revoke the oldKeyIdis gone from the active set; after an archive it remains withstatus: archived.Audit trail. The timeline carries
verification.key.{introduced, reenvelope_started, reenvelope_completed, invalidated}with correlation ids and non-secret counters only.
Consequences & recovery
What invalidation actually breaks
Revoking a root makes every artifact signed only under it stop verifying. That is the point — for a retired/compromised key it is the desired outcome. The danger is invalidating before re-enveloping is complete: any not-yet-re-enveloped artifact then fails verification. D4 lets you proceed past the grace deadline with a residual, but the residual artifacts will fail until re-enveloped.
“I invalidated too early — material that should verify now fails.”
- If you archived (not revoked): the old root is still verify-only. Re-envelope the stragglers; they will verify under the new key once re-signed, and the archived root keeps the rest verifying meanwhile. No data loss.
- If you revoked and the corpus is re-signable: re-introduce the situation by running re-envelope again under the active root — the source material + the new signer are all you need (offline). Already-re-enveloped artifacts are skipped (idempotent). The revoked root does not need to come back.
- If you revoked AND the artifact is immutable AND you have no archived root: this is the irrecoverable case — there is no way to make an un-re-signable, transparency-anchored entry verify under a key you removed. Prevention: for any profile that holds immutable/transparency-anchored material, always
--retain-archivedat invalidation (D2). If you have a PostgreSQL snapshot from before the invalidation, restore the version ledger row to re-publish the archived key (migration-recovery.md).
“A service won’t start after rotation.”
That is the fail-closed guard working as designed: the boot-time pull found no active root for the current profile (e.g. you revoked the sole active root with no replacement). Fix forward — introduce a valid active root for that profile, then restart. Never disable the guard.
“I need to abort an in-flight rotation.”
Before invalidation, a rotation is fully reversible: re-envelope is idempotent and the old root is still retiring (both keys verify). To abort, simply do not invalidate; if you want to discard the new root, invalidate the new key-id (revoke) — the old root remains active and you are back to the pre-rotation state.
Air-gap / offline operation
The entire rotation cycle is air-gap-safe and requires no external services:
- The CLI talks only to the internal Platform endpoints over the deployment’s own network. No internet, no managed KMS, no Vault dependency (VexHub HMAC secrets are sealed by the local regional-crypto-backed credential store).
- Re-enveloping runs against the locally stored corpus + the operator-supplied new key + a local file checkpoint (the durability surface). Interrupt and resume freely.
- Proven by
VerificationKeyRotationE2ETests.Offline_AirGap_ReEnvelope_NoExternalServices_FileCheckpointResumes(in-memory stores, local RSA crypto, file checkpoint resume — no PostgreSQL, no network).
The procedure on an air-gap install is identical to the steps above; only ensure the new key material is staged on the deployment’s internal media before you begin.
Related
- ADR-028 — verification root-key rotation & re-enveloping:
../architecture/decisions/ADR-028-verification-key-rotation-and-re-enveloping.md - Sprint dossier:
docs/implplan/SPRINT_20260610_002_Platform_verification_key_rotation_reenveloping.md - Lifecycle API:
src/Platform/StellaOps.Platform.WebService/Endpoints/VerificationKeyLifecycleEndpoints.cs(MapVerificationKeyLifecycleEndpoints) - Version ledger / orchestration:
src/Platform/StellaOps.Platform.WebService/Services/VerificationRootRegistry.cs - VexHub secret seal store (D1):
src/Platform/StellaOps.Platform.WebService/Services/VexHubTrustRootSealStore.cs - Pack re-envelope job (VKR-004):
src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Core/Services/PackReEnvelopeJob.cs - DSSE re-envelope + multi-key verify (VKR-006):
src/EvidenceLocker/StellaOps.EvidenceLocker/Capsules/Rotation/ - Consequences gate (VKR-007):
src/Platform/StellaOps.Platform.WebService/Contracts/ConsequencesAcknowledgment.cs - CLI command group:
src/Cli/StellaOps.Cli/Commands/VerificationKeyCommandGroup.cs - Console UI:
/setup/system/cryptography→ “Root rotation lifecycle” - E2E + offline proof:
src/Platform/__Tests/StellaOps.Platform.WebService.Tests/VerificationKeyRotationE2ETests.cs - Boot-time pull + fail-closed guards:
docs/architecture/runtime-configuration-validation.md(note thesha256-notsha256:fingerprint constraint) - Scope catalog:
src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs
