Key Rotation Runbook
Module: Signer / Key Management (
src/Attestor/StellaOps.Signer,src/Attestor/__Libraries/StellaOps.Signer.KeyManagement) Version: 1.0.0 Last Updated: 2025-12-17
Audience: operators and security engineers who manage the signing-key lifecycle for Stella Ops attestations and trust anchors.
This runbook describes procedures for managing the signing-key lifecycle in Stella Ops — key rotation, revocation, trust-anchor management, temporal verification, and TUF/Rekor key distribution.
How to read this runbook. The two notes below separate what is implemented and usable today (the Signer
/api/v1/anchorsHTTP API and thedevops/scripts/rotate-*shell scripts) from what is aspirational or a non-functional stub (anchor-management CLI verbs,signer.yaml,signer_*metrics). Anything marked NOT IMPLEMENTED is clearly flagged inline — do not build automation on it.
Source-of-truth note (reconciled 2026-05-30): The implemented surface for trust-anchor key rotation is the Signer service HTTP API rooted at
/api/v1/anchors(KeyRotationEndpoints.cs), backed byKeyRotationService/TrustAnchorManagerand thesigner.key_history/signer.key_audit_log/signer.trust_anchorstables. The TUF / Rekor key-rotation flows are implemented as thedevops/scripts/rotate-rekor-key.shanddevops/scripts/rotate-signing-key.shshell scripts.Not implemented / not usable (flagged inline below): a “create anchor” / “list anchors” HTTP endpoint, the
signer.yamlprofile/rotation config block, thesigner_*Prometheus metrics, and thedevops/trust-repo-template/scripts. Thestellaops anchorCLI verb group (list/show/create/revoke-key) exists as code (src/Cli/StellaOps.Cli/Commands/Proof/AnchorCommandGroup.cs) but every handler is a// TODOstub that prints “pending implementation,” and the group is not registered inCommandFactory— so it is unreachable from the shipped CLI. Where this runbook shows anchor-management orsigner.yaml/metrics commands they are aspirational/roadmap or non-functional stubs and are marked accordingly — do not treat them as available tooling.Implemented CLI surfaces that do exist (used in the workflow below):
stellaops key creategenerates a file-backed ECDSA-P256/P384 signing keypair (new Command("key", "Manage attestation signing keys.")inCommandFactory.cs, handlerHandleAttestKeyCreateAsync);stellaops kmsmanages file-backed keys (export/import); both are registered at the CLI root. Astellaops trust status --show-keyshandler is also fully implemented (TrustCommandHandlers.HandleStatusAsync, real--show-keys/-koption) but itstrustverb group is not wired intoCommandFactory, so it is presently unreachable from the CLI (see TUF-Based Key Rotation).
Overview
StellaOps uses signing keys to create DSSE envelopes for proof chain attestations. Key rotation is critical for:
- Limiting exposure from compromised keys
- Compliance with key age policies (e.g., NIST SP 800-57)
- Transitioning between cryptographic algorithms
Key Principles
- Never mutate old DSSE envelopes - Signed content is immutable
- Never remove keys from history - Revoke (stamp
revoked_at/revoke_reason, move into the anchor’srevokedKeyIds); thesigner.key_historyrow is retained, not deleted - Distribute public-key material - Via the TUF/Rekor repository (
devops/scripts/rotate-rekor-key.sh); the anchor’s current keys are also readable from the Signer/keys/historyendpoint - Audit all changes - Every add/revoke writes a
signer.key_audit_logrow and a platform audit event (AuditModules.Attestor) - Maintain key version history - For forensic / temporal verification (
CheckKeyValidityAsync)
Signing Algorithms
The set of algorithms accepted when adding a key to a trust anchor is enforced by KeyRotationService via KeyRotationOptions.AllowedAlgorithms (src/Attestor/__Libraries/StellaOps.Signer.KeyManagement/KeyRotationService.cs). The default allow-list is:
| Family | Algorithm identifiers (as validated, case-insensitive) |
|---|---|
| ECDSA | ES256, ES384, ES512 |
| RSA (PKCS#1 v1.5 / PSS) | RS256, RS384, RS512, PS256, PS384, PS512 |
| EdDSA | ED25519, EdDSA |
| Regional | SM2, GOST12-256, GOST12-512 |
| Post-quantum | DILITHIUM3, FALCON512 |
| Deprecated (warn-only, still accepted) | RSA-2048, SHA1-RSA |
RSA-2048 and SHA1-RSA are flagged by KeyRotationOptions.DeprecatedAlgorithms; keys using them are still accepted but surface an AlgorithmDeprecating rotation warning.
NOT IMPLEMENTED — named “signing profiles” (
default/fips/gost/sm2/pqc) and asigner.yamlprofile/rotation config block. There is no profile registry, no per-profile key-store binding, and no/etc/stellaops/signer.yamlschema in source. The Signer service crypto options live inSignerCryptoOptions(src/Attestor/StellaOps.Signer/StellaOps.Signer.Infrastructure/Options/SignerCryptoOptions.cs) with fieldsKeyId,AlgorithmId,Secret,ProviderName,Mode— the dev default is a local HMAC provider (ProviderName = "LocalHmacProvider"), not a cloud KMS. StellaOps is on-prem/sovereign-first: there is no AWS/Azure/GCP KMS provider, and none is a default. Regional crypto (GOST/SM/eIDAS/FIPS) and HSM/PKCS#11 backends are delivered through the crypto plugin framework (src/Cryptography/,src/SmRemote/); HashiCorp Vault is the flagship KEK/secret backend.
Key Rotation Workflow
All trust-anchor key endpoints below are mounted under /api/v1/anchors on the Signer service and require the signer:rotate scope (ASP.NET policy KeyManagement, registered in StellaOps.Signer.WebService/Program.cs; scope constant StellaOpsScopes.SignerRotate). Every mutating call writes a row to signer.key_audit_log and returns its auditLogId.
Step 1: Generate New Key
NOT IMPLEMENTED — there is no “generate key” HTTP endpoint. The Signer service does not expose
POST /v1/signer/keys;KeyRotationEndpointsonly manages keys already bound to a trust anchor. Generate the keypair out-of-band, then register its public key on the anchor in Step 2. TheAddKeyAPI persists the public key in PEM form (AddKeyRequest.PublicKey); the private key never leaves your key store. Generation options that exist in this codebase:# Implemented + wired CLI: create a file-backed ECDSA keypair under ~/.stellaops/keys # (algorithm: ECDSA-P256 default, or ECDSA-P384; --export-public writes the .pub alongside) stellaops key create --name key-2025-prod --algorithm ECDSA-P256 --export-public(handler
HandleAttestKeyCreateAsync, registered viaBuildKeyCommandinsrc/Cli/StellaOps.Cli/Commands/CommandFactory.cs). Thestellaops kmscommand group (export/import of file-backed keys) is also available.opensslor your HSM/PKCS#11 tooling are equally valid. There is no literalstellaops key generateverb — the generation verb isstellaops key create.
Step 2: Add Key to Trust Anchor
Add the new public key to the trust anchor without removing the old key:
# Via API — POST /api/v1/anchors/{anchorId}/keys
curl -X POST https://signer.stellaops.local/api/v1/anchors/550e8400-e29b-41d4-a716-446655440000/keys \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"keyId": "key-2025-prod", "publicKey": "<pem-encoded>", "algorithm": "ED25519"}'
# Response 201 Created:
# { "keyId": "key-2025-prod",
# "anchorId": "550e8400-e29b-41d4-a716-446655440000",
# "allowedKeyIds": ["key-2024-prod", "key-2025-prod"],
# "auditLogId": "..." }
The request body is AddKeyRequestDto — required fields keyId, publicKey, algorithm (must be in the allow-list above); optional expiresAt and metadata. A duplicate keyId on the same anchor returns 400; an unknown anchor returns 404.
There is no usable
stellaops anchor add-keyCLI command — use the HTTP API. (TheAnchorCommandGroupscaffoldslist/show/create/revoke-keybut notadd-key, every handler is a no-op stub, and the group is not wired into the CLI.)
Result: Trust anchor now accepts signatures from both old and new keys.
Step 3: Transition Period
During transition:
- New signatures are created with the new key
- Old proofs are verified with either key (temporal validity, see below)
- Monitor for verification failures
Recommended transition period: 2-4 weeks
Inspect anchor state through the read endpoints (no dedicated “status” verb exists):
# Full key history for the anchor — GET /api/v1/anchors/{anchorId}/keys/history
curl https://signer.stellaops.local/api/v1/anchors/550e8400-e29b-41d4-a716-446655440000/keys/history \
-H "Authorization: Bearer $TOKEN"
# Rotation warnings (expiry / long-lived / deprecated-algorithm)
# GET /api/v1/anchors/{anchorId}/keys/warnings
curl https://signer.stellaops.local/api/v1/anchors/550e8400-e29b-41d4-a716-446655440000/keys/warnings \
-H "Authorization: Bearer $TOKEN"
NOT IMPLEMENTED — there is no
stellaops anchor statuscommand and no “verification success rate” / “pending rescans” surface in the Signer service. The available reads are/keys/history,/keys/warnings, and per-key/keys/{keyId}/validity.
Step 4: Revoke Old Key (Optional)
After transition is complete, revoke the old key:
# Via API — POST /api/v1/anchors/{anchorId}/keys/{keyId}/revoke
curl -X POST https://signer.stellaops.local/api/v1/anchors/550e8400-e29b-41d4-a716-446655440000/keys/key-2024-prod/revoke \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"reason": "annual-rotation", "effectiveAt": "2025-02-01T00:00:00Z"}'
# Response 200 OK:
# { "keyId": "key-2024-prod", "anchorId": "550e8400-...",
# "revokedAt": "2025-02-01T00:00:00Z", "reason": "annual-rotation",
# "allowedKeyIds": ["key-2025-prod"], "revokedKeyIds": ["key-2024-prod"],
# "auditLogId": "..." }
The request body is RevokeKeyRequestDto — reason is required; effectiveAt defaults to “now” (server clock) when omitted. Revoking moves the key out of allowedKeyIds and into revokedKeyIds, and stamps revoked_at / revoke_reason on the signer.key_history row. Re-revoking an already-revoked key returns 400.
The
stellaops anchor revoke-keyverb exists only as a non-functional stub (AnchorCommandGroup.RevokeKeyAsyncis a// TODOthat prints “(Revocation pending implementation)”) and theanchorgroup is not registered in the CLI — so it is not usable. Use the HTTP API.
Important: The old key remains valid for verifying proofs signed before the revocation effectiveAt (see Verification with Key History). This temporal rule is enforced by KeyRotationService.CheckKeyValidityAsync.
Step 5: Publish Key Material
NOT IMPLEMENTED —
stellaops feed publish --include-keysandstellaops rekor syncare not CLI commands in this codebase. Trust-anchor key material is read directly from the Signer service (/api/v1/anchors/.../keys/history). For TUF/Rekor public-key distribution use the shell scripts described in TUF-Based Key Rotation below (devops/scripts/rotate-rekor-key.sh), which publish via the TUF repository’s ownsign-metadata/publishsteps.
Trust Anchor Management
Trust Anchor Structure
A trust anchor is the signer.trust_anchors row (TrustAnchorEntity). The TrustAnchorInfo projection returned by TrustAnchorManager joins in the key history from the separate signer.key_history table. Field names below match the source model:
{
"anchorId": "550e8400-e29b-41d4-a716-446655440000",
"purlPattern": "pkg:npm/*",
"allowedKeyIds": ["key-2024-prod", "key-2025-prod"],
"allowedPredicateTypes": [
"evidence.stella/v1",
"reasoning.stella/v1",
"cdx-vex.stella/v1",
"proofspine.stella/v1"
],
"policyRef": null,
"policyVersion": "v2.3.1",
"revokedKeyIds": ["key-2023-prod"],
"isActive": true,
"createdAt": "2023-01-15T00:00:00Z",
"updatedAt": "2025-02-01T00:00:00Z",
"keyHistory": [
{
"keyId": "key-2023-prod",
"algorithm": "ED25519",
"addedAt": "2023-01-15T00:00:00Z",
"revokedAt": "2024-01-15T00:00:00Z",
"revokeReason": "annual-rotation",
"expiresAt": null
}
]
}
Notes vs. earlier drafts of this runbook: the primary key field is anchorId (not trustAnchorId), key collections are allowedKeyIds / revokedKeyIds (camelCase, not allowedKeyids / revokedKeys), and keyHistory is materialised from a join — it is not an embedded column on the anchor row.
Create / List Trust Anchors
NOT IMPLEMENTED as an HTTP endpoint; only a non-functional CLI stub exists.
ITrustAnchorManagerexposesGetAnchorAsync,CreateAnchorAsync,UpdateAnchorAsync,DeactivateAnchorAsync,GetActiveAnchorsAsync, andFindAnchorForPurlAsyncas an internal service API (TrustAnchorManager.cs), but the Signer WebService only maps the per-anchor key routes (KeyRotationEndpoints→/api/v1/anchors/{anchorId}/keys/...). There is noPOST /api/v1/anchorsand noGET /api/v1/anchorslisting. Astellaops anchor create/stellaops anchor listCLI verb is scaffolded inAnchorCommandGroupbut each handler is a// TODOstub (prints “(Creation pending implementation)” / “(No anchors found - implementation pending)”) and the group is not registered inCommandFactory, so it is unreachable and non-functional. Anchors are created programmatically (or seeded) viaITrustAnchorManager. Anchor creation validates the PURL pattern withPurlPatternMatcher.IsValidPattern(must start withpkg:).
PURL Pattern Matching
Trust anchors use PURL glob patterns for scope, matched by PurlPatternMatcher (TrustAnchorManager.cs). * becomes .* and ? becomes . in the compiled regex; matching is case-insensitive and anchored end-to-end. When several anchors match a PURL, FindAnchorForPurlAsync picks the most specific by GetSpecificity (more path segments win, wildcards reduce the score), breaking ties toward the pattern with more literal characters.
| Pattern | Matches |
|---|---|
pkg:npm/* | All npm packages |
pkg:maven/org.apache.* | Apache Maven packages |
pkg:docker/myregistry/* | All images from myregistry |
pkg:* | Universal (all pkg: PURLs) |
Note: a bare
*is not a valid stored pattern —IsValidPatternrequires thepkg:prefix. Usepkg:*for a universal anchor.
Verification with Key History
When verifying a proof signed at time T, KeyRotationService.CheckKeyValidityAsync applies the following rule for a given anchor + key:
- Locate the key in
signer.key_history(unknown key →KeyStatus.Unknown,404). - If
T < addedAt→ invalid,KeyStatus.NotYetValid. - If the key was revoked and
T >= revokedAt→ invalid,KeyStatus.Revoked. - If the key had an expiry and
T >= expiresAt→ invalid,KeyStatus.Expired. - Otherwise valid. (A revoked key still reports
IsValid = truewithKeyStatus.Revokedfor timestamps before its revocation — historical proofs stay verifiable.)
TrustAnchorManager.VerifySignatureAuthorizationAsync layers an optional predicate-type check on top of this: if the anchor restricts allowedPredicateTypes, the signing predicate must be in that list.
Temporal Verification
# Check key validity at a specific time
# GET /api/v1/anchors/{anchorId}/keys/{keyId}/validity?signedAt=<ISO-8601>
curl "https://signer.stellaops.local/api/v1/anchors/550e8400-e29b-41d4-a716-446655440000/keys/key-2024-prod/validity?signedAt=2024-06-15T12:00:00Z" \
-H "Authorization: Bearer $TOKEN"
# Response 200 OK (KeyValidityResponseDto):
# { "keyId": "key-2024-prod", "anchorId": "550e8400-...",
# "checkedAt": "2024-06-15T12:00:00Z", "isValid": true,
# "status": "Active", "addedAt": "...", "revokedAt": null, "invalidReason": null }
When signedAt is omitted the server uses the current time. status is one of Active, Revoked, Expired, NotYetValid, Unknown.
NOT IMPLEMENTED — there is no
stellaops verify --at-timeorstellaops key check-validityCLI command. Use the/validityHTTP endpoint above. (Proof-bundle verification is performed by the Attestor verification pipeline, not by astellaops verifykey-validity CLI.)
Emergency Key Revocation
In case of key compromise:
Immediate Actions
The revoke/add API is per anchor + per key. There is no
--anchor-id ALLfan-out, no--urgentflag, no literalstellaops key generateverb (the keypair-generation verb isstellaops key create, see Step 1), and nostellaops feed publishCLI. To revoke a compromised key across multiple anchors, enumerate the affected anchor IDs and issue one revoke call per anchor.
Revoke the compromised key on every affected anchor. Omit
effectiveAtto revoke as of “now” — note this means proofs created up to the revocation instant still verify; if the key should be distrusted retroactively, document that out-of-band (the model does not support a “void all past signatures” flag).for ANCHOR in $AFFECTED_ANCHOR_IDS; do curl -X POST "https://signer.stellaops.local/api/v1/anchors/$ANCHOR/keys/compromised-key-id/revoke" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"reason": "compromise"}' doneGenerate a new keypair out-of-band (
stellaops key create/ HSM /stellaops kms/openssl) — see Step 1 of the workflow above. There is no in-service key-generation endpoint.Add the new public key to every affected anchor.
for ANCHOR in $AFFECTED_ANCHOR_IDS; do curl -X POST "https://signer.stellaops.local/api/v1/anchors/$ANCHOR/keys" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d "{\"keyId\": \"emergency-key-$(date +%Y%m%d)\", \"publicKey\": \"<pem>\", \"algorithm\": \"ED25519\"}" doneDistribute updated public-key material via your TUF/Rekor repository (
devops/scripts/rotate-rekor-key.sh) — see TUF-Based Key Rotation.
Post-Incident Actions
- Review all proofs signed with compromised key
- Determine if any tampering occurred
- Re-sign critical proofs with new key if needed
- File incident report
Rotation Warnings
KeyRotationService.GetRotationWarningsAsync derives warnings for each active (non-revoked) key on an anchor, governed by KeyRotationOptions (src/Attestor/__Libraries/StellaOps.Signer.KeyManagement/KeyRotationService.cs). Defaults and warning types:
| Option | Default | Behaviour |
|---|---|---|
ExpiryWarningDays | 60 | Emits ExpiryApproaching when a key’s expiresAt is within this many days (or already past). |
MaxKeyAgeDays | 365 | Emits LongLived when now - addedAt exceeds this many days. |
DeprecatedAlgorithms | ["RSA-2048", "SHA1-RSA"] | Emits AlgorithmDeprecating for keys using a listed algorithm. |
(RotationWarningType.HighUsage is defined in the enum but no usage-count warning is currently produced.)
NOT IMPLEMENTED — the
signer.rotationYAML block (warningMonths,alertswith Slack/email sinks).KeyRotationServicedoes not push alerts to channels; it returns warnings on request. These options are bound from configuration asKeyRotationOptions(days, not months) and there is no built-in Slack/email alerting in this module. Wire alerting through the Notify module if required.
Check Rotation Warnings
# GET /api/v1/anchors/{anchorId}/keys/warnings
curl https://signer.stellaops.local/api/v1/anchors/550e8400-e29b-41d4-a716-446655440000/keys/warnings \
-H "Authorization: Bearer $TOKEN"
# Response 200 OK (RotationWarningsResponseDto):
# { "anchorId": "550e8400-...",
# "warnings": [
# { "keyId": "key-2024-prod", "warningType": "LongLived",
# "message": "Key key-2024-prod has been active for 400 days. Consider rotation.",
# "criticalAt": "..." } ] }
There is no
stellaops key rotation-warningsCLI command (NOT IMPLEMENTED) — use the/keys/warningsendpoint.
Audit Trail
All key operations are logged to signer.key_audit_log (KeyAuditLogEntity). Mutating endpoints also emit a platform audit event via .Audited(AuditModules.Attestor, …) in KeyRotationEndpoints. The signer.key_audit_log columns are:
| Column | Description |
|---|---|
log_id | Primary key (UUID) for the audit entry |
anchor_id | Affected trust anchor (required) |
key_id | Affected key identifier (nullable) |
operation | Operation type — one of add, revoke, rotate, update, verify (KeyOperation constants) |
actor | User/service that performed the action (defaults to KeyRotationOptions.DefaultActor, i.e. system) |
reason | Free-text reason (e.g. the revocation reason) |
old_state / new_state | JSONB before/after snapshots (nullable) |
details | JSONB additional context (nullable) |
metadata | JSONB additional metadata (nullable; added in migration 004) |
ip_address / user_agent | Request origin (nullable) |
created_at | UTC timestamp (NOW() default) |
Field-name correction vs. earlier drafts: the PK is
log_id(notevent_id), the operation column isoperationwith lowercase verb values (notevent_typewithKEY_*values), and the time column iscreated_at(nottimestamp).
Query Audit Log
-- All key operations in 2025, most recent first
SELECT * FROM signer.key_audit_log
WHERE operation IN ('add', 'revoke', 'rotate', 'update', 'verify')
AND created_at >= '2025-01-01'
ORDER BY created_at DESC;
NOT IMPLEMENTED — there is no
stellaops audit queryCLI command for the Signer key audit log. Querysigner.key_audit_logdirectly (above), or use the platform audit trail emitted viaAuditModules.Attestor(actionsAddKey/RevokeKey).
Database Schema
The authoritative DDL is the embedded migration set in src/Attestor/__Libraries/StellaOps.Signer.KeyManagement/Migrations/ (001_initial_schema.sql for the key tables, 003_trust_anchor_runtime_state.sql for trust_anchors + the FKs). It is applied on startup via AddStartupMigrations (module Signer.KeyManagement) — there is no manual init step. The blocks below mirror that source.
trust_anchors Table
-- 003_trust_anchor_runtime_state.sql
CREATE TABLE IF NOT EXISTS signer.trust_anchors (
anchor_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
purl_pattern TEXT NOT NULL,
allowed_key_ids TEXT[] NULL,
allowed_predicate_types TEXT[] NULL,
policy_ref TEXT NULL,
policy_version TEXT NULL,
revoked_key_ids TEXT[] NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_trust_anchors_purl_pattern ON signer.trust_anchors(purl_pattern);
CREATE INDEX IF NOT EXISTS idx_trust_anchors_is_active ON signer.trust_anchors(is_active);
key_history Table
-- 001_initial_schema.sql
CREATE TABLE IF NOT EXISTS signer.key_history (
history_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
anchor_id UUID NOT NULL,
key_id TEXT NOT NULL,
public_key TEXT NOT NULL,
algorithm TEXT NOT NULL,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ,
revoke_reason TEXT,
expires_at TIMESTAMPTZ,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_key_history_anchor_key UNIQUE (anchor_id, key_id)
);
CREATE INDEX IF NOT EXISTS idx_key_history_anchor ON signer.key_history(anchor_id);
CREATE INDEX IF NOT EXISTS idx_key_history_key_id ON signer.key_history(key_id);
CREATE INDEX IF NOT EXISTS idx_key_history_added ON signer.key_history(added_at);
CREATE INDEX IF NOT EXISTS idx_key_history_revoked ON signer.key_history(revoked_at) WHERE revoked_at IS NOT NULL;
-- 003 adds: FK anchor_id -> signer.trust_anchors(anchor_id) ON DELETE CASCADE
Correction vs. earlier drafts: the PK is
history_id(notid), the FK targetssigner.trust_anchors(anchor_id)(not(id), and added in migration 003 — not 001), and the table carriesexpires_atandcreated_atcolumns.
key_audit_log Table
-- 001_initial_schema.sql
CREATE TABLE IF NOT EXISTS signer.key_audit_log (
log_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
anchor_id UUID NOT NULL,
key_id TEXT,
operation TEXT NOT NULL,
actor TEXT,
old_state JSONB,
new_state JSONB,
details JSONB,
ip_address TEXT,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_key_audit_anchor ON signer.key_audit_log(anchor_id);
CREATE INDEX IF NOT EXISTS idx_key_audit_key ON signer.key_audit_log(key_id) WHERE key_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_key_audit_operation ON signer.key_audit_log(operation);
CREATE INDEX IF NOT EXISTS idx_key_audit_created ON signer.key_audit_log(created_at DESC);
-- 003 adds: FK anchor_id -> signer.trust_anchors(anchor_id) ON DELETE CASCADE
-- 004_key_audit_log_shape_fix.sql adds, to align with KeyAuditLogEntity:
ALTER TABLE IF EXISTS signer.key_audit_log ADD COLUMN IF NOT EXISTS reason TEXT NULL;
ALTER TABLE IF EXISTS signer.key_audit_log ADD COLUMN IF NOT EXISTS metadata JSONB NULL;
The
reasonandmetadatacolumns are introduced by migration004_key_audit_log_shape_fix.sql(the001DDL predates them);KeyRotationServicepopulatesreasonon add/revoke. A fresh database converges to the full shape because all four migrations apply on startup.
Metrics
NOT IMPLEMENTED — the
signer_*key-rotation Prometheus metrics below do not exist in source. A grep acrosssrc/finds nosigner_key_age_days,signer_keys_active_total,signer_keys_revoked_total,signer_rotation_events_total, orsigner_verification_key_lookups_totalemitter. Today the equivalent signal is obtained on demand from the/api/v1/anchors/{anchorId}/keys/warningsendpoint (ExpiryApproaching/LongLived/AlgorithmDeprecating) and fromsigner.key_audit_log. The roadmap proposal below is retained for planning; do not build alerting on these metric names until they are instrumented.
The following metrics and alerting rules are a proposed Prometheus surface (roadmap, not yet emitted):
| Metric | Type | Description |
|---|---|---|
signer_key_age_days | Gauge | Age of each active key in days |
signer_keys_active_total | Gauge | Number of active keys per anchor |
signer_keys_revoked_total | Counter | Total revoked keys |
signer_rotation_events_total | Counter | Key rotation events |
signer_verification_key_lookups_total | Counter | Temporal key lookups |
Alerting Rules (roadmap — depend on the unimplemented metrics above)
groups:
- name: key-rotation
rules:
- alert: SigningKeyNearExpiry
expr: signer_key_age_days > (365 - 60)
for: 1d
labels:
severity: warning
annotations:
summary: "Signing key approaching rotation deadline"
- alert: SigningKeyExpired
expr: signer_key_age_days > 365
for: 1h
labels:
severity: critical
annotations:
summary: "Signing key exceeded maximum age"
TUF-Based Key Rotation
Sprint: SPRINT_20260125_003 - WORKFLOW-007
For organizations using TUF-based trust distribution, additional key rotation procedures apply to Rekor public keys and TUF metadata signing keys.
Rekor Public Key Rotation
Rekor public keys verify transparency log signatures. Rotation uses a dual-key grace period to ensure all clients sync the new key before removing the old one.
Recommended rotation interval: Annually Grace period: 7-14 days
Phase 1: Add New Key
# Add new Rekor key to TUF repository
./devops/scripts/rotate-rekor-key.sh add-key \
--repo /path/to/tuf \
--new-key rekor-key-v2.pub
# Sign and publish TUF metadata
cd /path/to/tuf
./scripts/sign-metadata.sh
./scripts/publish.sh
Phase 2: Grace Period
During the grace period (7-14 days):
- Monitor client sync logs
- Verify both keys work for verification
- Confirm all clients have updated
# Verify both keys are active in the TUF repo
./devops/scripts/rotate-rekor-key.sh verify --repo /path/to/tuf
# Should show both rekor-key-v1 and rekor-key-v2
Correction (reconciled against source):
stella trust status --show-keysis implemented as a handler (TrustCommandHandlers.HandleStatusAsync, with a real--show-keys/-koption inTrustCommandGroup.cs), but thetrustverb group is not registered inCommandFactory— itsBuildTrustCommandbuilder has no callers — so the command is currently unreachable from the shipped CLI. Note this means theverifyphase shown above is also presently non-functional:rotate-rekor-key.sh verifydoes nothing but callstella trust status --show-keys(rotate-rekor-key.shline 145) and ignores its--repoargument. Until thetrustgroup is wired up, confirm both keys are present during the grace period by inspecting the TUFtargets.jsonin the repo directly (or via your TUF tooling) rather than relying on the CLI.
Phase 3: Remove Old Key
# Remove old key after grace period
./devops/scripts/rotate-rekor-key.sh remove-old \
--repo /path/to/tuf \
--old-key-name rekor-key-v1
# Sign and publish
cd /path/to/tuf
./scripts/sign-metadata.sh
./scripts/publish.sh
TUF Root Key Rotation
TUF root keys are the ultimate trust anchor. Rotation is a high-ceremony operation requiring M-of-N key holders.
Recommended rotation interval: 2-3 years Requires: Key ceremony with multiple signers
See Disaster Recovery for full root key ceremony procedures.
TUF Metadata Signing Key Rotation
For targets, snapshot, and timestamp keys:
# Generate new metadata signing key
openssl ecparam -name prime256v1 -genkey -noout \
-out /secure/targets-key-v2.pem
# Update root.json to include new key
tuf update-root --add-targets-key /secure/targets-key-v2.pem
# Sign with both old and new keys during transition
tuf sign targets --key /secure/targets-key-v1.pem
tuf sign targets --key /secure/targets-key-v2.pem
# After grace period, remove old key from root.json
tuf update-root --remove-targets-key /secure/targets-key-v1.pem
Automated Scripts
Use the provided automation scripts:
| Script | Purpose | Status |
|---|---|---|
devops/scripts/rotate-rekor-key.sh | Rekor public key rotation (phases: add-key, verify, remove-old; --grace-days default 7) | Present |
devops/scripts/rotate-signing-key.sh | Organization signing key rotation (phases: generate, activate, retire; key types ecdsa-p256 / ecdsa-p384 / rsa-4096; --grace-days default 14) | Present |
devops/trust-repo-template/scripts/revoke-target.sh | Remove target from TUF | NOT PRESENT — neither the script nor the devops/trust-repo-template/ directory exists in this repo. Reference is orphaned; remove or implement before relying on it. |
TUF root metadata rotation on the client side is handled automatically by TufClient.CheckRootRotationAsync (src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TufClient.RootRotation.cs), which fetches {N+1}.root.json, verifies it against both the current and the new root keys (threshold-checked), and persists it on success.
Related Documentation
- Incident Response Runbook
- Key Escrow and Recovery Runbook
- HSM Setup Runbook
- Proof Chain API
- Attestor Architecture
- Signer Architecture
- Signer API Reference
- TUF Integration Guide
- Bootstrap Guide
- Disaster Recovery
- NIST SP 800-57 — Key Management Guidelines
Implementing source (ground truth)
- Trust-anchor key endpoints:
src/Attestor/StellaOps.Signer/StellaOps.Signer.WebService/Endpoints/KeyRotationEndpoints.cs - Rotation logic + options:
src/Attestor/__Libraries/StellaOps.Signer.KeyManagement/KeyRotationService.cs - Trust-anchor service + PURL matcher:
src/Attestor/__Libraries/StellaOps.Signer.KeyManagement/TrustAnchorManager.cs - Entities:
src/Attestor/__Libraries/StellaOps.Signer.KeyManagement/Entities/ - Schema migrations:
src/Attestor/__Libraries/StellaOps.Signer.KeyManagement/Migrations/(001,003,004) - Auth policy wiring + scope (
signer:rotate):src/Attestor/StellaOps.Signer/StellaOps.Signer.WebService/Program.cs,.../Security/SignerPolicies.cs - TUF/Rekor rotation scripts:
devops/scripts/rotate-rekor-key.sh,devops/scripts/rotate-signing-key.sh - Scope catalog:
src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs - CLI key generation (
stellaops key create, implemented + wired):src/Cli/StellaOps.Cli/Commands/CommandFactory.cs(BuildKeyCommand), handlerHandleAttestKeyCreateAsyncinsrc/Cli/StellaOps.Cli/Commands/CommandHandlers.cs - CLI
stellaops kms(file-backed key export/import, wired):src/Cli/StellaOps.Cli/Commands/CommandFactory.cs(BuildKmsCommand) - CLI
stellaops trust status(handler implemented,--show-keysoption real, buttrustgroup NOT registered inCommandFactory):src/Cli/StellaOps.Cli/Commands/Trust/TrustCommandGroup.cs,.../Trust/TrustCommandHandlers.cs - CLI
stellaops anchor(scaffoldedlist/show/create/revoke-key— all// TODOstubs, group NOT wired):src/Cli/StellaOps.Cli/Commands/Proof/AnchorCommandGroup.cs - Signer crypto options (no
signer.yaml; dev defaultLocalHmacProvider):src/Attestor/StellaOps.Signer/StellaOps.Signer.Infrastructure/Options/SignerCryptoOptions.cs - Audit actions / module (
AuditModules.Attestor=attestor,AddKey/RevokeKey):src/__Libraries/StellaOps.Audit.Emission/AuditModules.cs,.../AuditActions.cs - Client-side TUF root rotation:
src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TufClient.RootRotation.cs
