Documentation

Signing keys

Server-side signing keys (Ed25519 or ECDSA P-256/ES256): the private key is generated inside the store, wrapped under the environment KEK, and never returned over the API. Callers send a message and get a signature; verification is public-key-only and needs no store.

A signing oracle, not another secret

A secret is a value the caller fetches. A signing key is a value the caller must neverfetch — token and JWT issuance, artifact and release signing, mTLS client identity all want asigning oracle: send a message, get a signature, and no code path can return the private key. The store ships this as a second resource, keys, besidesecrets — storage and usage stay separate, and get/put are never overloaded.

The key is born inside the store: generated in the Worker isolate, wrapped under the per-environment KEK with its own encryption-context kind, and persisted only as ct || tag. At sign time it is rehydrated as a Web Crypto handle with extractable: false and usages['sign'] — the platform, not review vigilance, guarantees that no later code path or bug can serialize it back out. Only the signature and the public key ever cross the API boundary. Verification runs anywhere with the SDK's public-key-only verify — no store, no KEK, no network.

Two algorithms in a closed alg vocabulary: Ed25519 (the default — a real managed-KMS gap, since AWS KMS cannot sign it) and ES256 (ECDSA P-256 + SHA-256 — the wire KMS-class signers standardize on). Web Crypto emits ECDSA signatures as rawr‖s natively, so unlike the KMS path there is never a DER conversion. Workers and Node 20+ ship both in baseline Web Crypto.

Algorithms and wire encodings

Store alg values match Web Crypto key names; because downstream token wires speak JOSE, every response that carries alg also carries jose — an explicit 1:1 mapping, so no adapter maintains a translation table. An alg outside the vocabulary is rejected at the door; a future algorithm is a new alg value, never a version-magnitude branch.

algjosePublic keySignature
Ed25519EdDSAraw, 32 bytes64 bytes
ES256ES256SEC1 uncompressed point, 65 bytes (04‖X‖Y)raw r‖s (IEEE P1363), 64 bytes — never DER

Routes

Same middleware as secrets — bearer auth, immutable-ID scope resolution, audit-before-return. The :sign/:rotate action suffixes follow the existing…:export convention.

Method + pathDoesCapability
POST /v1/keys/{p}/{e}/{t}/{name}Body { alg?, published? } (Ed25519 default, or ES256; publishing defaults false). Generate a keypair server-side; wrap the private key under the environment KEK; return only the public key + metadata. An existing name is a 409 — creation is explicit, never an upsert.keys:create
GET /v1/keys/{p}/{e}/{t}/{name}The public key plus { alg, keyVer, status } and all non-retired prior versions — the set verifiers accept during a rotation grace window.keys:read
POST /v1/keys/…/{name}:signBody { message } (base64 bytes) → { signature, keyId, keyVer, publicKey, alg }. The private key is never serialized out.keys:sign
POST /v1/keys/…/{name}:rotateMint keyVer + 1 as the active signing version; retain the prior version as retiring for verification. Synchronous, O(1) — nothing was encrypted under the old key, so nothing re-encrypts.keys:rotate
GET /v1/keys/…/{name}/publicPublished keys only: the current + non-retired versions with the public key in base64 and bare hex — the set verifiers accept during a rotation grace window.(none — unauthenticated)
GET /v1/keys/…/{name}/public/jwksThe same published set as RFC 7517 JWKS (OKP/Ed25519 or EC/P-256) for JOSE-ecosystem verifiers.(none — unauthenticated)

Signing takes the full message for both algorithms — PureEdDSA signs the message itself, and for ES256 the SHA-256 happens inside crypto.subtle.sign. A caller-supplied digest would bypass the audit's hash commitment, so it is deliberately not accepted.

Published keys — opt-in unauthenticated distribution

Verifiers pin public keys out-of-band — config values, sidecar files, gateway environments. That material is public by definition, but serving it behind keys:read drags token minting and rotation into systems that should never hold store credentials. So distribution is aper-key opt-in: create or rotate a key with { published: true }and the …/public routes serve its current + non-retired public keys to anyone — no token, base64 and bare hex side by side, plus a JWKS variant. Omit the flag and nothing changes.

The existence-oracle policy survives the exception: unpublished keys stay behindkeys:read with the usual 403/404 semantics, and the public route answers one uniform 404 for unknown and existing-but-unpublished names alike — publishing is the only thing that makes a key name observable. Unauthenticated fetches append nothing to the hash-chained audit log (rows with no actor would be attacker-writable spam); the publish/unpublish transition itself is recorded in key.create/key.rotate meta.

Key lifecycle — the property everything rests on

  1. Generate + wrap is one operation. The keypair is generatedextractable: true solely so the private key can be exported to PKCS#8 insidethe SDK call, immediately AES-GCM-wrapped under the KEK, and the transient bytes zeroed. A plaintext private key is never an SDK input or output — there is deliberately no import API.
  2. At sign time, the store decrypts the PKCS#8 bytes, imports themextractable: false with usages ['sign'], zeroes the buffer, signs, and lets the handle fall out of scope. exportKey on the rehydrated handle throws by construction.
  3. Only the signature and the public key ever cross the API boundary.

The wrap uses a third encryption-context kind, KEYWRAP (0x03), binding{ tenantId, keyId, keyVer, kekKv } — so a wrapped private key relocated to another tenant, another key, another version slot, or presented under the wrong KEK version fails the GCM tag. The kind is additive: no existing byte layout changed, the fmt byte is still 1, and the new kind has its own frozen golden vector. alg is deliberatelynot in the AAD: the wrapped PKCS#8 blob names its own algorithm, so a cross-alg import fails structurally — an alg-column flip by a database-write attacker is a DoS on that key, never a wrong-algorithm signature — and binding it would buy nothing while changing a frozen layout. Details on the Cryptography page.

