Skip to content

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.

StepAlgorithm
Item bodyXChaCha20-Poly1305 (crypto_aead_xchacha20poly1305_ietf_*)
Wrap K_item to each deviceCurve25519 sealed box (crypto_box_seal)
Master key from phraseArgon2id + BIP39 seed

Full threat model: How encryption works.

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"
}
  1. K_item = randomBytes(32)
  2. nonce = randomBytes(24)
  3. Build AAD as UTF-8 bytes of type + "|" + ts (binId is not in the AAD — it’s covered by sig)
  4. ct = AEAD_Encrypt(K_item, nonce, plaintextJSON, aad)
  5. For each non-revoked device: keys[deviceId] = crypto_box_seal(K_item, devicePubkey)
  6. master = secretbox(K_item, masterKey)
  7. sig = crypto_auth(K_mac, canonical(id, binId, type, ts, ct, nonce [, blobRef, blobNonce, blobKeys])) where K_mac = BLAKE2b(masterKey, "syncbins-item-mac-v1", 32)
  8. POST /api/items with { payloadEnc: { ct, nonce, keys, master, sig }, … }
  1. Verify sig with K_mac over the same canonical fields. Reject the item on mismatch (forged, tampered, or relabelled by the server).
  2. K_item via the master wrap (secretbox_open with MK) or crypto_box_seal_open(keys[myDeviceId], myPubkey, myPrivkey).
  3. Reconstruct AAD (type|ts) from row metadata.
  4. 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.

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:

  1. Encrypt file bytes locally → ciphertext blob
  2. POST /api/blobs/upload-url → presigned PUT
  3. Upload ciphertext
  4. POST /api/items with metadata payload (small JSON in payloadEnc) or metadata-only inline fields plus blob refs — large media types use blobRef on the row and encrypted metadata in payloadEnc when 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.

  • Every non-revoked paired device must appear in keys on 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.

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.

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

{
"id": "01JC4MXXXXXXXXXXXXXX",
"binId": "inbox",
"type": "link",
"ts": 1716000000000,
"payloadEnc": {
"ct": "base64…",
"nonce": "base64…",
"keys": {
"01JB…": "base64…"
},
"master": { "nonce": "base64…", "ct": "base64…" },
"sig": "base64…"
}
}