Skip to main content
This page documents what Reflecto puts on the wire. It exists so the privacy claim is falsifiable: if what your devices send doesn’t match what’s written here, one of the two is wrong, and you can tell which. For the trust boundaries and what this design deliberately does not defend against, read the threat model. For the specific /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:
Two related tables: 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.
  1. The computer generates a keypair and calls POST /v1/pair/init with its public key. The server mints a six-digit code and stores pair:<code> → { extensionPubKey, extensionDeviceId, surfaceKind, extensionDeviceName?, intent? } in Redis with a 300-second TTL, plus two sibling keys on the same TTL: pair:pending:<deviceId> and pair:initiated_at:<deviceId>.
  2. You type the code into the Android app. The phone calls POST /v1/pair/confirm with the code and its own public key.
  3. 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.
  4. 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.
The code is generated with 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.
Two attacks live in this step, and both are real.A malicious or compromised server could hand each device a public key it controls and read everything afterwards. Nothing in the protocol prevents that; safety numbers make it detectable, but only if you check.Six digits is a small space. An attacker guessing live codes fast enough would pair as a phone to a stranger’s computer. See the threat model for what currently stands between an attacker and that, and why we think it is the weakest structural point in a design with no accounts.

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 0x01 is rejected with Unsupported wire version.
  • Nonces are 24 bytes from the platform CSPRNG, fresh per message.
Decryption, in full:
In practice you don’t write the HSalsa20 step: 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 a type 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 and POSTs 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 — not secretstream. 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.
Peer-to-peer setup uses Google’s public STUN servers (stun.l.google.com, stun1.l.google.com). Each device sends them a STUN binding request to learn how it appears from the outside, which reveals that device’s public IP and port to Google, along with the fact and timing of a transfer attempt.It does not reveal your local network addresses: those are gathered from the device’s own interfaces as ICE host candidates and exchanged only between the two peers, inside the encrypted file_ctrl envelope. Transfer content stays encrypted throughout. But the direct path is not free of Google.

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:
Sorting the keys makes the function symmetric, so both devices compute the same value without agreeing on who goes first. The 5200 iterations are Signal’s constant: they make grinding a look-alike keypair cost 5200 hashes per candidate while costing a device nothing to compute once per screen view. It is derived from public keys only. No secret material is touched, so the number is safe to read aloud, screenshot, or send over any channel. Compare them in Settings → Privacy & security → Verify encryption on the computer, and on the device detail screen on Android.
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.