Sign-only tokens

A sign-only credential is { scopes: ["keys:sign"], project, env, tenant } — it can sign but cannot read secrets, list anything, or manage keys. Two additional rules:

  • Per-key pinning. A token minted with a key scope is pinned to that one key's immutable id: it authorizes nothing outside the keys:* family and only for its own key. Pin a CI release-signing token to the release key and it cannot touch anything else.
  • Signing is never ambient across tenants. A wildcard-tenant token cannot sign without admin — the same non-ambient rule that gates cross-tenant reads behindsecrets:export-all.

The whole operator flow is four fe commands:

fe key create release                        # → base64 public key (and only that)
fe key sign release --stdin < artifact.bin   # → base64 signature; message from stdin, never argv
fe key rotate release                        # v+1 signs; prior versions stay verifiable ('retiring')
fe token create --name ci-release --cap keys:sign --key release \
  --project app --env production --tenant <ulid>   # the sign-only, key-pinned CI token

Audit, rotation, shred

  • Every signature is audited as a key.sign row committing to{ keyVer, sha256(message) } — the hash-chained audit proves what was signed without ever logging the payload.
  • KEK rotation re-wraps signing keys too. The rotation step re-wrapssigning_key_versions rows alongside tenant DEKs, and the completion count gates on both tables — pruning a KEK version while a signing key still references it would brick that key, so the gate refuses until the count reaches zero.
  • Key rotation does not un-leak a key. If a version is presumed compromised, mark it retired (verifiers drop it) and rotate — the same honest distinction as proactive vs compromise-driven KEK rotation.
  • Crypto-shred drops the wrapped signing material in the same pass that drops the tenant's DEKs. A shredded tenant loses signing capability along with decryption.

Operating the oracle

A consumer minting tokens per request needs a few operational facts, stated from the implementation so nobody has to reverse-engineer them.

  • Retries are safe; byte idempotency is algorithm-specific. The sign path holds no per-request state, so retrying a timed-out or failed call is safe with no idempotency key. Ed25519 is deterministic (PureEdDSA, RFC 8032), so the same message under the samekeyVer produces a byte-identical signature. ES256 signatures are randomized and may differ across attempts while remaining valid. The only side effect is an additional key.signaudit row; consecutive rows with the same message hash read as retries, not new issuances. One caveat: a retry that straddles a :rotate signs under the new active version, so take{ keyVer, publicKey } from the response you actually use, never from an earlier attempt.
  • Latency is D1 round trips, not crypto. A sign call is six sequential D1 reads (token, project→environment→tenant chain, key row, active version row) plus the audited append, with the KEK value read per request and its import memoized per isolate — only a cold isolate pays an importKey. The AES-GCM unwrap, non-extractable import, and Ed25519 sign are microseconds. The sign path never touches the freshness Durable Object (key rows have no anchor in v1), and audit appends are single-writer, so sustained parallel sign load serializes at the chain — by design: the oracle is sized for issuance cadence. Measure in your own deployment; colocating the store with its D1 primary is the lever that matters.
  • Same account? The v2 RPC mirror lands sign first. AWorkerEntrypoint service-binding call keeps token minting off the public HTTP hop entirely — the transport swap removes only the HTTP hop; the D1 + audit shape is unchanged.
  • Verifiers stay offline and rotation-tolerant. verify needs only the public key — no store, no KEK, no network. During a rotation grace window, accept the full non-retired version set from GET, refreshed on a cadence shorter than your retiring→retired policy window — and drop a retired version immediately.

Honest boundary

This is not an HSM. The private-key bytes exist transiently in isolate memory during generate and sign. That is strictly stronger than fetching a secret and signing on a client host — the key never leaves the Cloudflare boundary, and every use is capability-scoped and audited — but the trust root remains the KEK. Break-glass export of the D1 database preserves key custody through a store outage; unwrapping offline with the extracted KEK yields the private key, so an exercised break-glass is treated as key exposure: rotate and retire after use. fe key drillscripts this as a runbook step — it proves custody offline without printing private material, and only --reveal outputs the PKCS#8 keys (seeDeployment & CLI). A D1-write attacker cannot forge a signable key (writing priv_wrapped without the KEK fails the GCM tag) and cannot exfiltrate one; rolling back the active version or swapping a stored public key breaks verification and is visible in the audit chain, which records the keyVer of every signature.

SDK surface

import { generateSigningKey, unwrapSigningKey, sign, verify } from "@fractalboxdev/flare-encrypt-sdk";

// Store-side (the worker does this for you). alg: 'Ed25519' (default) | 'ES256'
const wsk  = await generateSigningKey(kek, { aadFmt: 1, tenantId, keyId, keyVer: 1, kekKv }, "ES256");
//    → Result<{ publicKey /* 32B raw | 65B SEC1 */, wrapped, iv, … }, KeygenFailed | WrapFailed | ContextMismatch>
const key  = await unwrapSigningKey(kek, wsk.value, tenantId, keyId, "ES256"); // extractable:false, ['sign']
const sig  = await sign(key.value, message);                            // Result<Uint8Array /* 64B */, SignFailed>

// Anywhere (verifier side) — no store, no KEK:
const okay = await verify(publicKeyRaw, message, signature, "ES256");   // Result<boolean, VerifyFailed>

verify resolves to ok(false) for a signature that does not verify;VerifyFailed is reserved for structural faults — mirroring the GCM-honest split between tag failures and pre-crypto errors. The store maps KeygenFailed,SignFailed, and VerifyFailed to a cause-silent 500, like every other crypto failure. Routes and token details are in the API reference; the schema is in the Data model.