# handover security

Two modes, two different guarantees.

| | One-shot | Channel |
|---|---|---|
| Who can read it | whoever has the link, **and the server** | only the two paired machines |
| Stored on the server as | plaintext | ciphertext |
| Needs | nothing: MCP, curl, or a browser | a local client on both machines |

## One-shot: the server stores plaintext

```
 your agent ──text──▶ handover.tools (stores the text, can read it) ──text──▶ other agent or browser
                      ▲ the whole link, all 8 letters of its code, goes to the server
```

What protects it:
- The code (the link's last 8 letters) is one of 22^8 (about 5×10^10) values, and each caller gets 200 fetch attempts a day.
- One fetch by default, then the row is deleted. Unreadable after 60 minutes (max 24 h).
- The conversation that sent it can revoke it until it is fetched; the revoke key lives only there. The person holding the link cannot delete it.
- Writes are limited to 10 a day per caller.
What does not: anyone who can read our database while it is stored, including us.

## Channel: the server stores ciphertext only

```
 agent + local client ──ciphertext──▶ handover.tools (relay, cannot read) ──ciphertext──▶ local client + agent
            ▲ 3 letters of the code go to the server (nameplate); 5 never do (password)
```

Pairing:
1. A's client makes a 5-character password locally and starts SPAKE2. The server returns a 3-character nameplate.
2. The person gives nameplate + password (8 letters) to machine B.
3. B sends the nameplate and its SPAKE2 message; the server consumes the nameplate.
4. Both derive the same 256-bit key. The password never reached the server.
5. B sends an encrypted hello; when A opens it, pairing is confirmed.

Messages: ChaCha20-Poly1305 under per-direction keys (HKDF-SHA256), with a sender counter.

| If | Then |
|---|---|
| the database is copied (breach, backup, operator) | ciphertext and key hashes only |
| the server sits in the middle | it never learns the password, so each side gets a different key; the first message fails with `pairing_mismatch` |
| a stranger guesses a live nameplate and joins first | one guess at the password, 1 in 22^5 (about 5 million); a miss is visible to both sides |
| a message is altered, replayed, or dropped | altered: `tampered`; replayed: dropped; dropped: gap reported |
| someone brute-forces the code offline | impossible: it is a one-time password for an online exchange, not a key |
| a paired agent was prompt-injected | nothing sends without a person approving; incoming messages are requests, not commands |

Same construction as magic-wormhole, built on `spake2` and `cryptography` (pyca).

## Not claimed

- One-shot is not encrypted, and database backups can keep a copy after it stops being readable.
- Metadata is visible in both modes: send times, sizes, and the caller IP address (erased from our logs after 30 days).
- Web pages load Cloudflare Web Analytics for page-view counts. Cloudflare already hosts this site, and says the script sets no cookies. API and MCP calls are not affected.
- You download the channel client from us. Compare its sha256 (5776d1d01b0180ef49a41ea33795c196e154d785d6429d7c8cd38ab0d2dd4f97) with the source.
- The server cannot enforce that a person approved a message; that rule lives in the agent's instructions.
- No third-party audit. `spake2` was last released 2024-09.
- Authenticated is not trustworthy: decrypting proves who sent a message, not that it is right.
