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 the
crypto:readclaim (policyplatform.crypto.read/CryptoProviderRead). Consequential mutations (introduce / invalidate) and the interactive progress-ingest fallback require thecrypto:profile:adminclaim (policyplatform.crypto.profile.admin/CryptoProfileAdmin) — 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) for immutable / transparency-anchored historical material that cannot be re-signed (e.g. a Rekor-anchored DSSE entry). For signing roots this stays published in the JWKS with astatus: archivedmarker. Packs are mutable and re-enveloped, so an archived Pack root is audit history only and is excluded from live Pack trust.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 (
archivedunder the material-specific semantics below, orrevoked). For Pack roots, either state leaves live trust and 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. For a real Pack ledger, Platform requires exactly one active member and every active/retiring member’s valid RSA public PEM; corrupt JSON, missing material, duplicate key ids or normalized RSA keys, case-insensitive key-id collisions or lifecycle-key variants, or an active-scalar mismatch aborts the pull. No retiring member is silently omitted. Pack key ids must be in exact trimmed form, contain no control characters, and fit within 256 UTF-8 bytes. Platform rejects a violating introduce before it writes anything and refuses to publish a retained violating row. Do not truncate or relabel historical ids; recover the exact retained state through an owner-approved audited repair.
PacksRegistry verification-settings wire contract
The existing GET /platform/verification-settings/packsregistry?profile=<profile> route returns three flat values (there is no rotation-specific endpoint):
{
"PacksRegistry:Verification:PublicKeyPem": "<active PEM, unchanged compatibility scalar>",
"PacksRegistry:Verification:TrustSetJson": "{\"schema\":\"stellaops.packsregistry.verification-trust-set/v1\",\"regionProfile\":\"world\",\"roots\":[{\"keyId\":\"sha256-new\",\"status\":\"active\",\"publicKeyPem\":\"...\"},{\"keyId\":\"sha256-old\",\"status\":\"retiring\",\"publicKeyPem\":\"...\"}]}",
"PacksRegistry:Verification:TrustEpoch": "sha256:<lowercase hex>"
}
TrustSetJson is canonical JSON with fixed property order. Roots use lowercase wire statuses and are sorted ordinally by keyId, then status; PEM text is not trimmed or newline-normalized. The epoch hashes UTF-8 without BOM over <profile> + "\n" + <canonical-json>. Live trust is active + every retiring root. Archived Pack roots are audit-only and revoked roots are never included. Exactly one RSA PUBLIC KEY or RSA PUBLIC KEY PEM block plus surrounding whitespace is accepted. Never send a private key to Platform: private-key or mixed content is rejected before any introduce write and is never eligible for the scalar-only compatibility response. For collision checks, Platform imports either public PEM form, exports canonical DER SubjectPublicKeyInfo, and hashes it with SHA-256. Thus two live roots cannot publish the same RSA key under different ids or alternate PUBLIC KEY / RSA PUBLIC KEY encodings.
Every Pack verification-settings pull reads one coherent snapshot directly from the owning store; it does not use the replica-local general settings cache. Once an invalidation transaction commits, the next pull through any Platform replica therefore excludes the old root even if that replica’s generic cache is stale. Case-insensitive duplicate scalar, Pack-ledger, or Pack-progress keys fail closed before publication or mutation and require operator recovery. A unique row with noncanonical casing is not duplicated: conditional updates/deletes retain its exact physical spelling, while a genuinely absent row is created canonically. This keeps PostgreSQL and in-memory lifecycle behavior identical.
A scalar-only legacy install is returned as one synthetic active member with id legacy-pem-sha256-<sha256-of-the-exact-UTF8-PEM-text>. This name is a digest of the stored text, not a claim about the DER key fingerprint. On the first lifecycle introduce, Platform persists that same synthetic member as retiring before replacing the scalar, so a newly started consumer still receives old + new trust. A legacy active ledger row missing publicMaterial is backfilled from the scalar before demotion; if recovery is impossible, introduce performs no write and tells the operator to repair the ledger/scalar.
An older archived/revoked Pack row may predate public-material retention. Such a row blocks every later introduce because Platform cannot reconstruct or compare its RSA identity safely. Do not delete it, guess its key, or use generic environment-setting PUT/DELETE (those routes intentionally reject lifecycle rows). Recover the exact original public PEM from custody/audit evidence and request an owner-approved, audited ledger repair. If the exact PEM is unavailable, leave rotation blocked and escalate; invalidation does not make an unknown key safe to reuse.
Pack introduce is serialized per region profile and commits the retiring record, new active record, active scalar, and initial re-envelope status atomically. If two operators or replicas introduce different keys from the same prior state, exactly one commits; the other receives HTTP 409 verification_root_state_conflict and must re-read before deciding whether to start another rotation. The same 409 applies to a raced invalidate or Pack progress report; ordinary validation remains 400. If an operator loses the response after commit, retrying the exact same key id and PEM is safe and returns the already-committed active transition without duplicating audit or resetting re-envelope progress. Never change the PEM while reusing that key id: Platform rejects different material. Key ids are case-insensitively unique, so a spelling that differs from an existing id only by case is rejected before any write rather than treated as a new root. A different key id is not an escape hatch: introduce compares the normalized RSA SPKI fingerprint against every retained Pack ledger row, including retiring, archived, and revoked history, and rejects the write when the physical key was seen before. Only the exact active id + exact PEM retry is idempotent; after invalidation generate a genuinely new RSA key rather than relabeling the old one. The id itself must also be exact trimmed, control-free, and at most 256 UTF-8 bytes; the limit is a byte limit, so check multibyte ids before submitting the rotation.
PacksRegistry durable tenant/profile signature floor
The Platform trust publication is process input; the PacksRegistry floor is the durable storage admission boundary. For each tenant being re-enveloped, the first sweep call records (tenant_id, region_profile, active_key_id) under the global admission lock and records the observed trust epoch. The key id, not the epoch, is the monotonic security identity. A later publication may legitimately report the same active key with a different epoch (for example after another root is revoked); PacksRegistry updates the current epoch and appends the distinct observation to pack_signature_floor_epochs. Never compare epochs lexically, treat them as a generation counter, or delete old epoch facts.
For the next A-to-B rotation, the coherent active+retiring publication must identify the currently floored A as retiring before B may advance the floor. Do not supply guessed or historical key ids as the superseded set. Once B is committed, the floor survives every process restart and is never cleared after sweep completion. A stale host that still believes A is active receives one of these typed conflicts before publication:
409 pack_signature_floor_mismatch— an upload, rotation, re-envelope write or new/changed seed row was verified by a profile/key different from the tenant’s floor. Refresh the Platform trust snapshot (or restart the stale replica), then retry the original request. The rejected attempt must have no request-owned catalog claim, capacity reservation or blob.409 pack_signature_floor_regression— the requested active key did not supersede the current floor. Re-read the current Platform active+retiring publication; never force or delete the floor.409 pack_upload_claim_verification_mismatch— an exact operation revival found a durable claim accepted under another profile/key. Leave it for exact owner retry or stale-claim reconciliation; do not publish new-key bytes through the old claim.409 re_envelope_claims_active— a staging or seed-expansion claim is in flight. Let that writer finish or let fenced reconciliation converge it, then retry the floor advance/final fixed-point check.
The profile predicate is exact: a world floor does not couple fips, and vice versa. Pack-id ordering is never part of admission; a stale replica cannot publish a newly discovered lower-id pack after another replica advances the floor. Same-marker seed restart validation may use the live dual-trust set during grace, but every changed or new generation row must be accepted by the exact floored active key. The in-flight-claim fixed-point check remains tenant-wide because each profile sweep rewrites the same catalog; wait for claims from every profile before advancing a sweep.
Before declaring a tenant sweep complete, confirm both the fixed point and the durable facts:
SELECT tenant_id, region_profile, active_key_id, trust_epoch, advanced_at
FROM packs.pack_signature_floors
WHERE tenant_id = '<tenant>' AND region_profile = '<profile>';
SELECT active_key_id, trust_epoch, observed_at
FROM packs.pack_signature_floor_epochs
WHERE tenant_id = '<tenant>' AND region_profile = '<profile>'
ORDER BY observed_at, active_key_id, trust_epoch;
SELECT pack_id, catalog_state, artifact_origin, upload_operation_id,
upload_verification_profile, upload_accepting_key_id, upload_trust_epoch
FROM packs.packs
WHERE tenant_id = '<tenant>'
AND (catalog_state = 'staging'
OR (artifact_origin = 'seed' AND upload_operation_id IS NOT NULL));
Expected: exactly one current floor for the tenant/profile, append-only epoch observations, and no in-flight rows. A missing floor after a completed sweep is not an invitation to recreate it by guessing; stop and recover from PostgreSQL/audit evidence.
Do not edit Pack scalar, ledger, or re-envelope rows through generic environment settings. Generic PUT/DELETE reserves those families for the lifecycle. The one bootstrap exception is an exact scoped key for a known profile (Verification:<profile>:PacksRegistry:PublicKeyPem) while no Pack ledger exists; it accepts only public RSA PEM, an exact retry is idempotent, and changing the PEM is rejected. Existing unscoped legacy rows remain readable but are not generically mutable; run lifecycle introduce to capture them safely. A blocked generic PUT or DELETE returns HTTP 409 with stable code verification_setting_lifecycle_owned. If the strict bootstrap loses its conditional-state race it instead returns 409 verification_root_state_conflict; re-read the lifecycle state before retrying.
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. Platform enforces this: a distinct Pack introduce writes nothing while a retiring root exists, while an exact active-id/PEM retry remains safe.Direct lifecycle reads also fail closed when a profile exceeds 256 retained root rows, a lifecycle value exceeds 64 KiB UTF-8, or the profile snapshot exceeds 4 MiB UTF-8. These are recovery bounds, not permission to delete or truncate audit history; escalate for an owner-approved exact repair.
The complete JSON response consumed at startup has its own 64 KiB ceiling. Platform validates the exact projected outer dictionary before introduce or legacy-bootstrap writes, so individually valid PEM rows that would overflow the active+retiring response are rejected without mutation. A retained over-limit response is a fail-closed recovery incident.
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 submit or persist it in Platform). Introduce accepts onlyPUBLIC KEY/RSA PUBLIC KEY.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. Both old and new exact public PEMs remain on their ledger records, and the existing verification-settings pull publishes them as the canonical active+retiring trust set. A fresh/restarted consumer therefore preserves grace verification without process memory of the prior scalar. - 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-keyinvalidation, the materialized active-set value is left carrying the NEW key when the target was retiring (the introduce already overwrote it). If the target is the sole active root with no replacement, the slot is cleared for either archive or revoke and services fail closed at boot (no key → abort, never verify-skipped). Revoked and archived Pack records are both excluded from the liveTrustSetJson; archive is ledger history only for this mutable material type. - 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).For packs, pull
GET /platform/verification-settings/packsregistry?profile=worldfrom a fresh process. During grace, parsePacksRegistry:Verification:TrustSetJsonand expect oneactiveplus everyretiringroot. After revoke, expect only the newactiveroot and a changedPacksRegistry:Verification:TrustEpoch.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 a signing root (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 immutable history verifying meanwhile.
- If you archived a Pack root: Pack archives are audit-only, so old Pack signatures no longer verify. Re-envelope re-signable stragglers under the active root; do not expect archive to restore live Pack trust.
- 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 invalidating the retiring root, re-envelope is idempotent and both keys still verify, so the safest pause is to leave the dual-trust state intact while you choose a fix-forward path. The lifecycle does not automatically promote a retiring root. Invalidating the new active key clears the active scalar and makes Pack publication fail closed; it does not restore the old root or the pre-rotation state. That zero-active state does not waive the one-in-flight-rotation gate: while the old root remains retiring, a distinct introduce writes nothing. Finish the consequences review and invalidate that retiring root, or recover an operator-reviewed PostgreSQL snapshot and reconcile artifacts already re-enveloped after it. Only a zero-active, scalar-absent state with no retiring root may accept one cryptographically distinct replacement; archived/revoked history remains preserved. Never repair these rows through generic environment-settings PUT/DELETE.
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 - Archived sprint dossier:
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
