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 = structural

SDK — 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
}) => DekCache

The 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 + pathDoesCapability
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}:exportReturn { 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}:rotateNew keyVer active for signing; the prior version is retained as retiring for verification.keys:rotate
GET /v1/keys/…/{name}/publicPublished 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/jwksThe 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/tokensMint 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/bootstrapOne-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 scopes JSON 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 explicit admin or secrets:export-all capability — a baresecrets:read does not grant it, and keys:sign follows the same rule (cross-tenant signing requires admin). The default guidance mints per-tenant tokens.
  • Sign-only tokens. A token minted with an optional key scope is pinned to that one signing key's immutable id — it authorizes nothing outside the keys:*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.

StatuscodeWhen
400malformed_requestBad JSON, missing value, or a bad path segment.
401unauthenticatedMissing or garbled bearer.
403forbiddenAuthenticated but out of scope or missing a capability — also used for a name outside the token's scope, to avoid an existence oracle.
404not_foundA name that is in scope but absent or soft-deleted.
409conflictOptimistic-concurrency version mismatch on upsert.
409stale_ciphertextFreshness-anchor mismatch on read (rollback detected); distinct from conflict, and raises an audit alarm.
422context_mismatchMaps the SDK's ContextMismatch and UnsupportedAadFmt (both folded in).
500internalMaps 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.