Skip to content

How encryption works

Everything you save in SyncBins is encrypted on your device before it ever touches the server. The server stores scrambled bytes — it can’t read your passwords, notes, or files. Even if the server were compromised, your data stays private.

This page explains how that works, step by step. You don’t need to read it to use SyncBins — but if you’re the kind of person who wants to know what’s under the hood, this is for you.

SyncBins only uses well-vetted, NaCl-family primitives — there’s no custom crypto. Every primitive in the table below ships in libsodium and is exposed through libsodium-wrappers-sumo on the web client and server.

UsePrimitiveLibrary call
Symmetric encryption (items, blobs)XChaCha20-Poly1305crypto_aead_xchacha20poly1305_ietf_*
Key wrapping (K_item → device)Curve25519 sealed boxcrypto_box_seal / _open
Master-key derivation (recovery phrase)Argon2id (t=3, m=64 MiB)crypto_pwhash
Passphrase → KEK (wraps master key)Argon2id (t=3, m=256 MiB)crypto_pwhash
PIN key stretchingArgon2id (t=4, m=64 MiB)crypto_pwhash
Recovery phraseBIP39 128-bit entropy@scure/bip39
Pairing shared secretBLAKE2bcrypto_generichash
Pairing attestationHMAC-SHA512-256crypto_auth / _verify
Item authentication (sig)HMAC-SHA512-256 (MK-derived key)crypto_auth / _verify
Device reauth challengeCurve25519 sealed boxcrypto_box_seal / _open
Session tokensSHA-256 (server-side hash)Node crypto.createHash
Blob signed URLsHMAC-SHA256Node crypto.createHmac

During onboarding we generate 128 bits of entropy and encode it as a BIP39 12-word phrase. The phrase is the only thing you have to back up.

crypto/phrase.ts (client-side, simplified)
// 12-word phrase → master key — never sent to server
const seed = bip39ToSeed(phrase); // 64 bytes (PBKDF2-HMAC-SHA512)
const salt = blake2b("SyncBins/v1/master-salt", 16); // fixed 16-byte salt
const master = await argon2id(seed, salt, {
t: 3, m: 64 * 1024, p: 1, len: 32, // 64 MiB, ~700 ms on laptop
});

The master key is used for device key wrapping and recovery only — it does not directly encrypt items. Each device generates its own Curve25519 keypair on first run; the device’s secret key is wrapped (encrypted) to the master key using XChaCha20-Poly1305 and stored in the browser’s IndexedDB.

When you send an item, four things happen on the client. The server never sees the plaintext or K_item — only the ciphertext and the wrapped-key bundle.

Item encryption · client side
STEP 1 — GENERATESTEP 2 — ENCRYPTSTEP 3 — WRAP K_ITEMSTEP 4 — SENDrandomBytes(32)K_itemXChaCha20 keyPLAINTEXT{ url: "…", title: "…" }AAD = type ∥ tsWRAP K_ITEMcrypto_box_seal()for each device pubkeyCIPHERTEXT+ wrapped[]→ POST /itemsSERVER (cannot decrypt)Stores ciphertext + wrapped key bundle. Records version. Fans out via WebSocket to other paired devices.It sees: item id · bin_id · type · ts · size · sender device_id
client/crypto/envelope.ts
// Per-item encryption (TypeScript-ish pseudocode)
const K_item = randomBytes(32);
const nonce = randomBytes(24);
const aad = utf8(`${type}|${ts}`); // binId is bound by sig, not AAD
const ct = xchacha20poly1305.encrypt(K_item, nonce, plaintext, aad);
const wrapped = devices.map(d => sodium.crypto_box_seal(K_item, d.pubkey));
const master = secretbox(K_item, masterKey); // any device with MK can decrypt
// Authentication tag — keyed by a master-key-derived subkey the server never has
const K_mac = blake2b(masterKey, "syncbins-item-mac-v1", 32);
const sig = crypto_auth(K_mac, canonical(id, bin_id, type, ts, ct, nonce));
postItem({ id, bin_id, type, ts, nonce, ct, wrapped, master, sig });

The sig tag is what stops a malicious server from forging or relabelling items. It’s an HMAC over the item’s id, bin, type, timestamp, and ciphertext, keyed by a subkey derived from the master key — which the server never holds, so it can’t produce a valid tag. Recipients verify sig before decrypting and reject anything that fails, so the server can neither inject content that looks like it came from one of your devices nor silently move an item to another bin or change its id/type/timestamp.

