S2S Signing-Key Lifecycle — Operator Runbook (OPS-03)
This is the runbook for provisioning, rotating, and revoking the HMAC signing keys
that service-to-service (S2S) clients use with the SH-HMAC-SHA256 scheme
(plan/security/07-request-signing-hmac.md, canonicalization spec
plan/security/07a-canonicalization-spec.md).
It is backed by:
- Library —
@smithy-hono/key-tool: backend-agnostic lifecycle operations (provisionClient,rotateClient,revokePreviousKey,revokeClient) plusgenerateHmacSecret/mintKeyId. The library is web-standard and portable; it drives a structuralWritableKeyBackend. - CLI —
key-tool(node-only), a thin wrapper that wires the Node Redis backend. - Per-adapter write backends:
@smithy-hono/adapter-node→RedisKeyBackend— full end-to-end (material + client→keyId directory both in Redis). This is the adapter with a live conformance test, so this runbook is verifiable against it.@smithy-hono/adapter-aws→AwsKeyBackend— full (material in Secrets Manager via a writable structural port; directory via a structural port, e.g. DynamoDB).@smithy-hono/adapter-cf→CfKeyBackend— directory plane only is writable in Workers KV; key material is provisioned out-of-band because Workers secrets are read-only at request time (see "Cloudflare" below).
The key model (read this first)
A client signs every request with one keyId, carried in the Authorization
header. The server's verifySignature resolves the key purely by that keyId:
secrets.getSigningKey(parsed.keyId) — any keyId whose material is still present
verifies, independent of which key is "current"
(packages/security-core/src/signing/verifySignature.ts, step 6 "SIGN-05 key
resolution"). The client→keyId directory only decides which keyId NEW signatures
should use (getCurrentKeyId).
That is what makes a zero-downtime rotation possible:
provision directory: { current: K1 } material: { K1 }
rotate directory: { current: K2, previous: K1 } material: { K1, K2 } ← overlap window
revoke-prev directory: { current: K2 } material: { K2 }
During the overlap window both K1 and K2 are present, so a request already in
flight (signed with K1) still verifies, while new requests use K2. The window is
closed explicitly by revoke-previous, which deletes K1's material.
Overlap duration: keep the previous key alive for at least the signing
acceptance window (config.signing.acceptanceWindowSeconds, default 300 s) —
a request older than that is already rejected on the timestamp check, so once that
window has fully elapsed since you moved the pointer, no valid in-flight request
can still be using K1. A few minutes of margin is prudent.
Onboarding a new client (provision)
export REDIS_URL=redis://localhost:6379
npx key-tool provision client-a
Output (printed once — the material is never recoverable from the backend):
{
"action": "provision",
"clientId": "client-a",
"keyId": "client-a.3f9c1a2b4d5e",
"material": "<base64 HMAC secret>"
}
- Distribute
keyId+materialto the client over a secure channel (the client imports the base64 material as an HMAC-SHA-256 signing key and setskeyIdin itsAuthorizationheader). - The server side needs the new keyId reflected in its
currentKeyByClientmap (NodeNodeSecretProvider) — withRedisKeyBackendthe directory entry written here is the source of truth; point the provider'scurrentKeyByClientat the directory'scurrentfor each client, or rebuild it from the directory on deploy.
Library equivalent:
import { RedisKeyBackend } from '@smithy-hono/adapter-node'
import { provisionClient } from '@smithy-hono/key-tool'
const backend = new RedisKeyBackend(createRedisPort(redis))
const { keyId, material } = await provisionClient(backend, { clientId: 'client-a' })
Rotating a client's key (with overlap)
npx key-tool rotate client-a
{
"action": "rotate",
"clientId": "client-a",
"newKeyId": "client-a.aa11bb22cc33",
"previousKeyId": "client-a.3f9c1a2b4d5e",
"material": "<new base64 secret>"
}
What happens, in order (rotateClient):
- The new key's material is written first (so it verifies the instant the pointer moves).
- The directory pointer moves:
current = newKeyId,previous = oldKeyId. The old material is left in place — this is the overlap window. - A
key.rotateaudit event is emitted through the injectedAuditSink(buildAuditEvent({ type: 'key.rotate', ... })+emitAudit(...)), withdetail: { action: 'rotate', clientId, newKeyId, previousKeyId }. The CLI wires the Node stdout audit sink, so the rotation appears as akind: 'audit'JSON line your log shipper already collects.
Rollout:
- Run
rotate, distribute the newkeyId+materialto the client. - The client switches to the new key. In-flight requests still using the old key keep verifying (overlap).
- After ≥ the acceptance window (default 300 s) + margin, close the overlap:
npx key-tool revoke-previous client-a
This deletes the previous key's material and clears previous. From then on the
old keyId resolves to null → uniform 401.
Revoking a client (offboarding / compromise)
To reject a client immediately and entirely:
npx key-tool revoke client-a
revokeClient deletes both the current and previous key material (so every
signature from the client is rejected at once) and tombstones the directory entry,
then emits a key.rotate event with detail.action = 'revoke'. There is no
overlap here — this is intentional for compromise response.
For a planned offboarding where in-flight requests should still drain, prefer
rotate then revoke-previous after the window, and stop issuing new material.
Cloudflare specifics
On Workers, key material is a Workers secret and is read-only at request
time. So CfKeyBackend:
-
directory plane (Workers KV) —
getDirectoryEntry/putDirectoryEntrywork in-band; the lifecycle library can move the current→previous pointer at runtime. -
material plane (out-of-band) —
putKeyMaterial/deleteKeyMaterialthrow with an instruction. Publish/rotate/revoke material via the control plane:# provision / rotate materialwrangler secret put SIGNING_KEY_<keyId> # paste the base64 material# revoke materialwrangler secret delete SIGNING_KEY_<keyId>(or the equivalent Cloudflare API call). Then move the KV directory pointer with the library. Order: publish new material out-of-band first, then rotate the pointer, then (after the window) delete the old secret.
AWS specifics
AwsKeyBackend is full lifecycle: material lives in Secrets Manager (writable via
the structural WritableSecretsSourceLike — the consumer wires
PutSecretValue/CreateSecret/DeleteSecret), and the directory via a structural
KeyDirectoryPortLike (e.g. one DynamoDB item per client). The same
provisionClient / rotateClient / revokePreviousKey / revokeClient calls
apply.
Verifying against live conformance
The Node path is verifiable against a real Redis:
docker run --rm -d -p 6379:6379 redis:7-alpine
export REDIS_URL=redis://localhost:6379
# CLI round-trip
npx key-tool provision client-a
npx key-tool rotate client-a # emits a key.rotate audit line to stdout
npx key-tool current client-a # { clientId, entry: { current: K2, previous: K1 } }
npx key-tool revoke-previous client-a # entry: { current: K2 } (previous cleared)
Automated coverage (no Docker needed — in-process fake Redis port):
packages/key-tool/src/lifecycle.test.ts— provision/rotate/revoke + thekey.rotateemission + the overlap-window invariant (a signature made with the previous key still verifies after rotation, fails afterrevoke-previous).packages/key-tool/src/redisBackend.test.ts— the same flow end-to-end through the realRedisKeyBackendand a liveNodeSecretProviderreading the same Redis material.
Run: npm -w @smithy-hono/key-tool run test:ci.