# Encryption

Mark a text column `.encrypted()` and Quickback envelope-encrypts it at rest with a **per-organization key**:

```typescript
export default feature('guests', {
  columns: {
    id:               q.id(),
    organizationId:   q.scope('organization'),   // required — keys are per-org
    name:             q.text().required(),
    emergencyContact: q.text().encrypted(),
    dietaryNotes:     q.text().encrypted(),
    ...q.audit(),
    ...q.softDelete(),
  },
  // "+" role expansion requires auth.roleHierarchy in quickback.config.ts
  read:   { access: { roles: ['member+'] } },
  create: { access: { roles: ['member+'] } },
});
```

That one modifier changes what a database breach means: a leaked D1 backup, export, or stolen database token yields only ciphertext for those columns. The keys live separately, wrapped under a worker secret the database never sees.

## What the compiler generates

- **`org_data_keys`** — one row per organization holding a random 256-bit data key (DEK), stored **wrapped** under the `ENCRYPTION_KEK` worker secret. Created lazily on the org's first encrypted write.
- **Write path** — every write (CRUD routes, batch routes, and `db.insert`/`db.update` inside your actions) flows through the same chokepoint, which AES-256-GCM-encrypts the column before anything else sees it. Audit snapshots, webhook payloads, and realtime deltas therefore carry ciphertext by construction.
- **Read path** — generated routes decrypt behind the firewall and then apply masking, so authorized callers see plaintext and everyone else sees the redaction — never ciphertext.
- **`ctx.crypto`** — explicit `await ctx.crypto.decrypt(value)` / `ctx.crypto.encrypt(value)` for action code that processes the field server-side (building a manifest, sending an email). Scoped to the caller's active organization.
- **Boot guard** — a project with encrypted columns refuses to serve without `ENCRYPTION_KEK` (503 `MISSING_ENV`), instead of silently storing plaintext.

Values at rest look like `qbenc:1:<keyVersion>:<iv>:<ciphertext>` — the prefix makes accidental plaintext detectable, and the embedded key version supports rotation with lazy re-encryption on write.

## Setup

Scaffolded projects already have a generated `ENCRYPTION_KEK` in `.dev.vars`, so local dev works with zero setup. For production:

```bash
openssl rand -hex 32 | wrangler secret put ENCRYPTION_KEK
```

## Rules the compiler enforces

Ciphertext cannot be queried, so encrypted columns are **excluded from every query surface at compile time** — these are errors, not warnings:

- No `.filterable()` / `.searchable()`, and never listed in a view's `query.{searchable,filterable,sortable}`
- No `.unique()` (AES-GCM randomizes ciphertext per write), no primary keys, no `.references()`, no `q.scope()` columns, no defaults (a SQL default would bypass the encrypting chokepoint)
- Never an `embeddings.fields` source (embedding a value ships its plaintext to the model)
- The table must carry a `q.scope('organization')` column — keys are per-organization
- Text-shaped columns only (v1)
- Database provider must be **Cloudflare D1** or **Neon over HTTP/Hyperdrive**. The deprecated Neon WebSocket mode and providers without the key-table plumbing are compile-time refusals, never silently-plaintext output. On Neon, `org_data_keys` lives in the `app` Postgres schema with org-scoped row-level security: a request-scoped connection can read and lazily create exactly its own organization's wrapped-key row; rotation and crypto-shredding are service-role operations.

Encrypted columns **may** appear in view projections and responses — they decrypt through the generated prepare path. Masking composes on top as defense-in-depth.

## Per-organization keys: blast radius and crypto-shredding

Each organization's data is encrypted under its own DEK, so one compromised key never exposes another tenant. Deleting an org's `org_data_keys` row makes every encrypted value it ever wrote **permanently unreadable** — the cleanest possible GDPR-erasure and offboarding story.

## What this tier does and doesn't claim

The envelope tier protects against **storage compromise**: backups, exports, dashboard access, leaked database tokens, SQL injection, and insiders with database read access all get ciphertext. The server *can* decrypt — that's the point; your actions still process the field. It does not protect against full Worker compromise — for that, use the **sealed tier** below, where the server structurally cannot read the value at all.

Two practical notes:

