Quickback Docs

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
KeyLivesDerivation
USERclient onlyArgon2id(vault passphrase, salt) → HKDF-split into an Ed25519 signing key + an X25519 wrap key. Never the login password.
SCOPEprivate half wrapped per-user; public half on the serverrandom X25519 keypair, one per scope (per org).
Content (CK)wrapped to the scoperandom 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, …).

PurposePrimitiveParameters
USER-key KDFArgon2idfloor below; output 32 bytes
KDF domain splitHKDF-SHA256info = "qbseal-ed25519-v1" and "qbseal-x25519-v1", no salt, L=32
Signing keyEd25519seed = HKDF ed25519 output
Wrap keyX25519seed = HKDF x25519 output
Step-up proofEd25519 sign/verifyover the step-up message
Sealed box (wrap a key to a pubkey)X25519 ECDH → HKDF-SHA256 → AES-256-GCMsee below
Content / key encryptionAES-256-GCM12-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:

  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. 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 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 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-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.

// → { 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 capture

The 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 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 /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:

FieldValue
challengethe /challenge nonce as the base64url string (the client signs the b64url text, the server passes expectedChallenge = nonce)
rpIDthe passkey plugin's rpID — i.e. crossSubDomainCookies.domain (leading dot stripped) when configured, else the host of env.ACCOUNT_URL
userVerificationrequired (requireUserVerification: true — a reveal is a deliberate re-auth)
originthe 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.

GroupPinsDeterminism
kdfpassphrase + salt + params → Ed25519/X25519 public keysfull
stepUpMessage(scopeRef, vaultItemId, nonce) → signed messagefull
stepUpSigna signature that verifies against the message + pubkeyverify-based (signing may be randomized)
aesGcmkey + IV + plaintext → ciphertext (+tag)full
sealedBoxOpenrecipient secret + box → plaintextopen-direction (canonical)
revealpassphrase → keys; owner + scope unwrap paths → plaintextopen-direction (canonical)
envelopeqbseal:1: sealed-cell decodes structurallyfull

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.

PlatformPackageDistributionCrypto
Web / Worker / Node@quickback-dev/seal-clientemitted into your project at src/lib/seal-client/ (for any project with a .sealed() column)@noble + WebCrypto
iOS / macOSQuickbackSealSwiftPM — apps/seal-sdk/swiftCryptoKit + vendored Argon2
JVM / Androiddev.quickback.sealGradle — apps/seal-sdk/kotlinBouncyCastle

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)                       // 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. 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 revealvault_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.

On this page