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_schemecolumn — never by comparingdek_verto 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
| Table | Holds | Notes |
|---|---|---|
projects | ULID id, unique renamable slug, name. | Top of the hierarchy; slug never enters the context. |
environments | ULID id, parent project, slug, kek_kv. | kek_kv is this environment's current KEK version. |
tenants | ULID id, parent environment, slug, shred fields. | id is the context tenantId; carries shredded_at, erasure_eta, shred_generation. |
api_tokens | token_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_keys | ULID 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_versions | public_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_log | Monotonic seq, action, ids, prev_hash, row_hash. | Append-only, hash-chained, mirrored to an immutable sink. |
store_meta | Singletons: 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.
| Axis | Columns | Context | Rotation cost |
|---|---|---|---|
dek_ver | secrets.dek_ver, tenant_deks.dek_ver | data context only | DEK rotation re-encrypts one tenant's secrets — O(tenant secrets) |
kek_kv | tenant_deks.kek_kv, environments.kek_kv | wrap context only | KEK 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, records
SHA-256(new ciphertext), and extends the audit hash-chain; then the D1secretsrow is written. The D1 ciphertext is never written without the matching DO update. - Read. The store verifies
row.version === DO.versionandSHA-256(row.ciphertext) === DO.ciphertext_hash; a mismatch is aStaleCiphertexterror (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.versionbecomes 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-detectingAUTOINCREMENTseqand a genesisprev_hashof 32 zero bytes. Any deletion or edit breaks the chain or leaves a gap;GET /v1/auditreturns 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-KBmeta. - 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.