- **Action responses**: rows returned from `db.insert(...).returning()` inside your own actions contain ciphertext — decrypt explicitly with `ctx.crypto.decrypt()` if your action returns the field. Generated CRUD routes handle this automatically.
- **No server-side equality lookups** on encrypted values (blind indexes are a roadmap item). If you need to filter by it, it probably shouldn't be encrypted — or it needs a separate non-sensitive derived column.

---

# Sealed tier — end-to-end encryption

Mark a text column `.sealed()` instead of `.encrypted()` and you cross a line the envelope tier never claims: **the server cannot read the value.** It holds only ciphertext plus a content key *wrapped to a public key it has no private half for*. A full Worker compromise — leaked secrets, malicious code, a coerced admin — still yields nothing but ciphertext. Decryption happens only on the client, after a fresh step-up.

{/* doc-compile: skip — project-config prerequisite: `.sealed()` requires a Cloudflare KV binding (the reveal-nonce + JWT-revocation store), and the gate compiles every fence against one fixed harness config that declares no KV. The file is otherwise whole. */}

```typescript
export default feature('candidates', {
  columns: {
    id:              q.id(),
    organizationId:  q.scope('organization'),       // required — keys are per-scope
    name:            q.text().required(),
    governmentId:    q.text().encrypted(),           // envelope tier: server CAN read (to mask)
    backgroundCheck: q.text({ maxLength: 8000 }).sealed(),  // sealed tier: server CANNOT read
    ...q.audit(),
    ...q.softDelete(),
  },
  read:   { access: { roles: ['member+'] } },
  create: { access: { roles: ['admin+'] } },
});
```

Use it for data that is legally sensitive and need-to-know, where you want a database breach **and the server itself** to yield only ciphertext: background checks, health findings, raw identity documents, anything you'd rather not be able to read by accident.

The page below is the *usage* view. For the on-the-wire contract — the versioned `qbseal:1` envelope format, the full `/seal/v1` lifecycle, the Argon2id floor, passkey expectations, canonical conformance vectors, and the first-party **Swift / Kotlin / TypeScript SDKs** (with install + usage) — see [Sealed protocol](/define/sealed-protocol).

## The key model

Three nested keys. A breach of any layer above plaintext yields only more wrapped keys:

```
USER key ──unwrap──▶ SCOPE private key ──unwrap──▶ content key ──decrypt──▶ plaintext
```

- **USER key** — derived on the client from a **vault passphrase** (Argon2id → an Ed25519 signing key + an X25519 wrap key). This is **never** the Better Auth login password — reusing the login password would collapse the zero-knowledge property back to server-trust. The server stores only the Ed25519 *public* key (to verify step-ups) and the Argon2id params.
- **SCOPE keypair** — one per scope (per organization). The content key is wrapped to the scope's public key, so any authorized reader can be granted access without re-encrypting the data.
- **Content key** — a random AES-256-GCM key, one per sealed value, wrapped to the scope.

A **writer** needs only the scope's *public* key — no key of their own. Only **readers** (staff doing a reveal) need a grant of the scope private key, wrapped to their USER key.

## The client ships with your backend

For any project with a `.sealed()` column the compiler emits the E2EE client **into your project** — there's nothing to `npm install`:

```
src/
├─ lib/
│  ├─ seal-routes.ts     # server: the /seal/v1/* reveal rails (verify-only)
│  └─ seal-client/       # client: the browser SDK you import — emitted for you
│     └─ index.ts  sdk.ts  flow.ts  wrap.ts  kdf.ts  envelope.ts  ...
```

`@noble/curves` and `@noble/hashes` are added to your `package.json` automatically. The client is emitted from the *same compiler version* as the server routes, so the wire format (`qbseal:` envelopes, the step-up message, the key-wrap method) can never drift between the two — a class of silent decrypt-time bug that a separately-versioned package would reintroduce.

## Writing a sealed value

The writer seals the plaintext to the scope's public key, then writes the result like any other field. The server stores ciphertext and is none the wiser.

```typescript
import { SealClient } from './lib/seal-client';

const seal = new SealClient(transport);  // transport = POST to /seal/v1/*

// Fetches the scope's PUBLIC key and seals to it — the writer needs no key.
const sealed = await seal.sealField(
  'scope:organization:org_123',
  new TextEncoder().encode('Background check: clear'),
);

// Write it like any field. At rest this is a `qbseal:` sealed-cell envelope.
await api.post('/api/v1/candidates', { name: 'Ada', backgroundCheck: sealed });
```

