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
Passkey PRF unlock (wrap the user master)HKDF-SHA256 → AES-256-GCMsee Multi-factor unlock

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. On D1 the column is emitted as Drizzle JSON-mode text (text(name, { mode: 'json' })), so the marker is stored as JSON and reads back as the { __vaultRef, vaultItemId } object; the SQL type stays TEXT. (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

Each reveal-log row (requested here, delivered at /key) is also written to the separate security-audit database as an audit_events row with event_kind = 'vault_reveal', action_name = 'vault-reveal:<phase>', resource = 'vault_items', operation = 'reveal' | 'key', the actor, org, and vaultItemId, and metadata = { scopeRef, reauthMethod, ip, userAgent }. No key material and no before-image. The write is fail-closed: if the audit binding is missing or the insert throws, no token is minted and no key is returned. A sealed column therefore always provisions the security-audit pipeline (AUDIT_DB on D1, the audit schema on Postgres), even with no unsafe actions or hard deletes.

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 factor (key-admin)

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

wrapMethod names the client-side wrap and defaults to recovery-code. One live recovery per (scope key version, wrapMethod), so a passkey-wrapped recovery can sit next to the recovery code; re-enrolling the same method on the same key version is a 409 until it is revoked or the key rotates. wrapMeta is an optional JSON object the server stores verbatim and hands back at /recovery/use (public wrap material only — a credential id, salts, a label; capped at 4 KiB). The server never reads either field.

wrapMethodThe blob is…
recovery-code (default)the scope key wrapped to a key derived from a recovery code
prf-aesgcmthe scope key wrapped under a KEK derived from a WebAuthn PRF output (a hardware security key or a shared vault passkey); wrapMeta carries the credential id and salts the client needs to re-derive the KEK
// → { scopeRef, wrappedPrivateKey: "<qbseal envelope>", wrapMethod?: "recovery-code" | "prf-aesgcm", wrapMeta?: { … } }
// ← { enrolled: true, scopeRef, keyVersion: 1, wrapMethod: "recovery-code" }

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

Returns the live recovery-wrapped scope key for the newest key version that has one (and appends a recovery entry to the reveal log). The client unwraps it with its recovery factor to recover the scope private key, then re-grants readers. The returned blobs are useless without the factor — the server gates who may fetch them, the factor gates whether one can be unwrapped.

Without wrapMethod, every live method for that key version rides along in recoveries, so a client can offer "use your security key or enter the recovery code" from one call; the top-level fields are the recovery-code row when one exists (wire-compatible with clients that predate wrapMethod). Pass wrapMethod to narrow to one method; 404 parity when none of that method is live.

// → { scopeRef, wrapMethod?: "recovery-code" | "prf-aesgcm" }
// ← { scopeRef, keyVersion, wrappedPrivateKey: "<qbseal envelope>", wrapMethod, wrapMeta,
//     recoveries: [{ wrapMethod, wrapMeta, wrappedPrivateKey }, …] }   // 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 registrable host of env.ACCOUNT_URL, else the compile-time account origin (both emissions share one resolution, so they cannot skew)
userVerificationrequired (requireUserVerification: true — a reveal is a deliberate re-auth)
originany of: the resolved account origin (mirrors the passkey({ rpID, origin }) plugin), https://<rpID> (what an Apple associated-domain app asserts), and every entry of encryption.seal.extraOrigins (e.g. android:apk-key-hash:<sha256>)

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.

Multi-factor unlock (passkey PRF)

The passphrase is one way to reach the USER keys; a passkey with the WebAuthn PRF extension is the other. The two converge on one secret — the 32-byte user master (the raw Argon2id output, before the HKDF split) — so either factor yields byte-identical keys:

master  = Argon2id(passphrase, salt, params)          deriveUserMaster
keys    = HKDF-split(master)                           userKeysFromMaster   (ed25519 / x25519, as above)

A PRF-capable passkey evaluates a per-credential pseudo-random function inside the authenticator, released only after user verification. The client wraps the master under a key derived from that output and stores the wrapped blob next to the enrollment. At reveal time one ceremony both proves the step-up (the assertion) and unlocks the keys (the PRF output) — no passphrase prompt. The server stores the blob as opaque ciphertext: it never sees the PRF output, the KEK, or the master.

Derivation

prfSalt = SHA-256( utf8("qbseal-prf-salt-v1") ‖ utf8(orgId) )           ← extensions.prf.eval.first
prf     = PRF(credential, prfSalt)                                        ← 32 bytes, from the authenticator

binding = utf8(userId) ‖ 0x00 ‖ utf8(orgId) ‖ 0x00 ‖ utf8(credentialId)

KEK     = HKDF-SHA256( ikm = prf, salt = kekSalt,
                       info = utf8("qbseal-prf-kek-v1") ‖ binding, L = 32 )
ct      = AES-256-GCM( KEK, iv, plaintext = master, AAD = binding )
  • prfSalt is per org, so one user's PRF outputs across orgs are unrelated. Pass the same salt at registration and every assertion.
  • credentialId is the passkey's base64url credential id string exactly as WebAuthn returns it (PublicKeyCredential.id); it is never re-encoded. The envelope carries it verbatim so a client can pick the matching passkey before the ceremony.
  • kekSalt is 32 fresh random bytes and iv 12; both are stored in the envelope. The (userId, orgId, credentialId) binding is in both the KEK info and the AAD, so a blob lifted from another user, org, or credential fails on the GCM tag. Open fails closed on any mismatch.

Envelope

A qbseal:1 envelope with a new alg — the wire version is unchanged and every existing decoder keeps accepting it structurally:

{ "alg": "prf-aesgcm",
  "cred": "<credentialId, verbatim>",
  "salt": "<base64url kekSalt, 32 bytes>",
  "iv":   "<base64url, 12 bytes>",
  "ct":   "<base64url AES-GCM ciphertext ‖ tag of the 32-byte master>" }

Client usage

import { deriveUserMaster, prfSaltFor, wrapMasterWithPrf, SealClient } from './lib/seal-client';

// ENROLL the passkey as a second factor (once, with the passphrase in hand).
const master = deriveUserMaster(passphrase, salt, enrollment.argon2);
const reg = await startAuthentication({ optionsJSON: {
  ...options, extensions: { prf: { eval: { first: prfSaltFor(orgId) } } },
} });
const prf = new Uint8Array(reg.clientExtensionResults.prf!.results!.first);
const wrapped = await wrapMasterWithPrf({ master, prf, userId, orgId, credentialId: reg.id });
// store `wrapped` (opaque) alongside the user's enrollment

// REVEAL with the passkey only — no passphrase prompt.
const seal = new SealClient(transport);
const plaintext = await seal.revealWithPasskey({
  scopeRef, vaultItemId,
  getAssertion: async (nonce) => {
    const a = await startAuthentication({ optionsJSON: {
      challenge: nonce, rpId, userVerification: 'required',
      extensions: { prf: { eval: { first: prfSaltFor(orgId) } } },
    } });
    return { assertion: a, prf: new Uint8Array(a.clientExtensionResults.prf!.results!.first) };
  },
  prfUnwrap: { wrapped, userId, orgId, credentialId, argon2: enrollment.argon2 },
});

getAssertion may still return the raw AuthenticationResponseJSON with a passphrase-derived session — the original signature is unchanged. When prfUnwrap is given and no session, the SDK opens the blob with the assertion's PRF output (unwrapMasterWithPrf), builds the session from the master (sessionFromMaster), and completes the unwrap; it fails closed if the assertion carried no PRF output. Only the assertion is sent to /reveal — the PRF output never leaves the client. The Swift (KDF.deriveUserMaster, KDF.userKeysFromMaster, Prf) and Kotlin (Kdf.deriveUserMaster, Kdf.userKeysFromMaster, Prf) SDKs expose the same primitives, vector-locked to the TS reference.

Compiler support

  • With encryption.seal.passkeyPrf: true the emitted passkey({ rpID, origin }) plugin call adds registration: { extensions: { prf: {} } }, so sign-in passkeys are PRF-capable at creation and the same credential serves as the unlock factor — no second enrollment ceremony. It is off by default because it changes the registration ceremony for every passkey the project creates; authenticators without PRF ignore the flag. Creation-time PRF evaluation is optional in WebAuthn, so clients should still run one assertion with prf.eval after registration to obtain the output they wrap with.
  • Reveal-log honesty. After a PRF unlock, follow-up reveals inside the client's unlock window present the Ed25519 passphrase-kind proof (the key came from the unwrapped master, not from a passphrase). The server records the proof kind it verified; it cannot see how the client obtained the key. A client may declare stepUp.unlockedBy: 'passkey' | 'passphrase' as an unverifiable hint, which the server records verbatim next to reauthMethod — treat it as telemetry, never as evidence.
  • The reveal verify accepts assertions from native apps as well as the browser. expectedOrigin is the account origin, https://<rpID> (the origin Apple's ASAuthorization asserts for an associated-domain app), and anything listed in encryption.seal.extraOrigins — the home for Android's android:apk-key-hash:<sha256>:
// quickback.config.ts (fragment)
encryption: {
  seal: {
    keyAdminRoles: ['owner', 'admin'],
    extraOrigins: ['android:apk-key-hash:pNiP5iKyQ8JwgGOaKA1zGPUB3eTSh-f-5d7HsAwBGFQ'],
  },
},

Entries must be non-empty strings without whitespace; duplicates are dropped. A browser origin other than the account origin cannot be smuggled in — the passkey plugin's own registration check already refuses credentials from it, so listing it here widens nothing.

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
masterSplitpassphrase → the 32-byte master; master → the kdf public keysfull
prfWrapprfSaltFor(orgId); pinned prf + kekSalt + iv + ids → the prf-aesgcm envelope; a wrongCredentialId that must NOT open itfull (wrap is pinned by fixed salt/iv)

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

On this page