This document describes the secret management and rotation policies for the Credence economic trust protocol backend.
This document is written for Contributors (developers, security reviewers, and maintainers) of the Credence Backend. It outlines how credentials, signing keys, and encryption secrets are stored, how they are rotated programmatically or operationally, and the security boundaries (blast radius) of each secret type.
The platform manages four main types of credentials and secrets:
- Evidence Key Encryption Keys (KEK) — AES-256 keys protecting dispute and slashing evidence.
- JWT Signing Keys — RSA key pairs used to issue and verify user authentication tokens.
- Integration API Keys — Hashed credentials issued to users/organizations to query public and admin APIs.
- Webhook Signing Secrets — Secrets used to sign outbound payloads sent to event subscribers.
All sensitive dispute/slashing evidence submitted by users is encrypted at rest using envelope encryption:
- Each evidence record is encrypted with a unique, randomly generated 32-byte Data Encryption Key (DEK) via AES-256-GCM.
- The DEK is then encrypted ("wrapped") using the active Key Encryption Key (KEK) and stored alongside the record.
- In-memory key versioning is handled by KekManager.
- Local Dev / Testing: Defined via the
EVIDENCE_ENCRYPTION_KEYenvironment variable in.env. - Production: Injected dynamically into the container environment or operations tooling from a secure secrets vault (e.g., HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager) under versioned identifiers (e.g.,
EVIDENCE_ENCRYPTION_KEY_V1,EVIDENCE_ENCRYPTION_KEY_V2).
- Manual / On-Demand: Triggered manually by the Platform Security / Operations team periodically (e.g., annually) or immediately upon suspected credential exposure.
- Exposed KEK: If a specific KEK version is compromised, only evidence records encrypted under that version's identifier are exposed. Other versions remain secure.
- Migration Failure / Early Zeroization: If a retired KEK is zeroized before historical records have been re-encrypted to the new version, those older evidence records become permanently unreadable.
- Mitigation: The re-encryption worker performs migration online and is idempotent. Always ensure re-encryption completes and backups are verified before zeroizing old keys in the vault.
KEK rotation uses a programmatically enforced dual-control (two-approver) process via scripts/rotate-kms-key.ts:
-
Register a new KEK version:
EVIDENCE_ENCRYPTION_KEY="current-32-byte-active-key-here" \ npx tsx scripts/rotate-kms-key.ts registerOutput: Generates a new KEK, prints its hex material, and returns a new version number (e.g.,
v2). -
Dual-Control Approval (Two different operators must run this):
# Approver 1 EVIDENCE_ENCRYPTION_KEY="current-32-byte-active-key-here" \ npx tsx scripts/rotate-kms-key.ts approve --version 2 --approver alice@example.com # Approver 2 EVIDENCE_ENCRYPTION_KEY="current-32-byte-active-key-here" \ npx tsx scripts/rotate-kms-key.ts approve --version 2 --approver bob@example.com
-
Activate the new KEK (Retires the old KEK, uses new KEK for new uploads):
EVIDENCE_ENCRYPTION_KEY="current-32-byte-active-key-here" \ npx tsx scripts/rotate-kms-key.ts activate --version 2 -
Re-encrypt existing records from version 1 to version 2:
# Dry-run first to preview changes EVIDENCE_ENCRYPTION_KEY="current-32-byte-active-key-here" \ npx tsx scripts/rotate-kms-key.ts rotate --dry-run # Perform batch re-encryption (updates DEKs to be wrapped under KEK v2) EVIDENCE_ENCRYPTION_KEY="current-32-byte-active-key-here" \ npx tsx scripts/rotate-kms-key.ts rotate --batch-size 100
-
Verify status & Zeroize retired key material: Once re-encryption is completed, the CLI automatically zeroizes retired KEK material from memory. Ensure the old key is deleted from the secrets vault.
EVIDENCE_ENCRYPTION_KEY="new-v2-32-byte-active-key-here" \ npx tsx scripts/rotate-kms-key.ts status
See docs/kms-rotation-runbook.md for step-by-step operational details.
Used to sign user authentication tokens (using the PS256 algorithm). In-memory management is handled by KeyManager.
- Startup Config: Can be pre-loaded as a stable PKCS#8 PEM-encoded RSA private key via
KEY_PRIVATE_PEM(with optionalKEY_INITIAL_KID). - Dynamic Generation: If
KEY_PRIVATE_PEMis not set, a new RSA key pair is generated dynamically in-memory on server startup. - JWK Set Endpoint: Public keys are served at
/.well-known/jwks.jsonfor validation by downstream verification clients.
- Automated: The background task rotates keys in-memory every
KEY_ROTATION_INTERVAL_SECONDS(default:86400seconds / 24 hours).
- Key Compromise: If a JWT signing key is compromised, an attacker can forge JWTs.
- Grace Period / User Disruption: When keys rotate, the old key remains in a
retiredstate and continues verifying existing tokens forKEY_GRACE_PERIOD_SECONDS(default:3600seconds / 1 hour) plusKEY_CLOCK_SKEW_SECONDS(default:300seconds / 5 minutes). Once this window expires, the key is hard-pruned, and any user whose token was signed by the pruned key is logged out. - Mitigation: The 24-hour rotation limit restricts the window of usability for compromised keys. The 1-hour grace period ensures active users do not experience session termination during key rollover.
# Cadence to rotate the active signing key (24h)
KEY_ROTATION_INTERVAL_SECONDS=86400
# How long a retired key can still verify existing JWTs (1h)
KEY_GRACE_PERIOD_SECONDS=3600
# Clock skew tolerance added to the grace window (5 min)
KEY_CLOCK_SKEW_SECONDS=300
# Optional: Initial PKCS8 private key PEM to keep keys stable across restarts
# KEY_PRIVATE_PEM="-----BEGIN PRIVATE KEY-----\nMII...\n-----END PRIVATE KEY-----"
# KEY_INITIAL_KID="my-key-v1"Issued to users and organizations to query public data and perform admin actions. Handled by ApiKeyRotationService.
- Hashed (SHA-256) inside the database. The raw key is only shown once to the user upon creation.
- A prefix (e.g.,
cre_) is kept plain-text in the database for identification and lookup.
- Manual / On-Demand: Triggered by the key owner or an administrator via the management API.
- Key Compromise: An attacker gains access to endpoints within the key's assigned scope.
- Safe Invalidation (No Grace Period): Rotation is immediate. The old key is revoked instantly and cannot be used.
- Mitigation: Invalidation is atomic. The new key is returned immediately. Downstream integrations must be updated to use the new key without delay to minimize downtime.
API keys are rotated by issuing a POST request to the rotation endpoint:
POST /api/integrations/keys/key_01h7x2z3a4b5c6d7e8f9g0h1j2/rotate
Authorization: Bearer <user-jwt-token>
Content-Type: application/jsonResponse (200 OK):
{
"success": true,
"message": "API key rotated. Store the new key securely — it will not be shown again.",
"data": {
"id": "key_01h7x2z3a4b5c6d7e8f9g0h1j2",
"key": "cre_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"prefix": "cre_live_",
"scope": "read",
"tier": "pro",
"active": true
}
}Used to sign payloads sent to external subscribers via X-Webhook-Signature HMAC headers so receiving servers can verify the authenticity of the webhook event. Handled by WebhookRotationService. For full operator procedures, see Webhook Signature Operator Guide.
- Stored plain-text (or encrypted) in the database in the webhooks subscription table.
- Manual / On-Demand: Triggered by subscribers or administrators when rotating webhook configurations.
- Secret Compromise: An attacker can forge webhook events to the subscriber's endpoint.
- Verification Grace Period: To prevent delivery disruption, the old secret is kept as
previousSecretand remains valid for signing/verification for 24 hours (PREVIOUS_SECRET_TTL_MS). - Mitigation: During the 24-hour grace period, webhook dispatches are signed using the new secret, but the system logs metadata allowing validation tools to transition gracefully. Subscribers must update their signature verification code within 24 hours.
Webhook secrets are rotated by administrators or owners via the rotation endpoint:
POST /api/webhooks/wh_9876543210abcdef/rotate-secret
Authorization: Bearer <user-jwt-token>
Content-Type: application/jsonResponse (200 OK):
{
"webhookId": "wh_9876543210abcdef",
"newSecret": "d9f8e7d6c5b4a392817263544536271809a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4",
"rotatedAt": "2026-07-24T00:30:00.000Z",
"previousSecretExpiresAt": "2026-07-25T00:30:00.000Z"
}