## Revealing a sealed value

A reveal is a deliberate, audited, **step-up-gated** action — not an ambient read. The SDK runs the whole handshake; you provide the reader's passphrase-derived session:

```typescript
// Derive the reader's keys once from their vault passphrase (NOT their login password).
const session = seal.session(passphrase, enrollment);

const plaintext = await seal.reveal({
  session,
  scopeRef:    'scope:organization:org_123',
  vaultItemId: candidate.id,
});
new TextDecoder().decode(plaintext);   // "Background check: clear"
```

Behind that one call:

1. **`/seal/v1/my-grant`** → the reader's wrapped scope private key.
2. **`/seal/v1/challenge`** → a fresh, single-use nonce bound to this reader + item.
3. The client signs a **step-up proof** over the nonce (Ed25519 from the passphrase, or a passkey assertion whose WebAuthn challenge *is* the nonce).
4. **`/seal/v1/reveal`** verifies the proof, writes an append-only reveal-log entry, and mints a short-lived (≤180s) `scope:seal:reveal` capability.
5. **`/seal/v1/key`** (bearer that capability) passes three fail-closed gates — tenant, capability, live-grant — and returns the still-wrapped content key.
6. The client unwraps the scope key, unwraps the content key, and decrypts. **None of this touches the server with a readable key.**

Passkey reveal is the same call as `revealWithPasskey({ ..., getAssertion })`, where `getAssertion(nonce)` runs the WebAuthn ceremony.

## Rules the compiler enforces

Sealed values are opaque references at rest, so they're fenced off from every surface that would need to read them — these are compile errors:

- **Never** in a view projection, `?search`, or a `masking` rule (there's no plaintext to project, search, or redact — it leaves the system *only* through the reveal rails).
- No `.filterable()` / `.searchable()` / `.unique()` / defaults / `embeddings.fields` (same reasons as the envelope tier).
- The table **must** carry a `q.scope('organization')` column — keys are per-scope.
- Reading a sealed column server-side is a **type error**: the column is branded `$type<SealedValue>()`, an opaque non-string, so `db.select()` on it won't typecheck. The server's structural inability to read it is enforced by `tsc`, not convention.
- Sealed projects **force-enable** KV-backed JWT revocation (so a revoked reader's in-flight reveal token dies); a project with sealed columns and no KV binding **fails to compile**.
- Database provider must be **Cloudflare D1** or **Neon over HTTP/Hyperdrive** — anything else fails to compile. On Neon, the seal/vault tables carry **deny-all row-level security for request connections**: only the worker-internal service-role lane (the generated `/seal/v1` routes, `ctx.seal`, and the sealed write chokepoint) can touch them, and every one of those queries is still explicitly organization-scoped.

## What this tier does and doesn't claim

The sealed tier protects against **full Worker compromise** — leaked secrets, malicious or buggy server code, a coerced operator. All of them get ciphertext; the keys to read it never exist server-side. Its operating model is explicit:

- **A reveal needs a real second factor.** The vault passphrase (or passkey) is separate from login and never leaves the client.
- **Recovery is opt-in and zero-knowledge.** A key admin can enroll a scope key wrapped under a client-held recovery code through `/seal/v1/recovery/enroll`; `/recovery/use` returns only that opaque wrapped blob. If no recovery is enrolled and every reader loses their factor, the data is unrecoverable by design.
- **Rotation includes re-granting.** `/rotate` versions the scope key and returns the live-reader worklist and public keys so the key-admin client can wrap and grant the new private key without exposing it to the server.
- **No server-side processing.** Your actions can't read, mask, search, or index a sealed value — it's plaintext only in a client that just revealed it. If the server needs to process the field, you want the envelope tier instead.
- **Passkey rollout check.** The server verification path and nonce-bound
  WebAuthn contract are implemented; before enabling it for a production
  hostname, run the ceremony with that deployment's real browser,
  authenticator, RP ID, and origin. The passphrase lane is fully round-trip
  tested.
