> ## Documentation Index
> Fetch the complete documentation index at: https://docs.reflecto.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Protocol and wire format

> The exact primitives, wire format, pairing exchange and safety-number algorithm Reflecto uses, so you can check our claims against what your devices actually send.

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](/guides/threat-model). For the specific
`/v1/send` carve-out, read [Encryption](/guides/encryption).

## Primitives

| Purpose                 | Algorithm                                             |
| ----------------------- | ----------------------------------------------------- |
| Key agreement           | X25519 (Curve25519 ECDH)                              |
| Message encryption      | XSalsa20-Poly1305 (NaCl `box` / `crypto_box`)         |
| File chunk encryption   | XChaCha20-Poly1305-IETF, key derived via HKDF-SHA-256 |
| Safety-number digest    | SHA-512, iterated                                     |
| Client–server transport | TLS (HTTPS), including the SSE stream. No WebSockets  |
| Peer-to-peer transport  | WebRTC DTLS/SCTP, self-signed peer certificates       |

Implementations: [TweetNaCl](https://tweetnacl.js.org/) on TypeScript surfaces
(server, Chrome extension, web app, CLI) and
[Lazysodium](https://github.com/terl/lazysodium-android) on Android.

<Note>
  **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.**
</Note>

## 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:

| Surface         | Location                                   | In your backups?                                                              |
| --------------- | ------------------------------------------ | ----------------------------------------------------------------------------- |
| Android         | `reflecto_secure_prefs.xml`                | No — excluded from cloud backup and device transfer                           |
| CLI             | `~/.config/reflecto/cli.json`, mode `0600` | **Yes** — an ordinary machine backup (Time Machine, rsync, Borg) will copy it |
| Extension / web | Browser storage in your profile            | **Yes**, if you back up the profile                                           |

The server stores only public keys — but it stores more than keys. The live
`devices` table is:

```sql theme={null}
devices(
  device_id          TEXT PRIMARY KEY,  -- random UUID
  device_type        TEXT,              -- 'extension' | 'android'
  surface_kind       TEXT,              -- 'phone' | 'extension' | 'pwa' | 'cli' | 'desktop'
  public_key         TEXT,              -- base64 X25519 public key
  last_seen_at       INTEGER,
  fcm_token          TEXT,              -- Android, for push wake-up
  name               TEXT,              -- your device label, e.g. "Work laptop"
  ever_paired        INTEGER,
  ever_drained       INTEGER,
  battery_percent    INTEGER,           -- device status, reported on a 60s throttle
  charging           INTEGER,
  ringer             TEXT,
  network            TEXT,
  status_reported_at INTEGER
)
```

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](/guides/encryption) — 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`.

<Note>
  Rotation of the server keypair is not implemented. Device keypairs do not
  rotate either, and there is no ratchet — see
  [no forward secrecy](/guides/threat-model#no-forward-secrecy-no-key-rotation).
</Note>

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

<Warning>
  **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](#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](/guides/threat-model#guessing-a-live-pairing-code) 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.
</Warning>

## Wire format

Every encrypted message on the device-to-device path is a single opaque blob:

```
[0x01] | nonce (24 bytes) | ciphertext (variable)
  ^        ^                 ^
  |        |                 NaCl box output: plaintext + 16-byte Poly1305 tag
  |        random per message, never reused with the same key
  wire version
```

* **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:

```
sym_key   = HSalsa20(0, X25519(my_secret_key, sender_public_key))
plaintext = secretbox_open(ciphertext, nonce, sym_key)
```

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.

| Type                  | Direction                        | Carries                                                                                            |
| --------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------- |
| `notification`        | phone → computer, server → phone | Mirrored notification, or a server-composed one                                                    |
| `reply`               | computer → phone                 | Reply text to send via the originating app                                                         |
| `action`              | computer → phone                 | A notification action to invoke                                                                    |
| `dismissed`           | phone → computers                | A notification was cleared on the phone                                                            |
| `replied`             | phone → computers                | Echo of a sent reply — **contains the reply text**                                                 |
| `clipboard`           | both                             | Text or image clip                                                                                 |
| `file_ctrl`           | both                             | File-transfer control: offer, accept, decline, cancel                                              |
| `interaction_request` | server → phone                   | An agent's question and its answer choices                                                         |
| `token_update`        | server → devices                 | Push-token housekeeping                                                                            |
| `ping` / `pong`       | both                             | Connectivity diagnostics                                                                           |
| `sms` family          | both                             | SMS mirroring and send — feature-flagged **off**                                                   |
| `reminder`            | server → phone                   | **Defined but not shipped.** The type exists in the shared schema; nothing sends or handles it yet |

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 `POST`s 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 `type`        | Cleartext fields                             | Encrypted payload inline?                                                                               |
| ----------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `notify`          | `msgId`, `seqId`                             | No — a wake-up nudge; the phone then fetches                                                            |
| `notification`    | `msgId`, `seqId`                             | **Yes** — server-composed sends (public API, MCP connector) ride inline so the phone can skip the fetch |
| `clipboard`       | `msgId`                                      | Yes                                                                                                     |
| `file_ctrl`       | `msgId`                                      | Yes                                                                                                     |
| `reply`, `action` | `msgId`                                      | Yes                                                                                                     |
| `sms` family      | `msgId`                                      | Yes                                                                                                     |
| `batch_dismiss`   | **`msgIds`** — the message IDs being cleared | No                                                                                                      |
| `disconnect`      | `deviceId`                                   | No                                                                                                      |
| `ring`            | `durationS`                                  | No                                                                                                      |
| `reg_challenge`   | `registrationId`, **`nonce`**                | No                                                                                                      |

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

| Party            | Sees                                                                                                                                                                                             | Does not see                                                                     |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
| The server       | Device IDs and labels, message sizes, timestamps, sequence IDs, delivery targets, FCM tokens, Web Push endpoints, device status (battery, charging, ringer, network), client IP during a request | Message content on the device-to-device path, shared secrets, device secret keys |
| Google (FCM)     | Delivery timing per device; ciphertext for inline commands; dismissed message IDs; disconnect device IDs; registration nonces                                                                    | Plaintext of any inline command                                                  |
| Google (STUN)    | Each device's public IP and port, as observed during peer-to-peer setup                                                                                                                          | Local network addresses, and any transfer content                                |
| Google Analytics | Routing events keyed to a pseudonymous pair ID, with coarse geography derived from your IP                                                                                                       | Message content                                                                  |

## 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`.

<Warning>
  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.
</Warning>

## 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:

```
DOMAIN_TAG = "reflecto-safety-v1"        (18 ASCII bytes)
ITERATIONS = 5200
(first, second) = the two 32-byte public keys, sorted by unsigned
                  lexicographic byte order
seed = DOMAIN_TAG || first || second      (82 bytes)

h = SHA-512(seed)
repeat 5199 more times:  h = SHA-512(h)   (each iteration hashes the
                                           previous 64-byte digest)

for g in 0..11:
    chunk  = big-endian uint40 of h[g*5 .. g*5+4]
    digits += zero-pad-to-5(chunk mod 100000)

result = 60 decimal digits
```

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.

<Note>
  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](/guides/threat-model#verifying-two-computers-against-each-other).
</Note>

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