Documentation
API reference
The @fractalboxdev/flare-encrypt-sdk surface — Result-returning encrypt/decrypt, KEK/DEK lifecycle, the DEK cache — and the deployed store's /v1 HTTP API: routes, scoped-token auth, the freshness read-check, and the error taxonomy.
There are two surfaces: the embeddable Web-Crypto library@fractalboxdev/flare-encrypt-sdk, which a consuming app runs in-isolate with zero runtime dependencies; and the deployed Worker store, an HTTP/RPC API over the SDK plus D1, a master-key binding, scoped-token auth, and a hash-chained audit log. One rule underpins both: callers never supply a raw encryption context — encrypt/decrypt/wrapDek/unwrapDektake a structured context and derive the bytes internally.
SDK — the Result channel
Every fallible operation returns a Result<A, E> — async operations aPromise<Result<A, E>> — with a typed, tagged error channel and nothrow. The error union stays in the type signature, so a caller cannot ignore it the way an exception silently propagates.
export type Result<A, E> =
| { readonly ok: true; readonly value: A }
| { readonly ok: false; readonly error: E };
export const ok = <A>(value: A): Result<A, never> => ({ ok: true, value });
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
const r = await encrypt(dek, pt, ctx);
if (!r.ok) return mapError(r.error); // branch on r.error._tag, never instanceof
use(r.value);Errors are plain tagged classes branched on by their _tag discriminant, which stays realm-safe across the Workers/Node boundary. A consumer that prefers exceptions or its own error type adapts at its own boundary in a few lines.
SDK — value types
type TenantId = string; // immutable row PK, [A-Za-z0-9_-], NEVER re-normalized
type ColumnId = string; // opaque, fully-qualifying; MUST be in a caller allowlist; <= 256 UTF-8 bytes
type DekVersion = number; // u32, strictly per-tenant, starts at 1
type KekVersion = number; // u32
type AadFmt = number; // u8; sourced from the row's aad_fmt, not hardcoded
interface DataContext { aadFmt: AadFmt; tenantId: TenantId; column: ColumnId; dekVer: DekVersion }
interface WrapContext { aadFmt: AadFmt; tenantId: TenantId; purpose: "dek-wrap"; kekKv: KekVersion }
interface CipherRecord { ciphertext: Uint8Array; iv: Uint8Array; dekVer: DekVersion; aadFmt: AadFmt }
interface WrappedDek { wrapped: Uint8Array; iv: Uint8Array; kekKv: KekVersion; dekVer: DekVersion; aadFmt: AadFmt }CipherRecord (ciphertext = ct || tag, iv exactly 12 bytes) andWrappedDek are deliberately separate rather than one bundled message: the wrapped DEK lives in its own row, so dropping that one row crypto-shreds the tenant in O(1). The on-wire version is the fmt byte in the serialized context — carried as aadFmt through the types, the encoders, and the row. There is no separate v field.
SDK — KEK and DEK lifecycle
Wrapping is AES-GCM encryption of the raw 32-byte DEK under the wrap context, notSubtleCrypto.wrapKey. The raw DEK is extractable:true only transiently to be wrapped, is zeroed after wrap and import, and is never logged; unwrapDek always importsextractable:false.
const importKek: (rawKek: Uint8Array, kekKv: KekVersion) => Promise<Result<CryptoKey, InvalidKeyMaterial>>
const importKekFromBase64: (b64: string, kekKv: KekVersion) => Promise<Result<CryptoKey, InvalidKeyMaterial | CryptoKeyMissing>>
const generateDek: () => Promise<Result<{ raw: Uint8Array }, EncryptFailed>> // 32B; zeroed after wrap+import
const wrapDek: (kek: CryptoKey, rawDek: Uint8Array, ctx: WrapContext) => Promise<Result<WrappedDek, WrapFailed | ContextMismatch>>
const unwrapDek: (kek: CryptoKey, w: WrappedDek, tenantId: TenantId) => Promise<Result<CryptoKey, UnwrapFailed | ContextMismatch | KekVersionUnknown>>SDK — data crypto
The context is mandatory. encrypt sets record.dekVer = ctx.dekVer andrecord.aadFmt = ctx.aadFmt; decrypt assertsctx.dekVer === record.dekVer, ctx.aadFmt === record.aadFmt, andiv.byteLength === 12 before decrypting with a constant tagLength = 128.
const encrypt: (dek: CryptoKey, plaintext: string, ctx: DataContext) => Promise<Result<CipherRecord, EncryptFailed | ContextMismatch>>
const decrypt: (dek: CryptoKey, record: CipherRecord, ctx: DataContext) => Promise<Result<string, DecryptFailed | ContextMismatch>>
// Pure, synchronous context derivation (no crypto): checks fmt support + field caps.
const deriveDataContext: (c: DataContext) => Result<Uint8Array, ContextMismatch>
const deriveWrapContext: (c: WrapContext) => Result<Uint8Array, ContextMismatch>SDK — signing (Ed25519 / ES256)
The asymmetric sibling of the envelope, same zero-dependency Web-Crypto posture. Two algorithms in a closed vocabulary — SigningAlg = 'Ed25519' | 'ES256', defaulting toEd25519, with the JOSE mapping (EdDSA/ES256) exported asJOSE_ALGS. Generation and wrapping are one operation — a plaintext private key is never an SDK input or output — and unwrapSigningKey always rehydratesextractable:false with usages ['sign']. verify is public-key-only: it runs anywhere, with no store and no KEK. The full model is on theSigning keys page.
const generateSigningKey: (kek: CryptoKey, ctx: KeyWrapContext, alg?: SigningAlg)
=> Promise<Result<WrappedSigningKey, KeygenFailed | WrapFailed | ContextMismatch>>
const unwrapSigningKey: (kek: CryptoKey, w: WrappedSigningKey, tenantId: TenantId, keyId: KeyId, alg?: SigningAlg)
=> Promise<Result<CryptoKey /* extractable:false, ['sign'] */, UnwrapFailed | ContextMismatch>>
const sign: (key: CryptoKey, message: Uint8Array) => Promise<Result<Uint8Array /* 64B */, SignFailed>>
const verify: (publicKeyRaw: Uint8Array, message: Uint8Array, signature: Uint8Array, alg?: SigningAlg)
=> Promise<Result<boolean, VerifyFailed>> // ok(false) = does not verify; VerifyFailed = structuralSDK — the DEK cache
A separate, optional module keeps the pure crypto path cache-free. It caches unwrappedextractable:false handles only — a heap dump yields an opaque handle, not bytes.
interface DekCache {
get(t: TenantId, v: DekVersion): CryptoKey | undefined // undefined = miss (no Option; zero-dep)
set(t: TenantId, v: DekVersion, k: CryptoKey): void
evictTenant(t: TenantId): void // call on disconnect AND crypto-shred AND dek-rotate
}
const makeDekCache: (o: {
maxSize: number; // default 512, hard LRU cap
ttlMs: number; // default 300_000, measured FROM INSERTION
shredGeneration?: (t: TenantId) => number; // optional: refuse a stale-generation handle
}) => DekCacheThe full error taxonomy and the encryption-context byte layout live in theCryptography page.
Store API — routes
The store is the SDK plus D1, a master-key binding, scoped-token auth, an HTTP API, and a hash-chained audit log. All routes are under /v1 with an Authorization: Bearer fe_…header — except the …/public published-key routes, which are unauthenticated by design; the hierarchy is project → environment → tenant → secret. The whole contract is also published as a generated OpenAPI 3.1 document — every route with its capability, the request/response schemas, the base64 encodings, and the closed error-code set — the same file the worker's tests verify against the route table, so a non-TypeScript consumer can generate a typed client or validate a hand-rolled one against it.
| Method + path | Does | Capability |
|---|---|---|
PUT /v1/secrets/{p}/{e}/{t}/{name} | Upsert a value; get-or-create the tenant DEK atomically; update the freshness anchor before writing the row. | secrets:write |
GET /v1/secrets/{p}/{e}/{t}/{name} | Return { name, value, version }; verify the freshness anchor (409 on mismatch). | secrets:read |
GET /v1/secrets/{p}/{e}/{t} | List names and versions — never values. | secrets:list |
POST /v1/secrets/{p}/{e}/{t}:export | Return { ENV: value, … } in one round-trip for the CLI injector. | secrets:read |
DELETE /v1/secrets/{p}/{e}/{t}/{name} | Soft delete (recoverable; not crypto-shred). | secrets:delete |
POST /v1/keys/{p}/{e}/{t}/{name} | Body { alg?, published? } (Ed25519 default | ES256; publishing defaults false). Generate a keypair server-side; wrap the private key under the KEK; return only the public key. 409 if the name exists. | keys:create |
GET /v1/keys/{p}/{e}/{t}/{name} | Public key(s) + { alg, jose, keyVer, status }, including non-retired prior versions for verifiers. | keys:read |
POST /v1/keys/…/{name}:sign | { message } (base64) → { signature, keyId, keyVer, publicKey, alg, jose }. The private key never leaves. See Signing keys. | keys:sign |
POST /v1/keys/…/{name}:rotate | New keyVer active for signing; the prior version is retained as retiring for verification. | keys:rotate |
GET /v1/keys/…/{name}/public | Published keys only: current + non-retired public keys, base64 + bare hex. Uniform 404 for unknown and unpublished names alike. See Signing keys. | (none — unauthenticated) |
GET /v1/keys/…/{name}/public/jwks | The same published set as RFC 7517 JWKS (OKP/Ed25519 or EC/P-256). | (none — unauthenticated) |
POST /v1/rotate/kek/{p}/{e} | Async Workflow: re-wrap tenant DEKs under a new KEK version. Returns 202 { jobId }. | rotate |
POST /v1/rotate/dek/{p}/{e}/{t} | Async Workflow: new DEK, re-encrypt the tenant's secrets. Returns 202 { jobId }. | rotate |
GET /v1/rotate/status/{jobId} | Progress plus remaining-on-old-version counts. | rotate |
DELETE /v1/tenants/{p}/{e}/{t} | Crypto-shred the tenant (not instantaneous — see below). | admin |
GET /v1/audit/{p}?env=&tenant=&since=&limit= | Audit entries plus chain-verification status. | audit:read |
POST /v1/tokens | Mint a scoped token; the value is returned once. admin mints unrestricted; a tokens:mint minter is attenuated — the minted token must be a subset of the minter's own scope, capabilities, and expiry (never admin or tokens:mint itself). | tokens:mint |
DELETE /v1/tokens/{id} | Revoke a token. Cascades to the live tokens it minted via tokens:mint; admin-minted tokens are independent grants and never cascade. | admin |
POST /admin/bootstrap | One-shot first-admin mint; gated, no token. | (gated) |
Store API — token auth
A token is fe_<prefix>_<secret>. The middleware hashes the secret (SHA-256, or HMAC-SHA256(pepper, secret) when a per-deployment pepper is set), looks it up by indexed hash, asserts it is not revoked or expired, resolves the full project → environment → tenant chain on immutable IDs, never slugs, verifies each resolved row is a child of the previous, asserts the path is within the token's scope, and asserts the capability is present. A denial is audited as auth.deny and returns 403.
- Entropy. The secret is at least 256 bits of
crypto.getRandomValues, base64url after the display prefix. That makes the stored hash brute-force-safe even unsalted; the optional pepper additionally makes a D1-only hash leak useless. - Capabilities (the
scopesJSON array):secrets:read,secrets:write,secrets:list,secrets:delete,secrets:export-all,keys:create,keys:read,keys:sign,keys:rotate,rotate,audit:read,admin. - Wildcard tenants are not ambient. A
tenant = NULL(cross-tenant) token requires an explicitadminorsecrets:export-allcapability — a baresecrets:readdoes not grant it, andkeys:signfollows the same rule (cross-tenant signing requiresadmin). The default guidance mints per-tenant tokens. - Sign-only tokens. A token minted with an optional
keyscope is pinned to that one signing key's immutable id — it authorizes nothing outside thekeys:*family and only for its own key. See Signing keys.
Store API — the freshness read-check
A per-tenant freshness Durable Object holds the authoritative per-(tenant, column) { version, ciphertext_hash }outside D1, so a D1-write attacker cannot roll it back. On write, the DO bumps the version, recordsSHA-256(new ciphertext), and extends the audit hash-chain before the D1 row is written. On read, the store verifies row.version === DO.version andSHA-256(row.ciphertext) === DO.ciphertext_hash; a mismatch raisesstale_ciphertext (HTTP 409) plus an audit alarm.
The anchor commits to a hash of the current ciphertext, not a version an attacker also writes, so a rolled-back older blob fails the read check. The freshness DO is on the read path, so it is an availability dependency — a read that cannot reach it fails closed. A fully-compromised Cloudflare account that writes both D1 and the DO still defeats it; the immutable Logpush→R2 audit mirror stays the tamper-evidence backstop.
Store API — error taxonomy
The canonical envelope is { error: { code, message, requestId } }. Messages never echo secret values.
| Status | code | When |
|---|---|---|
| 400 | malformed_request | Bad JSON, missing value, or a bad path segment. |
| 401 | unauthenticated | Missing or garbled bearer. |
| 403 | forbidden | Authenticated but out of scope or missing a capability — also used for a name outside the token's scope, to avoid an existence oracle. |
| 404 | not_found | A name that is in scope but absent or soft-deleted. |
| 409 | conflict | Optimistic-concurrency version mismatch on upsert. |
| 409 | stale_ciphertext | Freshness-anchor mismatch on read (rollback detected); distinct from conflict, and raises an audit alarm. |
| 422 | context_mismatch | Maps the SDK's ContextMismatch and UnsupportedAadFmt (both folded in). |
| 500 | internal | Maps DecryptFailed/UnwrapFailed — never leaks which. |
The existence-oracle policy is explicit: out-of-scope names return 403 (indistinguishable from "denied"); in-scope-but-absent names return 404. A caller can never probe names across a scope boundary. The D1 schema behind these routes is the Data model; deploying and operating the store is Deployment & CLI.