Encryption envelope
The server never sees item plaintext. It stores an envelope: ciphertext, nonce, and a wrapped-key bundle — one sealed item key per device that should decrypt the row.
Types are defined in App/shared/src/envelope.ts and validated via ItemEnvelopeSchema in shared/src/api.ts.
Primitives (recap)
Section titled “Primitives (recap)”| Step | Algorithm |
|---|---|
| Item body | XChaCha20-Poly1305 (crypto_aead_xchacha20poly1305_ietf_*) |
Wrap K_item to each device | Curve25519 sealed box (crypto_box_seal) |
| Master key from phrase | Argon2id + BIP39 seed |
Full threat model: How encryption works.
Inline envelope (payloadEnc)
Section titled “Inline envelope (payloadEnc)”Stored as JSON in SQLite column payload_enc:
interface ItemEnvelope { ct: string; // base64url ciphertext + poly1305 tag nonce: string; // base64url, 24-byte XChaCha20 nonce keys: Record<DeviceId, string>; // base64url sealed K_item per device pubkey master?: { // secretbox(K_item, masterKey) — any device holding MK can decrypt nonce: string; ct: string; }; sig: string; // base64url item authentication tag (required) — see "Item authentication"}Encryption (client write)
Section titled “Encryption (client write)”K_item = randomBytes(32)nonce = randomBytes(24)- Build AAD as UTF-8 bytes of
type + "|" + ts(binId is not in the AAD — it’s covered bysig) ct = AEAD_Encrypt(K_item, nonce, plaintextJSON, aad)- For each non-revoked device:
keys[deviceId] = crypto_box_seal(K_item, devicePubkey) master = secretbox(K_item, masterKey)sig = crypto_auth(K_mac, canonical(id, binId, type, ts, ct, nonce [, blobRef, blobNonce, blobKeys]))whereK_mac = BLAKE2b(masterKey, "syncbins-item-mac-v1", 32)POST /api/itemswith{ payloadEnc: { ct, nonce, keys, master, sig }, … }
Decryption (client read)
Section titled “Decryption (client read)”- Verify
sigwithK_macover the same canonical fields. Reject the item on mismatch (forged, tampered, or relabelled by the server). K_itemvia themasterwrap (secretbox_openwith MK) orcrypto_box_seal_open(keys[myDeviceId], myPubkey, myPrivkey).- Reconstruct AAD (
type|ts) from row metadata. plaintext = AEAD_Decrypt(K_item, nonce, ct, aad)
A device that holds the master key can always decrypt via the master wrap, even for items created before it was paired. The per-device keys map remains for compatibility.
Blob-backed items
Section titled “Blob-backed items”Large payloads store bytes in object storage; the item row references them:
interface BlobEnvelope { blobRef: string; // opaque name, e.g. ULID.bin nonce: string; // blob encryption nonce keys: Record<DeviceId, string>; // K_blob wrapped like inline}Wire fields on ItemRow: blobRef, blobNonce, blobKeys — with payloadEnc: null.
Flow:
- Encrypt file bytes locally → ciphertext blob
POST /api/blobs/upload-url→ presigned PUT- Upload ciphertext
POST /api/itemswith metadata payload (small JSON inpayloadEnc) or metadata-only inline fields plus blob refs — large media types useblobRefon the row and encrypted metadata inpayloadEncwhen present
Exactly one of inline payloadEnc body or blob path is enforced at the API layer.
Download: GET /api/blobs/:ref/url → presigned GET → decrypt with blobNonce + wrapped key.
Wrapped-key bundle rules
Section titled “Wrapped-key bundle rules”- Every non-revoked paired device must appear in
keyson write (server validates). - New device joins → existing clients re-wrap items they can decrypt (background job in web client).
- Revoked devices are omitted from new writes; old ciphertext may still exist on their disk.
Base64 encoding
Section titled “Base64 encoding”REST API uses standard base64 in JSON (see REST API conventions). Internal crypto helpers may use base64url in browser storage — convert at the HTTP boundary to match zod schemas.
AAD binding & item authentication
Section titled “AAD binding & item authentication”The AEAD AAD is the UTF-8 string type|ts. binId is deliberately not in the
AAD, so an item can move between bins without re-encrypting its body.
Integrity of the metadata and the item’s authenticity come from the sig tag, an
HMAC (crypto_auth, HMAC-SHA-512-256) keyed by a master-key-derived subkey the
server never holds. It covers id, binId, type, ts, ct, nonce (plus blobRef,
blobNonce, and the sorted blobKeys map when the item has a blob). Clients
verify it before decrypting and reject mismatches, so the server cannot forge an
item, relabel its type/timestamp/id, move it between bins, or swap its
attachment. sig is required — items without a valid tag are rejected.
Because sig binds binId, a bin move (POST /api/items/:id/reconcile) carries a
freshly recomputed tag from the moving client (which holds the master key).
Example PUT body (inline link)
Section titled “Example PUT body (inline link)”{ "id": "01JC4MXXXXXXXXXXXXXX", "binId": "inbox", "type": "link", "ts": 1716000000000, "payloadEnc": { "ct": "base64…", "nonce": "base64…", "keys": { "01JB…": "base64…" }, "master": { "nonce": "base64…", "ct": "base64…" }, "sig": "base64…" }}