# The relay never learns the password

2026-10-08

I brainstorm with an agent on my phone. The documents that give an idea its context live on my laptop, next to Claude Code, and I don't want to upload them anywhere. What I want is to move the idea to the context, not the context to the idea.

So I built [handover.tools](https://handover.tools). One agent stores something, I carry an 8-letter link like `handover.tools/kvmtrhxp` to the other side, and the other agent fetches it. A **one-shot** handoff is one piece of text, fetched once and not encrypted. A **channel** pairs two agents for several rounds, end-to-end encrypted. This post is about the channel. The [code is open source](https://github.com/gammaland/handover).

## What already exists

Moving work between agents is not a new problem, and most of it is well served already:

| | Examples | Why it didn't fit |
|---|---|---|
| Same-vendor continuity | Claude Code Teleport and Remote Control | Excellent within one vendor. No vendor will hand your work to a competitor's agent. |
| Agent protocols | A2A; handoffs in the OpenAI Agents SDK, LangChain and Microsoft Agent Framework | A2A connects agents that run as networked services; framework handoffs pass control inside one program. Neither covers an app on my phone and a CLI on my laptop. |
| Handoff documents | Skills and scripts that write a handoff file | They produce the summary well, then leave the delivery to you. That's the part I wanted. |
| Memory layers and shared workspaces | Hosted agent memory, team workspaces | Accounts and persistent storage: the work lives there. I wanted something that delivers and then disappears. |
| Encrypted transfer | magic-wormhole, croc | The right cryptography, built for two people at two terminals, online at the same moment. |

The gap is narrow: **cross-vendor, between devices or people, asynchronous, with no account and nothing to install on the receiving side.** The last row is where the channel's design comes from.

## A short code is not a key

The service is run by one person with no company behind it. "We don't look at your data" is a promise, and a promise from a stranger is worth nothing. So the server must be *unable* to look, which means the two agents need a key the server doesn't have.

Deriving that key from the code the person carries doesn't work. Eight letters from a 22-letter alphabet is 22⁸ ≈ 5.5 × 10¹⁰ possibilities. Anyone with a copy of the stored ciphertext can try them all offline.

A **password-authenticated key exchange** (PAKE) changes what the code is for. Two parties who share a weak password agree on a strong key, and the password can only be guessed *online*: every guess means talking to the other party, and a wrong guess just fails. Nothing an eavesdropper records can be brute-forced later. magic-wormhole and croc both work this way, and I copied magic-wormhole's construction rather than invent anything: SPAKE2 using the same [Python library](https://github.com/warner/python-spake2), then an AEAD for the messages.

## The mistake

My first design had the server generate the code. The client sent its SPAKE2 message, the server returned 8 letters, the person read them out, the other side joined. It looked fine.

I found the problem while writing the client: **if the server generates the code, the server knows the password.** It can run SPAKE2 separately with each side using that password. Each side completes a valid exchange with the server, believes it is talking to the other agent, and the server reads everything in between. The PAKE protected against everyone except the one party it was meant to protect against.

magic-wormhole doesn't have this problem, and I had copied it without noticing the half that makes it work. Its codes have two parts:

```
k v m   t r h x p
└─┬─┘   └───┬───┘
nameplate  password
(server)   (never leaves the two machines)
```

The **nameplate** is generated by the server and only answers "which channel is this?" The **password** is generated by the opening client and goes only into SPAKE2:

```python
password = "".join(secrets.choice(LETTERS) for _ in range(PASSWORD_LEN))
sp = SPAKE2_A(password.encode(), idA=ID_A, idB=ID_B)
r = call("open", {"pake_msg": b64e(sp.start())})
code = r["nameplate"] + password      # what the person carries
```

Now a malicious server that knows the nameplate gets one online guess at the password per channel, 1 in 22⁵ (about 5 million). A wrong guess is also visible: the real joiner fails, and the opener can't decrypt the attacker's first message.

When you draw the data flow for a PAKE, the question isn't "who does the ciphertext pass through?" It's **"who does the password pass through?"**

## SPAKE2, store and forward

magic-wormhole and croc expect both people online during the transfer. Agents don't work that way: I open a channel on one machine and may join from the other an hour later. The server has to be a mailbox.

SPAKE2 fits, because each side sends one message and neither depends on the other:

1. **A** starts SPAKE2, leaves its message on the server, and saves its SPAKE2 state to disk.
2. **B** joins with the nameplate and its own message, gets A's back, and derives the key.
3. **B** immediately sends an encrypted `hello`: the key confirmation.
4. **A**, the next time the person asks it to fetch, gets B's message, derives the key, deletes the saved state and the password, and decrypts the `hello`.

Consuming the nameplate is one conditional SQL statement, so of two concurrent joins exactly one wins:

```sql
UPDATE channels SET nameplate=NULL, pake_b=?1, touched_at=?2,
       expires_at=MIN(?2 + ?3, hard_expires_at)
 WHERE nameplate=?4 AND pake_b IS NULL AND expires_at>?2
 RETURNING id, pake_a, expires_at
```

Writing a protocol spec detailed enough for someone else to build a second client found a bug here. A fetch by A before B joined slid the channel's expiry forward, turning the 60-minute pairing window into 48 hours, during which a guessable nameplate stayed open. The expiry now slides only after B has joined.

## The message layer

Each direction gets its own key from the SPAKE2 output `K`:

```python
# direction is b"a2b" or b"b2a"
HKDF(algorithm=hashes.SHA256(), length=32, salt=None,
     info=b"percall-channel-v1:" + direction).derive(K)
```

A message is `nonce ‖ ChaCha20-Poly1305(plaintext, aad=direction)`, so it can't be reflected back to its sender. Inside, a sender counter catches replays and gaps; the server's own sequence numbers can't be trusted for that. The sender saves the incremented counter *before* sending, so a retry after a lost response isn't dropped as a replay. A message that won't decrypt is `pairing_mismatch` before the first success (wrong code, or interference) and `tampered` after it. Either way the client stops and tells the person to pair again.

## Guarding against helpful agents

Encryption that depends on the client has an odd failure mode when an LLM drives the client. The server accepts any base64 as ciphertext. An agent that skips the client and base64-encodes the plaintext would leave it readable in my database while everyone believes the channel is encrypted.

ChaCha20-Poly1305 output looks random, and only about 38% of random bytes are printable ASCII. So the server refuses anything under 48 bytes or at least 90% printable:

```python
printable = sum(1 for x in raw if 32 <= x < 127 or x in (9, 10, 13))
return printable / len(raw) >= 0.9          # refused as not_ciphertext
```

A determined sender can get around this, and it isn't meant to stop one. It stops the likely accident.

The same reasoning is why one-shot handoffs aren't encrypted. A zero-knowledge pastebin can keep the key in the URL fragment because a browser runs the decryption code. An agent opening a link makes one HTTP request and runs nothing, so it would get ciphertext it can't read. The one-shot docs say plainly that it isn't encrypted, and point anything sensitive to the channel.

## What it doesn't claim

- The client is downloaded from the server that relays the messages. Its sha256 and source are published, which helps only if someone checks.
- One key covers the whole channel: no forward secrecy within it.
- Decrypting proves which agent sent a message, not that it is safe to act on. The other agent may have read something injected, so messages are requests within the brief the person set, never commands.
- The server can't verify that a person approved each send.
- Nobody has audited it.

## Try it

Tell your agent "Hand this over with handover.tools." The [channel protocol spec](https://github.com/gammaland/handover/blob/main/docs/spec/channel.md) is meant to be enough to write a second client; if you write one, I'd like to hear about it. The [system design](https://github.com/gammaland/handover/blob/main/docs/system-design.md) and the [decision log](https://github.com/gammaland/handover/blob/main/docs/decisions.md) cover the rest, including what broke.
