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/anchors HTTP API and the devops/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 by KeyRotationService / TrustAnchorManager and the signer.key_history / signer.key_audit_log / signer.trust_anchors tables. The TUF / Rekor key-rotation flows are implemented as the devops/scripts/rotate-rekor-key.sh and devops/scripts/rotate-signing-key.sh shell scripts.

Not implemented / not usable (flagged inline below): a “create anchor” / “list anchors” HTTP endpoint, the signer.yaml profile/rotation config block, the signer_* Prometheus metrics, and the devops/trust-repo-template/ scripts. The stellaops anchor CLI verb group (list/show/create/revoke-key) exists as code (src/Cli/StellaOps.Cli/Commands/Proof/AnchorCommandGroup.cs) but every handler is a // TODO stub that prints “pending implementation,” and the group is not registered in CommandFactory— so it is unreachable from the shipped CLI. Where this runbook shows anchor-management or signer.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 create generates a file-backed ECDSA-P256/P384 signing keypair (new Command("key", "Manage attestation signing keys.") in CommandFactory.cs, handler HandleAttestKeyCreateAsync); stellaops kms manages file-backed keys (export/import); both are registered at the CLI root. A stellaops trust status --show-keys handler is also fully implemented (TrustCommandHandlers.HandleStatusAsync, real --show-keys/-k option) but its trust verb group is not wired into CommandFactory, 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:

Key Principles

  1. Never mutate old DSSE envelopes - Signed content is immutable
  2. Never remove keys from history - Revoke (stamp revoked_at / revoke_reason, move into the anchor’s revokedKeyIds); the signer.key_history row is retained, not deleted
  3. 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/history endpoint
  4. Audit all changes - Every add/revoke writes a signer.key_audit_log row and a platform audit event (AuditModules.Attestor)
  5. 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:

