# Sealed protocol (qbseal:1)

This is the **protocol reference** for the [SEALED tier](/define/encryption#sealed-tier--end-to-end-encryption) — the on-the-wire contract a client speaks to the generated `/seal/v1` surface. Quickback ships first-party clients — TypeScript (emitted into your project), **Swift, and Kotlin** — so you usually don't implement this yourself; jump to [Client SDKs](#client-sdks). The format is also **language-agnostic and versioned**, so you can build a Go or server-side client against this spec and the [conformance vectors](#conformance-vectors) instead of reading emitted source.

> **Stability.** The wire version is **`qbseal:1`**. The version is carried *in every envelope* (`qbseal:1:…`). `qbseal:1` is stable: no field is removed or repurposed within a major version. A breaking change ships as `qbseal:2` — a new prefix a `:1` decoder rejects fail-closed, never a silent change at decrypt time. New optional fields may be added within `:1` only if older readers ignore them safely. A server that needs to retire `:1` does so by re-sealing data to `:2` and announcing a deprecation window; it never reinterprets a `:1` envelope.


## 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
```

| Key | Lives | Derivation |
|---|---|---|
| **USER** | client only | `Argon2id(vault passphrase, salt)` → HKDF-split into an Ed25519 signing key + an X25519 wrap key. **Never** the login password. |
| **SCOPE** | private half wrapped per-user; public half on the server | random X25519 keypair, one per scope (per org). |
| **Content (CK)** | wrapped to the scope | random AES-256-GCM key, one per sealed value. |

A **writer** needs only the scope *public* key. A **reader** needs a grant of the scope private key, wrapped to their USER key.

## Crypto primitives

All standard; no hand-rolled constructions. A conforming port maps each to its platform library (CryptoKit, BouncyCastle, WebCrypto, …).

| Purpose | Primitive | Parameters |
|---|---|---|
| USER-key KDF | **Argon2id** | floor below; output 32 bytes |
| KDF domain split | **HKDF-SHA256** | `info = "qbseal-ed25519-v1"` and `"qbseal-x25519-v1"`, no salt, L=32 |
| Signing key | **Ed25519** | seed = HKDF ed25519 output |
| Wrap key | **X25519** | seed = HKDF x25519 output |
| Step-up proof | **Ed25519** sign/verify | over the [step-up message](#step-up-message) |
| Sealed box (wrap a key to a pubkey) | **X25519 ECDH → HKDF-SHA256 → AES-256-GCM** | see below |
| Content / key encryption | **AES-256-GCM** | 12-byte IV, 16-byte tag appended to ciphertext |

### Argon2id floor

The server **rejects** enrollment params below this floor (a weak-KDF verifier is offline-brute-forceable, and the public key is stored). These are the OWASP Argon2id minimums:

```json
{ "memoryKiB": 19456, "iterations": 2, "parallelism": 1 }
```

A client MAY choose stronger params; it MUST send the params it used (the server re-derives nothing — it only verifies the Ed25519 proof and stores the params for the client to re-derive at reveal time).

### Sealed box

Wrapping a key (a content key, or a scope private key) to an X25519 public key — the analogue of libsodium's `crypto_box_seal`:

1. Generate an ephemeral X25519 keypair `eph`.
2. `shared = X25519(eph.secret, recipientPub)`.
3. `wrapKey = HKDF-SHA256(ikm = shared, salt = eph.pub ‖ recipientPub, info = "qbseal-box-v1", L = 32)`.
4. `ct = AES-256-GCM(wrapKey, iv, plaintext)`.
5. Envelope: `{ alg: "x25519-aesgcm", eph, iv, ct }` (all base64url).

To open: recompute `shared = X25519(recipientSecret, eph.pub)`, re-derive `wrapKey` (salt uses the same `eph.pub ‖ recipientPub`), AES-GCM-decrypt.

### Step-up message

The message an Ed25519 step-up proof signs. Each field is **independently SHA-256-hashed then joined** — fixed-width, delimiter-free, so the `scope:<kind>:<id>` colons in `scopeRef` can't be re-segmented to forge a proof for a different target:

```
message = utf8(
  "qbseal-stepup:v1:" +
  b64url(sha256(utf8(scopeRef))) + ":" +
  b64url(sha256(utf8(vaultItemId))) + ":" +
  b64url(sha256(utf8(nonce)))
)
```

The `nonce` is the server-issued [challenge](#3-challenge--issue-a-step-up-nonce-any-member). The proof is `Ed25519.sign(message, userEd25519Secret)`, sent base64url.

## Envelope wire format

Every sealed blob is `qbseal:<version>:<base64url(json)>`. Three payload shapes:

```jsonc
// a key wrapped to an X25519 public key
{ "alg": "x25519-aesgcm", "eph": "<b64url>", "iv": "<b64url>", "ct": "<b64url>" }

// content encrypted under a content key
{ "alg": "aes-256-gcm", "iv": "<b64url>", "ct": "<b64url>" }

// a SEALED CELL — the compound value written into a .sealed() column.
// The server STRUCTURALLY SPLITS this (decode only, never decrypts) into
// vault_items.ct (content) + wrapped_ck_owner (wrappedCk).
{ "alg": "sealed-cell",
  "content":   { "alg": "aes-256-gcm",  ... },
  "wrappedCk": { "alg": "x25519-aesgcm", ... } }
```

The decoder rejects a non-`qbseal:` string, an unknown version, an envelope over 64 KiB, or a malformed body — all fail-closed.

### The `SealedValue` type brand (server-side code)

A `.sealed()` / `q.vaultRef()` column is typed as an opaque **`SealedValue`**
brand in the generated Drizzle schema, not `string`. Any server-side code that
tries to *use* the value as a string — `JSON.parse(row.backgroundCheck)`,
`.length`, string interpolation — is a **TypeScript compile error**, on reads
and on `.returning()` alike. That's the type-level face of "the server is
structurally unable to read it": at rest the column only ever holds a
`{ __vaultRef }` marker, and the brand keeps action code from string-processing
or hand-forwarding that marker. Sealed values leave the system exclusively
through the `/seal/v1` reveal rails below. (Envelope `.encrypted()` columns are
deliberately *not* branded — they type as `string` because the server can
decrypt that tier; an action reading one gets `qbenc:` ciphertext.)

## The `/seal/v1` lifecycle

All routes are `POST`, JSON in/out, mounted at `/seal/v1`, and require an authenticated session with an **active organization** (`requireOrg` → 401/403). Errors use the standard Quickback error envelope. Most write routes accept `"dryRun": true` to validate + preview without persisting.

`scopeRef` grammar is `scope:<kind>:<id>` (org/event/relationship) or `user:<id>`; the server validates it (`assertRefGrammar`).

### Key-admin authorization (who may enroll / grant / rotate / revoke)

`enroll`, `grant`, `rotate`, and `revoke` are gated by **`hasKeyAdminForScope`**:

- `userRole === "sysadmin"` always passes; **or**
- a verified `ctx.scope.seal` claim **for the exact `scopeRef`** carrying a key-admin-tier scoped role (configurable via `encryption.seal.keyAdminRoles`; the literal `"key-admin"` scoped role always qualifies).

Scoped roles live **only** on `ctx.scope.seal`, never `ctx.roles` — a forged `ctx.roles` entry can't satisfy the gate. There is **no automatic** role→grant: the server holds no key material, so it cannot wrap the scope key to a new reader itself. Instead a reader self-publishes their public key via [`/grant-request`](#10-grant-request--request-access-to-a-scope-any-member) and a key-admin fulfils it via [`/grant`](#7-grant--enroll-a-reader--store-their-wrapped-scope-key-key-admin) — see [role→grant](#rolegrant-readers-request-key-admins-fulfil). Minting the key-admin scope claim is done through the normal [scopes](/define/scopes) machinery — that policy (which role becomes key-admin for which scope) is yours to declare.

---

### 1. `/scope-key` — get a scope's public key (writers)

Any tenant member who can write the scope needs it. Returns the **current** (highest `keyVersion`) public key.

```jsonc
// → { scopeRef }
// ← { scopeRef, publicKey: "<b64url X25519>", keyVersion: 1 }
```
404 (existence-oracle parity) if the scope is unknown *or* has no key yet.

### 2. `/enroll` — create a scope's seal key (key-admin)

Trust-on-first-use: **first write wins**, never overwrites. Persists `seal_keys` (the scope public key). `organizationId` is stamped server-side.

```jsonc
// → { scopeRef, publicKey: "<b64url X25519>" }   // client generated the keypair
// ← { enrolled: true, scopeRef, keyVersion: 1 }
```
409 if a key already exists for the scope (use `/rotate`).

### 3. `/challenge` — issue a step-up nonce (any member)

A single-use, **(issuing-user, scopeRef, vaultItemId)-bound** nonce, stored in KV (`expirationTtl` **120 s**). Bound to the item's owning scopeRef so it can only answer a reveal for *that* item; bound to the issuing user so another user can't burn it.

```jsonc
// → { vaultItemId }
// ← { nonce: "<b64url 32B>", scopeRef, vaultItemId, expiresIn: 120 }
```
503 fail-closed if no KV binding (sealed deployments require it).

### 4. `/reveal` — prove step-up, mint a reveal token (reader)

Verifies the step-up proof, writes a **committed** append-only reveal-log row, then mints a short-lived capability. The proof is consumed (single-use); the token is **not** the key.

```jsonc
// → { vaultItemId, fieldRef?, stepUp: <StepUpProof> }
// ← { revealToken: "<JWT ≤180s>", scope, vaultItemId, expiresIn: 180 }
```
`StepUpProof` is one of:
```jsonc
{ "kind": "passphrase", "nonce": "<from /challenge>",
  "argon2": { "proof": "<b64url Ed25519 sig over the step-up message>",
              "memoryKiB": 19456, "iterations": 2, "parallelism": 1 } }

{ "kind": "passkey", "nonce": "<from /challenge>",
  "assertion": <WebAuthn AuthenticationResponseJSON> }   // see Passkey step-up
```
Gates, all fail-closed and in order: **(a)** tenant load, **(b)** any presented `ctx.scope.seal` claim must match the item's scopeRef, **(c)** structural step-up (single-use nonce, bound message), **(d)** `assertGrantLive` (D1 read — owner reveal needs no grant; otherwise a live `seal_key_grant` for the scope, or an audience `vault_grant`).

### 5. `/key` — fetch the wrapped key (bearer the reveal token)

The **separate** post-mint route (never folded into `/reveal`). Re-runs three independent gates — tenant, capability (`ctx.scope.seal` only), `assertGrantLive` (D1, **prior to and independent of** any KV revocation check, which fails *open*) — writes a committed `delivered` reveal-log row, and returns the still-wrapped material.

```jsonc
// → { vaultItemId }   (Authorization: Bearer <revealToken>)
// ← { wrappedKey: "<qbseal envelope>", ct: "<qbseal content envelope>",
//     wrapMethod: "owner" | "scope" | "audience" }
```
The client unwraps per `wrapMethod` (see [`/my-grant`](#6-my-grant--the-callers-own-wrapped-scope-private-key-reader)) and decrypts `ct`. **No readable key ever touches the server.**

### 6. `/my-grant` — the caller's own wrapped scope private key (reader)

For `wrapMethod: "scope"`, the client first unwraps the scope private key with its USER key, then the CK. User-scoped; 404 if not granted.

```jsonc
// → { scopeRef }
// ← { wrappedPrivateKey: "<qbseal envelope>", wrapMethod: "scope" }
```

### 7. `/grant` — enroll a reader + store their wrapped scope key (key-admin)

The combined **enrollment + grant** endpoint. The server **never wraps** — it persists the client-supplied opaque blob. Exactly one live grant per `(scopeRef, userId)` (it revokes any prior live grant first).

```jsonc
// → { scopeRef, userId, wrappedPrivateKey: "<qbseal envelope>", wrapMethod: "scope",
//     wrapMeta?,
//     // OPTIONAL passphrase-reveal enrollment (the step-up verifier):
//     stepUp?: { kind: "passphrase", publicKey: "<b64url Ed25519>",
//                salt: "<b64url>", argon2: { memoryKiB, iterations, parallelism } } }
// ← { granted: true, scopeRef, userId, stepUpEnrolled: boolean }
```
`stepUp.argon2` is floor-checked **at enrollment** (a weak verifier is rejected here, not just at reveal). The `stepUpPublicKey` + params are stored zero-knowledge; the passphrase never leaves the client.

### 8. `/revoke` — sever future access (key-admin)

Two forms: `{ scopeRef, userId }` (revoke a scope-key grant) or `{ vaultItemId, audienceRef }` (revoke an audience grant). Severs the future D1 key-fetch **and** kills in-flight tokens via KV revocation. It does **not** recall an already-unwrapped key — the response surfaces `rotationRecommended` when that matters.

### 9. `/rotate` — add a new key version + re-grant worklist (key-admin)

Append-only `INSERT` of a new `seal_keys` version, and returns the **re-grant worklist** — the live readers and their X25519 public keys.

```jsonc
// → { scopeRef, publicKey: "<new b64url X25519>" }   // client generated the new keypair
// ← { rotated: true, scopeRef, keyVersion: 2,
//     regrant: [{ userId, userPublicKey }, …],   // re-wrap the NEW scope key to each
//     missingPublicKey: [userId, …] }            // readers granted before key capture
```
The key-admin's client wraps the **new** scope private key to each `regrant[].userPublicKey` and calls [`/grant`](#7-grant--enroll-a-reader--store-their-wrapped-scope-key-key-admin) per reader (which revokes the stale grant and inserts the fresh one). The server holds no key material, so it can only hand back the wrap targets — it never re-wraps. Older `seal_keys` versions remain readable, so existing sealed values keep decrypting until re-sealed. Readers in `missingPublicKey` re-request via [`/grant-request`](#10-grant-request--request-access-to-a-scope-any-member).

### 10. `/grant-request` — request access to a scope (any member)

A reader who has a role for a scope but no grant publishes their X25519 USER public key. Requesting only publishes public material + intent; **fulfilling is the key-admin-gated action**. One pending request per `(scope, user)`; a fresh request replaces the pending key.

```jsonc
// → { scopeRef, userPublicKey: "<b64url X25519>" }
// ← { requested: true, scopeRef }
```

### 11. `/grant-requests` — list pending requests (key-admin)

```jsonc
// → { scopeRef }
// ← { scopeRef, requests: [{ userId, userPublicKey, requestedAt }, …] }
```
The key-admin wraps the scope key to each `userPublicKey` and calls [`/grant`](#7-grant--enroll-a-reader--store-their-wrapped-scope-key-key-admin), which **auto-resolves** the matching pending request.

### 12. `/recovery/enroll` — escrow the scope key under a recovery code (key-admin)

A key-admin (holding the scope private key) wraps it to a key derived from a **recovery code** and escrows the blob. The server stores only the opaque recovery-wrapped material — the recovery code, held by the client, is what makes it usable; the server can never unwrap it. One live recovery per scope key version.

```jsonc
// → { scopeRef, wrappedPrivateKey: "<qbseal envelope, scope key wrapped to the recovery key>" }
// ← { enrolled: true, scopeRef, keyVersion: 1 }
```

### 13. `/recovery/use` — retrieve the recovery blob (key-admin)

Returns the live recovery-wrapped scope key (and appends a `recovery` entry to the reveal log). The client unwraps it with the recovery-code key to recover the scope private key, then re-grants readers. The returned blob is **useless without the recovery code** — the server gates *who* may fetch it, the code gates whether it can be unwrapped.

```jsonc
// → { scopeRef }
// ← { scopeRef, keyVersion, wrappedPrivateKey: "<qbseal envelope>" }   // 404 parity if none
```

#### role→grant: readers request, key-admins fulfil

Because the server holds no key material, it cannot mint a grant when a user gains a role. The supported pattern: the reader calls `/grant-request` (publishing their public key); a key-admin lists pending requests with `/grant-requests`, wraps the scope key to each, and `/grant`s them — which auto-resolves the request. Who is allowed to *request* (the "role" half) is your access policy; the cryptographic *fulfilment* is always key-admin-gated.

## Passkey step-up

When the passkey plugin is enabled, `/reveal` verifies a WebAuthn assertion whose **challenge is the server nonce** (not Better Auth's login challenge). The native ceremony must produce an `AuthenticationResponseJSON` (the `@simplewebauthn/browser` `startAuthentication` shape) for these expectations:

| Field | Value |
|---|---|
| `challenge` | the `/challenge` `nonce` **as the base64url string** (the client signs the b64url text, the server passes `expectedChallenge = nonce`) |
| `rpID` | the passkey plugin's `rpID` — i.e. `crossSubDomainCookies.domain` (leading dot stripped) when configured, else the registrable host of `env.ACCOUNT_URL`, else the compile-time account origin (both emissions share one resolution, so they cannot skew) |
| `userVerification` | **required** (`requireUserVerification: true` — a reveal is a deliberate re-auth) |
| origin | the resolved account origin (mirrors the `passkey({ rpID, origin })` plugin) |

The credential is looked up in the **auth** database (`passkey` table) and the signature counter is clone-checked. Conformance for passkey is **verify-based** at the server; the client's job is to bind `challenge = nonce` exactly.

## Conformance vectors

Canonical vectors live at **`packages/seal-client/vectors/qbseal-v1.json`**, generated from the reference TS SDK (`bun packages/seal-client/scripts/generate-vectors.ts`) and self-validated against it (`src/vectors.test.ts`). Every input (passphrase, salt, key seeds, IVs, ephemerals) is fixed, so the file is byte-stable and each vector reproduces from its inputs alone.

| Group | Pins | Determinism |
|---|---|---|
| `kdf` | passphrase + salt + params → Ed25519/X25519 public keys | full |
| `stepUpMessage` | `(scopeRef, vaultItemId, nonce)` → signed message | full |
| `stepUpSign` | a signature that **verifies** against the message + pubkey | verify-based (signing may be randomized) |
| `aesGcm` | key + IV + plaintext → ciphertext (+tag) | full |
| `sealedBoxOpen` | recipient secret + box → plaintext | open-direction (canonical) |
| `reveal` | passphrase → keys; owner + scope unwrap paths → plaintext | open-direction (canonical) |
| `envelope` | `qbseal:1:` sealed-cell decodes structurally | full |

A port verifies the open-direction vectors exactly, and proves its *seal* direction by round-tripping through its own open. Vectors are the **cross-language byte-for-byte guarantee** — CI runs every client (TypeScript, Swift, and Kotlin) against this one file and fails on drift.

## Client SDKs

You don't have to implement the crypto. Quickback ships first-party clients, each gated in CI against the conformance vectors above — so they agree byte-for-byte with the server and with each other.

| Platform | Package | Distribution | Crypto |
|---|---|---|---|
| Web / Worker / Node | `@quickback-dev/seal-client` | **emitted** into your project at `src/lib/seal-client/` (for any project with a `.sealed()` column) | `@noble` + WebCrypto |
| iOS / macOS | **`QuickbackSeal`** | SwiftPM — `apps/seal-sdk/swift` | CryptoKit + vendored Argon2 |
| JVM / Android | **`dev.quickback.seal`** | Gradle — `apps/seal-sdk/kotlin` | BouncyCastle |

All three expose the same shape: a `SealClient` over a transport you implement on your HTTP client (URLSession / OkHttp / Ktor / fetch). The transport just POSTs to `/seal/v1/*`; the SDK does the crypto and the reveal handshake.

### Swift (iOS / macOS)

```swift
// Package.swift
.package(url: "https://github.com/paulstenhouse/quickback-compiler.git", branch: "main")
// target dependency: .product(name: "QuickbackSeal", package: "quickback-compiler")
```
```swift
import QuickbackSeal

let seal = SealClient(transport: myTransport)               // POSTs to /seal/v1/*
let sealed = try await seal.sealField(scopeRef: scope,      // WRITE — no key needed
                                      plaintext: Data("Background check: clear".utf8))

let session = try seal.session(passphrase: vaultPass, saltB64url: salt, argon2: .defaults)
let plaintext = try await seal.reveal(session: session, scopeRef: scope, vaultItemId: id)
```

### Kotlin (JVM / Android)

```kotlin
// build.gradle.kts — composite build or a published artifact from apps/seal-sdk/kotlin
import dev.quickback.seal.SealClient

val seal = SealClient(myTransport)
val sealed = seal.sealField(scope, "Background check: clear".toByteArray())   // WRITE

val session = seal.session(vaultPass, saltB64url, Argon2Params.DEFAULTS)
val plaintext = seal.reveal(session, scope, vaultItemId)                       // REVEAL
```

Passkey reveal is the same call as `revealWithPasskey(…, getAssertion)`, where `getAssertion(nonce)` runs the platform WebAuthn ceremony with `challenge = nonce`. Full details, the transport contract, and the conformance harness live in [`apps/seal-sdk/README`](https://github.com/paulstenhouse/quickback-compiler/tree/main/apps/seal-sdk). The SDKs are versioned **with** the compiler in the same repo, so the wire format never drifts from the server you're talking to.

## Current lifecycle boundary

The protocol already includes versioned rotation, a reader re-grant worklist,
grant requests, and recovery-code escrow. Two management conveniences remain
outside the current contract:

- **Content-key re-encryption on rotation** — `/rotate` versions the scope key and re-grants *readers*, and older key versions stay readable, so nothing breaks. But existing sealed *values* keep their content keys wrapped to the old scope key until a client re-seals them; there's no bulk re-encrypt-and-crypto-shred-the-old-version flow yet.
- **Audience grants beyond reveal** — `vault_grant` audience reveals are honored at `/reveal`/`/key`, but there's no first-class management surface yet.

Neither is required for ordinary seal, reveal, grant, revoke, recovery, or key
rotation flows. Future management routes can be added under the versioned
protocol without changing the `qbseal:1` wire format.
