Documentation

Data model

The flare-encrypt D1 schema: projects → environments → tenants → secrets, plus tenant_deks, api_tokens, audit_log, and store_meta. Column-generic envelope columns, the two version axes, the out-of-D1 freshness anchor, and honest crypto-shred.

Shape

One D1 database on your own Cloudflare account holds encrypted secrets for many projects, environments, and tenants. Four tables form the hierarchyprojects → environments → tenants → secrets; tenant_deks,api_tokens, audit_log, and store_meta support it. The master key lives per-environment outside D1, so only wrapped DEKs and ciphertext ever touch this database — a full D1 read yields no plaintext and no unwrapped key.

Three invariants the schema exists to enforce:

  • Placement integrity via the encryption context. Each ciphertext is bound to{ tenant_id, name, dek_ver } and each wrapped DEK to{ tenant_id, "dek-wrap", kek_kv }. Relocating a BLOB fails the GCM tag.
  • Scheme dispatch is structural, never by magnitude. Which key decrypts a row is decided by the presence of a wrapped DEK plus the crypto_scheme column — never by comparing dek_ver to a "current" version.
  • The context is placement integrity, not authorization and not freshness. Tenant isolation at read time rests on token scoping; anti-rollback rests on a freshness anchor held outside D1.

Immutable identity

projects.id, tenants.id, and the rest are ULIDs assigned once and never rewritten. tenants.id is the exact byte string used as the encryption-contexttenantId (charset [A-Za-z0-9_-], never re-normalized). Human-facingslugs are renamable and never enter the context — renaming a project or tenant does not touch a single ciphertext.

The envelope tables

Every secret is a column-generic four-column envelope — ciphertext, iv,dek_ver, aad_fmt — so one shape stores a DATABASE_URL, an OAuthaccess_token, or a webhook secret with no per-type schema. Every DEK is stored only wrapped, carrying dek_wrapped, dek_iv, kek_kv,dek_ver, and aad_fmt. Keeping the wrapped DEK in its own row (rather than bundled with the ciphertext, as an AWS "encrypted message" would) is what enables O(1) crypto-shred: drop one row to erase a tenant.

-- The per-tenant wrapped DEK (the envelope middle tier), versioned for rotation.
CREATE TABLE tenant_deks (
  tenant_id   TEXT    NOT NULL REFERENCES tenants(id),
  dek_ver     INTEGER NOT NULL,          -- per-tenant, starts at 1 and increases
  dek_wrapped BLOB    NOT NULL,          -- AES-GCM(KEK, rawDEK, wrapAAD) = ct||tag
  dek_iv      BLOB    NOT NULL,          -- 12 bytes, fresh-random per wrap
  kek_kv      INTEGER NOT NULL,          -- KEK version that wrapped this record; resolve by (environment_id, kek_kv)
  aad_fmt     INTEGER NOT NULL DEFAULT 1,
  status      TEXT    NOT NULL DEFAULT 'active',  -- active | retiring | revoked
  created_at  INTEGER NOT NULL,
  PRIMARY KEY (tenant_id, dek_ver)
);

-- The column-generic secret. One row per (tenant, name).
CREATE TABLE secrets (
  id            TEXT PRIMARY KEY,        -- ULID
  tenant_id     TEXT    NOT NULL REFERENCES tenants(id),
  name          TEXT    NOT NULL,        -- this string is the encryption-context column
  ciphertext    BLOB    NOT NULL,        -- AES-GCM(DEK, plaintext, dataAAD) = ct||tag
  iv            BLOB    NOT NULL,        -- 12 bytes, fresh-random per write
  dek_ver       INTEGER NOT NULL,        -- MUST reference a committed tenant_deks row
  aad_fmt       INTEGER NOT NULL DEFAULT 1,
  crypto_scheme TEXT    NOT NULL DEFAULT 'envelope-v1',  -- structural scheme discriminator
  version       INTEGER NOT NULL DEFAULT 1,               -- value revision; the rollback signal
  created_at    INTEGER NOT NULL,
  updated_at    INTEGER NOT NULL,
  deleted_at    INTEGER,
  UNIQUE (tenant_id, name),
  FOREIGN KEY (tenant_id, dek_ver) REFERENCES tenant_deks(tenant_id, dek_ver)
);

The supporting tables

TableHoldsNotes
projectsULID id, unique renamable slug, name.Top of the hierarchy; slug never enters the context.
environmentsULID id, parent project, slug, kek_kv.kek_kv is this environment's current KEK version.
tenantsULID id, parent environment, slug, shred fields.id is the context tenantId; carries shredded_at, erasure_eta, shred_generation.
api_tokenstoken_hash, display prefix, nullable project/env/tenant/key scope, scopes.Plaintext is never stored; CHECKs enforce hierarchical scope; nullable key_id pins sign-only tokens to one signing key.
signing_keysULID id (the KEYWRAP-context keyId), tenant, name, alg, current key_ver.The signing-key identity; alg is a closed vocabulary ('Ed25519' | 'ES256') with fail-closed dispatch, like crypto_scheme.
signing_key_versionspublic_key (raw 32B), priv_wrapped, priv_iv, kek_kv, aad_fmt, status.The private key exists only as ct||tag under the environment KEK; mirrors the tenant_deks envelope shape, keyed (key_id, key_ver). See Signing keys.
audit_logMonotonic seq, action, ids, prev_hash, row_hash.Append-only, hash-chained, mirrored to an immutable sink.
store_metaSingletons: audit head anchor, bootstrap state.Key/value.