FamilyAlgorithm identifiers (as validated, case-insensitive)
ECDSAES256, ES384, ES512
RSA (PKCS#1 v1.5 / PSS)RS256, RS384, RS512, PS256, PS384, PS512
EdDSAED25519, EdDSA
RegionalSM2, GOST12-256, GOST12-512
Post-quantumDILITHIUM3, 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 a signer.yaml profile/rotation config block. There is no profile registry, no per-profile key-store binding, and no /etc/stellaops/signer.yaml schema in source. The Signer service crypto options live in SignerCryptoOptions (src/Attestor/StellaOps.Signer/StellaOps.Signer.Infrastructure/Options/SignerCryptoOptions.cs) with fields KeyId, 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; KeyRotationEndpoints only 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. The AddKey API 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 via BuildKeyCommand in src/Cli/StellaOps.Cli/Commands/CommandFactory.cs). The stellaops kms command group (export/import of file-backed keys) is also available. openssl or your HSM/PKCS#11 tooling are equally valid. There is no literal stellaops key generate verb — the generation verb is stellaops 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-key CLI command — use the HTTP API. (The AnchorCommandGroup scaffolds list/show/create/revoke-key but not add-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:

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 status command 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 RevokeKeyRequestDtoreason 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-key verb exists only as a non-functional stub (AnchorCommandGroup.RevokeKeyAsync is a // TODO that prints “(Revocation pending implementation)”) and the anchor group 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-keys and stellaops rekor sync are 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 own sign-metadata/publish steps.


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. ITrustAnchorManager exposes GetAnchorAsync, CreateAnchorAsync, UpdateAnchorAsync, DeactivateAnchorAsync, GetActiveAnchorsAsync, and FindAnchorForPurlAsync as 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 no POST /api/v1/anchors and no GET /api/v1/anchors listing. A stellaops anchor create / stellaops anchor list CLI verb is scaffolded in AnchorCommandGroup but each handler is a // TODO stub (prints “(Creation pending implementation)” / “(No anchors found - implementation pending)”) and the group is not registered in CommandFactory, so it is unreachable and non-functional. Anchors are created programmatically (or seeded) via ITrustAnchorManager. Anchor creation validates the PURL pattern with PurlPatternMatcher.IsValidPattern (must start with pkg:).

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.

PatternMatches
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 — IsValidPattern requires the pkg: prefix. Use pkg:* 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:

  1. Locate the key in signer.key_history (unknown key → KeyStatus.Unknown, 404).
  2. If T < addedAt → invalid, KeyStatus.NotYetValid.
  3. If the key was revoked and T >= revokedAt → invalid, KeyStatus.Revoked.
  4. If the key had an expiry and T >= expiresAt → invalid, KeyStatus.Expired.
  5. Otherwise valid. (A revoked key still reports IsValid = true with KeyStatus.Revoked for 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-time or stellaops key check-validity CLI command. Use the /validity HTTP endpoint above. (Proof-bundle verification is performed by the Attestor verification pipeline, not by a stellaops verify key-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 ALL fan-out, no --urgent flag, no literal stellaops key generate verb (the keypair-generation verb is stellaops key create, see Step 1), and no stellaops feed publish CLI. To revoke a compromised key across multiple anchors, enumerate the affected anchor IDs and issue one revoke call per anchor.

  1. Revoke the compromised key on every affected anchor. Omit effectiveAt to 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"}'
    done
    
  2. Generate 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.

  3. 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\"}"
    done
    
  4. Distribute updated public-key material via your TUF/Rekor repository (devops/scripts/rotate-rekor-key.sh) — see TUF-Based Key Rotation.

Post-Incident Actions

  1. Review all proofs signed with compromised key
  2. Determine if any tampering occurred
  3. Re-sign critical proofs with new key if needed
  4. 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:

OptionDefaultBehaviour
ExpiryWarningDays60Emits ExpiryApproaching when a key’s expiresAt is within this many days (or already past).
MaxKeyAgeDays365Emits 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.rotation YAML block (warningMonths, alerts with Slack/email sinks). KeyRotationService does not push alerts to channels; it returns warnings on request. These options are bound from configuration as KeyRotationOptions (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-warnings CLI command (NOT IMPLEMENTED) — use the /keys/warnings endpoint.


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:

ColumnDescription
log_idPrimary key (UUID) for the audit entry
anchor_idAffected trust anchor (required)
key_idAffected key identifier (nullable)
operationOperation type — one of add, revoke, rotate, update, verify (KeyOperation constants)
actorUser/service that performed the action (defaults to KeyRotationOptions.DefaultActor, i.e. system)
reasonFree-text reason (e.g. the revocation reason)
old_state / new_stateJSONB before/after snapshots (nullable)
detailsJSONB additional context (nullable)
metadataJSONB additional metadata (nullable; added in migration 004)
ip_address / user_agentRequest origin (nullable)
created_atUTC timestamp (NOW() default)

Field-name correction vs. earlier drafts: the PK is log_id (not event_id), the operation column is operation with lowercase verb values (not event_type with KEY_* values), and the time column is created_at (not timestamp).

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 query CLI command for the Signer key audit log. Query signer.key_audit_log directly (above), or use the platform audit trail emitted via AuditModules.Attestor (actions AddKey / 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 (not id), the FK targets signer.trust_anchors(anchor_id) (not (id), and added in migration 003 — not 001), and the table carries expires_at and created_at columns.

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 reason and metadata columns are introduced by migration 004_key_audit_log_shape_fix.sql (the 001 DDL predates them); KeyRotationService populates reason on 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 across src/ finds no signer_key_age_days, signer_keys_active_total, signer_keys_revoked_total, signer_rotation_events_total, or signer_verification_key_lookups_total emitter. Today the equivalent signal is obtained on demand from the /api/v1/anchors/{anchorId}/keys/warnings endpoint (ExpiryApproaching / LongLived / AlgorithmDeprecating) and from signer.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):

MetricTypeDescription
signer_key_age_daysGaugeAge of each active key in days
signer_keys_active_totalGaugeNumber of active keys per anchor
signer_keys_revoked_totalCounterTotal revoked keys
signer_rotation_events_totalCounterKey rotation events
signer_verification_key_lookups_totalCounterTemporal 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):

# 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-keys is implemented as a handler (TrustCommandHandlers.HandleStatusAsync, with a real --show-keys/-k option in TrustCommandGroup.cs), but the trust verb group is not registered in CommandFactory— its BuildTrustCommand builder has no callers — so the command is currently unreachable from the shipped CLI. Note this means the verify phase shown above is also presently non-functional: rotate-rekor-key.sh verify does nothing but call stella trust status --show-keys (rotate-rekor-key.sh line 145) and ignores its --repo argument. Until the trust group is wired up, confirm both keys are present during the grace period by inspecting the TUF targets.json in 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:

ScriptPurposeStatus
devops/scripts/rotate-rekor-key.shRekor public key rotation (phases: add-key, verify, remove-old; --grace-days default 7)Present
devops/scripts/rotate-signing-key.shOrganization 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.shRemove target from TUFNOT 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.


Implementing source (ground truth)