Sealed protocol (qbseal:1)
The versioned wire format + /seal/v1 lifecycle contract for the SEALED tier (E2EE). Build a conforming client in any language against a published spec and canonical conformance vectors — not by reverse-engineering emitted code.
This is the protocol reference for the SEALED tier — 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. 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 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 |
| 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:
{ "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:
- Generate an ephemeral X25519 keypair
eph. shared = X25519(eph.secret, recipientPub).wrapKey = HKDF-SHA256(ikm = shared, salt = eph.pub ‖ recipientPub, info = "qbseal-box-v1", L = 32).ct = AES-256-GCM(wrapKey, iv, plaintext).- 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. The proof is Ed25519.sign(message, userEd25519Secret), sent base64url.
Envelope wire format
Every sealed blob is qbseal:<version>:<base64url(json)>. Three payload shapes:
// 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 /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.sealclaim for the exactscopeRefcarrying a key-admin-tier scoped role (configurable viaencryption.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 and a key-admin fulfils it via /grant — see role→grant. Minting the key-admin scope claim is done through the normal 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.
// → { 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.
// → { 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.
// → { 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.
// → { vaultItemId, fieldRef?, stepUp: <StepUpProof> }
// ← { revealToken: "<JWT ≤180s>", scope, vaultItemId, expiresIn: 180 }StepUpProof is one of:
{ "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-upGates, 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.
// → { vaultItemId } (Authorization: Bearer <revealToken>)
// ← { wrappedKey: "<qbseal envelope>", ct: "<qbseal content envelope>",
// wrapMethod: "owner" | "scope" | "audience" }The client unwraps per wrapMethod (see /my-grant) 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.
// → { 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).
// → { 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.
// → { 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 captureThe key-admin's client wraps the new scope private key to each regrant[].userPublicKey and calls /grant 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)
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.
// → { scopeRef, userPublicKey: "<b64url X25519>" }
// ← { requested: true, scopeRef }11. /grant-requests — list pending requests (key-admin)
// → { scopeRef }
// ← { scopeRef, requests: [{ userId, userPublicKey, requestedAt }, …] }The key-admin wraps the scope key to each userPublicKey and calls /grant, 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.
// → { 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.
// → { scopeRef }
// ← { scopeRef, keyVersion, wrappedPrivateKey: "<qbseal envelope>" } // 404 parity if nonerole→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 /grants 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 host of env.ACCOUNT_URL |
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)
// Package.swift
.package(url: "https://github.com/paulstenhouse/quickback-compiler.git", branch: "main")
// target dependency: .product(name: "QuickbackSeal", package: "quickback-compiler")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)
// 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) // REVEALPasskey 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. The SDKs are versioned with the compiler in the same repo, so the wire format never drifts from the server you're talking to.
Not yet wired
Honest gaps, fail-closed until built — a client should not assume these exist:
- Content-key re-encryption on rotation —
/rotateversions 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_grantaudience reveals are honored at/reveal//key, but there's no first-class management surface yet.
These will arrive as additive :1 routes (no wire-format break) with their own vectors.
Encryption
Field-level encryption in two tiers — .encrypted() (envelope, per-org keys, server-readable) and .sealed() (end-to-end, the server structurally cannot read it). A database breach, or a full Worker compromise, yields only ciphertext.
Views - Column Level Security
Named field projections with per-view access control. Views live under the read pipeline (read.views) and implement column-level security.