/v1/send carve-out, read Encryption.
Primitives
Implementations: TweetNaCl on TypeScript surfaces
(server, Chrome extension, web app, CLI) and
Lazysodium on Android.
Precisely on audit status: TweetNaCl-js and libsodium have public
third-party audits. Lazysodium is an unaudited Java binding over audited
libsodium. Our own use of any of them has not been audited by anyone.
Identities and keys
Every device generates its own X25519 keypair. The secret key is created on the device during pairing and never leaves it — we do not escrow it, and we cannot recover it. Losing the device loses the key; the remedy is to unpair and pair again. Where that key sits at rest differs by surface, and matters for your backups:
The server stores only public keys — but it stores more than keys. The live
devices table is:
device_pairs (two device IDs, a pair ID, timestamps), and
device_subscriptions (Web Push endpoint plus its p256dh and auth keys, for
PWA devices).
The device label is user-supplied and often contains a personal name. Battery
level, charging state, ringer mode and network type are persisted per device —
the same metadata list_devices exposes to AI connectors. There is no account,
no email address and no password anywhere in the schema, but “no account” is not
the same as “nothing identifying,” and the label is the exception worth knowing
about.
The server’s own keypair
The server holds one X25519 keypair, supplied as a deploy secret. It is used for exactly one thing: encrypting messages the server itself composes on the server-as-sender path — the public/v1/send API and the
hosted MCP connector. Devices receive its public key at pair time and store it
alongside their partners’, marked as the server.
Recipients identify these envelopes by the sentinel sender ID server.
Rotation of the server keypair is not implemented. Device keypairs do not
rotate either, and there is no ratchet — see
no forward secrecy.
Pairing
Pairing swaps two public keys through the server without either device sending a secret.- The computer generates a keypair and calls
POST /v1/pair/initwith its public key. The server mints a six-digit code and storespair:<code> → { extensionPubKey, extensionDeviceId, surfaceKind, extensionDeviceName?, intent? }in Redis with a 300-second TTL, plus two sibling keys on the same TTL:pair:pending:<deviceId>andpair:initiated_at:<deviceId>. - You type the code into the Android app. The phone calls
POST /v1/pair/confirmwith the code and its own public key. - The server hands each device the other’s public key, writes both device rows
and the pair row, and deletes the code. It also writes
pair:status:<extensionDeviceId>— holding the phone’s public key, device ID and device name — with a 24-hour TTL, so the computer can collect the result. - Each device computes the shared secret locally via X25519. The server never computes it and cannot derive it — a shared secret cannot be derived from two public keys.
crypto.randomInt(0, 1_000_000) — a CSPRNG, uniform
over the full six-digit space, zero-padded. Codes are claimed atomically
(HSETNX), so two concurrent pairings can never collide. A code is single-use
and dies after five minutes or on redemption, whichever comes first.
Wire format
Every encrypted message on the device-to-device path is a single opaque blob:- Minimum length is 41 bytes — 1 version + 24 nonce + 16 authentication tag. Shorter blobs are rejected before decryption is attempted.
- The version byte is checked, not assumed. A blob whose first byte is not
0x01is rejected withUnsupported wire version. - Nonces are 24 bytes from the platform CSPRNG, fresh per message.
nacl.box.open in TweetNaCl and
crypto_box_open_easy in libsodium do the whole thing.
A successful open authenticates the message as coming from one of the two
holders of the shared secret, and proves it has not been altered in transit. It
is deliberately not a signature: box derives a symmetric key both parties
hold, so either endpoint could produce a blob that opens as if it came from the
other, and neither can prove to a third party who wrote it. That is the normal
property of NaCl box and it is fine for a two-party bridge — but do not read a
successful decryption as non-repudiable evidence of authorship.
A failed open means wrong keys or tampering, and the device drops the message.
Payload types
Inside the envelope is JSON with atype discriminator.
Payload shapes are fixed by interop fixtures asserted on both platforms — field
order is part of the contract, because TypeScript’s
JSON.stringify and Kotlin’s
Json.encodeToString must produce identical bytes.
How messages travel
Phone → computer. The phone encrypts on-device andPOSTs the blob to the
server. If the recipient has an open SSE connection, the server forwards it
immediately. It also writes it to a per-device Redis sorted set
(queue:<deviceId>) scored by sequence ID, capped at 200 entries with a
24-hour TTL. On reconnect the client replays from its cursor via
GET /v1/sync?since_id=N or the Last-Event-ID header. The server stores the
blob; it cannot read it.
Computer → phone. Android holds no persistent connection, so the server wakes
it through Firebase Cloud Messaging. Every FCM type, and what rides outside the
encrypted envelope in each:
FCM’s data limit is 4 KB; the server checks the encoded size before sending.
Two of those rows deserve to be read twice.
batch_dismiss sends message
identifiers in the clear — opaque IDs carrying no content, but a signal about
your activity. And reg_challenge carries a possession-proof nonce in
cleartext, which makes Google a trusted party for solo-device registration.
What is visible outside the envelope
File transfer
Files do not ride the message envelope, and use a different construction. Each chunk is sealed with XChaCha20-Poly1305-IETF in combined mode — notsecretstream. The key is derived with HKDF-SHA-256 from the raw X25519
shared secret (deliberately not the crypto_box_beforenm key the messaging
path uses, so the two contexts cannot interact). The nonce is a 16-byte
per-transfer random prefix followed by an 8-byte big-endian global chunk
index. transferId | fileId | chunkIndex | isFinal is bound as associated data.
The separation that prevents one chunk being replayed into a different file comes
from the global chunk index in the nonce — the AAD authenticates those fields
but does not by itself separate keystreams. The wire identifier is
xchacha20poly1305-chunked-v1.
Parts are uploaded as ciphertext to object storage via presigned URLs, or sent
directly device-to-device over WebRTC when both ends can reach each other.
Control messages ride the normal encrypted envelope as file_ctrl.
Safety numbers
Because the server brokers the key exchange, you need a way to check it didn’t substitute keys. Each pair of devices independently computes a 60-digit fingerprint from the two public keys they hold. If the numbers on your phone and your computer match, no third party is sitting between them. The algorithm, exactly:Verification is pairwise between your phone and one computer. Verifying two
computers against each other is not offered — they learn each other’s keys
from a server-provided directory, so PC-to-PC clipboard and file transfer are
trust-the-server today with no detection path. Recorded in the
threat model.
Cross-platform interoperability
TypeScript and Kotlin must produce byte-identical ciphertext for identical inputs, or a message encrypted on your phone would not open on your laptop. Both sides embed the same fixed vectors — the same keys, the same 24-zero-byte nonce, the same plaintext. Being exact about what is automated, because it is less than we would like:- Safety-number vectors run on both sides in CI. The Kotlin test is a plain JVM test and executes on every relevant change.
- Message-encryption vectors run on the TypeScript side in CI only. The Kotlin equivalent needs real libsodium, which means an instrumented test on a device or emulator; it is not part of the automated gate and is asserted when run locally.
Versioning
WIRE_VERSION is 0x01. Receivers reject any other value rather than guessing.
Changing the format requires a synchronized change to both implementations and
both sets of interop vectors in the same commit; there is no version negotiation
and no silent fallback.