Because sig binds binId, moving an item between bins re-signs it (the moving device holds the master key). For items with a file attachment, sig also binds the blob reference and wrapped blob keys, so the attachment can’t be swapped.

Items larger than 64 KB (screenshots, audio, files) take a different path: the body is encrypted locally just like above, then the ciphertext is uploaded directly to your storage backend via a short-lived SAS URL. Only the metadata + a blob_ref go through the JSON API.

This is the part worth dwelling on. Three columns: what’s fully readable to the server, what’s structural metadata, and what’s fully opaque.

Fully visible
  • Item ID (ULID)
  • Bin ID
  • Type (password, link, …)
  • Timestamp
  • Sender device ID
  • Ciphertext size
Structural
  • Bin emoji + hue + capacity
  • Device name + glyph
  • Sync version counter
  • Pubkey of each device
  • Storage backend URL
Never visible
  • Item payload (any field)
  • Bin name
  • Master key / recovery phrase
  • Device private keys
  • Item search index
  • Your PIN

The 6-word pair code you see in the UI is a hash of an ephemeral public key. The full handshake is:

Pair handshake · 6 messages
EXISTING DEVICESERVERNEW DEVICE1. POST /pair-init  { epk, expires: 5min }6-word code = BIP39(SHA-256(epk)[:32])2. GET /pair-fetch-epk?code=…user types 6 words on new device3. POST /pair-fetch-npk  { npk, attestation }4. WS push: new device joining5. POST /pair-wrap  { master_key_to_npk }6. delivered to new device → paired ✓

The attestation in step 3 proves the new device knows the shared secret — derived from BLAKE2b(ephemeralPubkey || code) — meaning it physically read the 6 words (or scanned the QR) from the existing device, not just intercepted them on the wire.

Before the master key is sealed, both devices display a 6-digit verification code = BLAKE2b(ephemeralPubkey || newDevicePubkey) mod 1e6, and the user must approve on the existing device. Because the code is bound to the actual joining public key, an attacker who photographed the QR (and thus knows the code) produces a different verification code — the mismatch is visible and the user rejects. Only on approval does the existing device crypto_box_seal the master key to the new device’s pubkey and pair-wrap it.

When you come back after closing the browser, your session token may have expired (tokens last 30 days). SyncBins handles this without making you pair again:

  1. PIN unlock — your PIN decrypts the master key from localStorage, which in turn unlocks your device’s Curve25519 keypair from IndexedDB.
  2. Token refresh — if the bearer token is still valid, it’s refreshed automatically.
  3. Challenge-response reauth — if the token has expired, the client proves it still holds the device’s secret key. The server generates a random challenge, seals it with crypto_box_seal to your device’s public key, and the client opens it with the secret key. This proves key possession without sending any secret over the wire — and the server issues a fresh token.

If even the reauth fails (e.g. the device was revoked), you’ll be guided to re-pair from another active device.

AdversaryCan read items?Notes
Network observer (TLS-on-the-wire)NoHTTPS + items already encrypted at the app layer.
Compromised SyncBins hostNoSees ciphertext + metadata only. Cannot forge items, relabel them, or move them between bins — the master-key-derived sig (which it can’t compute) is verified on every item.
Stolen unlocked deviceYesSet a PIN; revoke the device remotely from another paired device.
Recovery phrase + stolen deviceYesFull access. Keep the phrase offline; rotate after exposure.
Malicious paired deviceYesIt’s paired — it has the master key. Revoke + rotate to evict.
Quantum adversary, futureNo (for now)Curve25519 is breakable post-Q. Re-encrypt rolling out with PQ KEM in 2027.

Revoking a device is a metadata flag. The device’s pubkey is marked revoked; new items skip it in the wrapped-key bundle; the server refuses its bearer token. Existing items the revoked device downloaded before revocation are obviously still on that device — revocation prevents future access, not past.

For full rotation (e.g. you think the phrase leaked), generate a new phrase from the Encryption settings pane. Every item is re-wrapped to fresh device keys; old wrapped-key bundles are dropped. This is a background job — expect a few minutes on a large bin.