The audit_log.action vocabulary is a closed set: secret.read,secret.write, secret.list, secret.export,secret.delete, the key.create / key.read /key.sign / key.rotate signing actions, the kek.rotate.* anddek.rotate.* steps, tenant.shred, ciphertext.regression,token.create, token.revoke, auth.deny, andbootstrap. A key.sign row's meta commits to{ keyVer, sha256(message) } — proof of what was signed with the payload never logged.

The two version axes

dek_ver and kek_kv are separate columns because they rotate independently and appear in different contexts. The data context never mentions the KEK, so re-wrapping DEKs under a new KEK version leaves every ciphertext byte untouched.

AxisColumnsContextRotation cost
dek_versecrets.dek_ver, tenant_deks.dek_verdata context onlyDEK rotation re-encrypts one tenant's secrets — O(tenant secrets)
kek_kvtenant_deks.kek_kv, environments.kek_kvwrap context onlyKEK rotation re-wraps DEKs — O(tenants), zero data re-encryption

kek_kv is per-environment exactly as dek_ver is per-tenant. Each environment holds its own KEK material, so a KEK is resolved by (environment_id, kek_kv), two environments both at kek_kv = 1 hold different bytes, and a KEK compromise unwraps only that one environment's DEKs. crypto_scheme is orthogonal to all of these — a closed vocabulary with a single member today, kept as the forward-extension seam, so a future scheme is a new value rather than a magnitude gate. There is no dek_ver >= N gate anywhere.

aad_fmt records which serialization format produced each row, so decryptrebuilds the exact historical context bytes and unwrapDek rebuilds the wrap context at the row's stored format. Without these columns an fmt change would make every prior row permanently unreadable. Adding a project, environment, tenant, or secret name is data, never a migration.

The freshness anchor

The encryption context binds placement, not the value revision, so a D1-write attacker could otherwise roll a secret back to a prior (ciphertext, iv) and the GCM tag would still validate. Adding version to the context does not fix it — the attacker controls that column too. The store closes the gap with a hash comparison in the read path, not a context change: the wrap and data contexts and their fmt byte are unchanged.

A per-tenant freshness Durable Object holds the authoritative per-(tenant, column) { version, ciphertext_hash }outside D1. Because it lives outside D1, a D1-write attacker cannot roll it back, and because it pinsSHA-256 of the current ciphertext rather than a version integer, a rolled-back older ciphertext hashes to a value that no longer matches and is rejected on read.

  • Write. In one DO transaction the anchor bumps the version, recordsSHA-256(new ciphertext), and extends the audit hash-chain; then the D1secrets row is written. The D1 ciphertext is never written without the matching DO update.
  • Read. The store verifies row.version === DO.version andSHA-256(row.ciphertext) === DO.ciphertext_hash; a mismatch is aStaleCiphertext error (HTTP 409) plus an audit alarm.
  • One writer, two jobs. The same sharded DO that serializes freshness writes is the single-writer the audit hash-chain requires, so freshness and audit extension commit as one transaction. secrets.version becomes the attacker-writable mirror the read path checks against the anchor.

The freshness Durable Object is on the read path, so it is an availability dependency — a read that cannot reach it fails closed rather than serving unverified ciphertext. A fully-compromised account able to write both D1 and the DO still defeats freshness; the R2-mirrored audit chain stays the out-of-band tamper-evidence backstop.

The audit log

audit_log lives in the same D1 as secrets, so the principal it holds accountable — anyone with wrangler d1 execute — could otherwise edit it. Two mechanisms make tampering detectable and recoverable:

  • Hash chain. row_hash = SHA-256(prev_hash || auditCanonical(fields))with a gap-detecting AUTOINCREMENT seq and a genesisprev_hash of 32 zero bytes. Any deletion or edit breaks the chain or leaves a gap;GET /v1/audit returns a verification status and flags the first broken link. The dedicated audit serializer is separate from the context TLV encoder — it writes explicit NULL markers, u64 sequence and timestamp fields, and a wide length prefix for multi-KB meta.
  • External immutable sink. Every row streams via Logpush → R2 with Object Lock in compliance mode. That mirror is the only copy that survives an attacker with full operator-account access, who could rewrite the in-D1 chain wholesale.

Mutating operations write their audit row in the same D1 transaction as the mutation, or committed before it. A failed audit write fails the operation — the store never performs an unlogged read, write, or shred. Audit meta never contains plaintext or ciphertext.

Crypto-shred semantics

Deleting a tenant runs DELETE FROM tenant_deks WHERE tenant_id = ?, setsdeleted_at and shredded_at, and bumps shred_generation. That is O(1) and makes the tenant's ciphertext unreadable through the live database — but the schema records the true erasure-completion time rather than claiming instantaneous deletion:

tenants.erasure_eta = max(
  shredded_at + TIME_TRAVEL_WINDOW_MS,          -- default 30 days
  kek_version_retirement_eta(env.kek_kv chain), -- when every wrapping KEK version is purged
  shredded_at + DEK_CACHE_TTL_MS                -- last cross-isolate cached handle expiry
)

The tenant.shred audit event records shredded_at, erasure_eta, and the KEK versions still pending purge. The store never claims "permanent, irreversible, O(1)" without this qualification. The cryptography behind these columns is on theCryptography page; the HTTP surface that writes them is theAPI reference.