# sPassPass

> End-to-end encrypted secret handoff between two parties. Each side generates an RSA-OAEP key pair locally; the server relays only public keys and ciphertext and stores nothing.

## For AI agents

Use this to get a secret (API key, password, token) from your human without it passing through your conversation, or to hand one to them. The secret is written to a file; it never needs to appear in your context.

Requires Node 22+, or Node 20/21, where the CLI re-runs itself with `--experimental-websocket` (Debian 13 ships Node 20). `--help` prints usage. Download the CLI from this host:

```
curl -fsSL https://spasspass.com/cli/v1/spasspass-2a49522e.mjs -o /tmp/spasspass.mjs
```

SHA-256 of that file: `2a49522e7a9d792d820fe801e2249528d79646032d1c05020dd95a39468b19d9`

The file name carries the start of that hash, so its content never changes. Once a newer CLI is deployed the old name returns 404; fetch this guide again for the current name and hash.

It is one unminified, commented file; read it before running it if you can.

**The log is the only source of truth.** Act only on lines that are actually in it, and copy `LINK` and `CODE` from it character for character: never retype, shorten or guess them. The human checks `CODE` against their page, so a wrong one defeats the check. The `/tmp/spasspass.*` paths below are examples; any directory works. `--timestamps` starts every log line with an ISO 8601 time, for any command.

### Receive a secret from your human

```
(node /tmp/spasspass.mjs receive --out ~/.secrets/db_password --base https://spasspass.com; echo "EXIT $?") > /tmp/spasspass.log 2>&1 &
```

1. Pick any `--out` path; `~/.secrets/<name>` is a sensible default. Run it in the background as shown, so the log ends with an `EXIT <code>` line: it waits (default 600 s, max 1800 s via `--timeout`) for the human, which usually outlasts a shell tool's timeout.
2. Within a second the log has two lines:
   ```
   LINK https://spasspass.com/share/AbC123_-xyZ
   CODE DDBG UFLZ RGMQ JYEV
   ```
3. Send the human the link and the code. Tell them to open the link, check that the page shows the same letters for the other party, and paste the secret. The page shows the first 4 letters (the first group) and reveals more on hover; matching the first group is the minimum, and the more letters the human reads out and compares, the stronger the check.
4. Poll the log until it has an `EXIT` line. A `PEER <letters>` line (four 4-letter groups) appears when the human's page connects; it is the fingerprint their page shows for itself. Success is `RECEIVED <path> <n> bytes peer=<letters>` then `EXIT 0`. The file has mode 600; the parent directory is created (mode 700) if missing. An existing file is never overwritten unless you pass `--force`.

Use the secret by reference (`$(cat ~/.secrets/db_password)`, a config file path, an env file). Do not print it.

### Your human sent you a link

If the human already opened https://spasspass.com and sent you a `https://spasspass.com/share/<id>` link, pass it to `receive` to join their session instead of making a new one:

```
(node /tmp/spasspass.mjs receive 'https://spasspass.com/share/AbC123_-xyZ' --out ~/.secrets/db_password; echo "EXIT $?") > /tmp/spasspass.log 2>&1 &
```

`--join <link>` is the same thing spelled as an option; give the link one way, not both. `--out` is still required. The log has no `LINK` line: the first line is `CODE <letters>`. Tell the human those letters; their page shows them for the other party. `PEER <letters>` is their page's own fingerprint. Then the human pastes the secret and the rest is as above. A `/send/<id>` link is the human's own page, not one to join; ask for the `/share/` link it shows. A link to a session that does not exist or has expired exits 1.

### Send a secret to your human

```
(node /tmp/spasspass.mjs send --in /path/to/secret --base https://spasspass.com; echo "EXIT $?") > /tmp/spasspass.log 2>&1 &
```

Same LINK/CODE handshake; success is `SENT <n> bytes peer=<letters>` once the human's browser confirms receipt. `--in -` reads stdin. Text secrets only (UTF-8). One trailing newline is stripped. Never put the secret on the command line. Limit: 190 bytes (UTF-8).

### Talk with your human

`chat` holds a running conversation instead of a one-shot handoff. It prints every message it receives in plaintext, so it is for talking (coordinating, testing the site together). **Never use it for secrets, even when a chat is already open: run `receive` or `send` for those.**

```
: > /tmp/spasspass.in
(tail -f /tmp/spasspass.in | node /tmp/spasspass.mjs chat --quiet-acks --base https://spasspass.com; echo "EXIT $?") > /tmp/spasspass.log 2>&1 &
```

Pass the human's `/share/` link as an argument to join their session instead. Same LINK/CODE handshake as above. Ending stdin (kill the `tail`) closes the chat; it also ends after `--timeout` (default and maximum 1800 s).

**Sending.** Each line appended to `/tmp/spasspass.in` is one message of at most 190 UTF-8 bytes. The line is sent exactly as written except for two escapes: `\n` becomes a line break and `\\` a backslash. Do not escape quotes or apostrophes. A quoted heredoc writes text verbatim, so send with one:

```
cat >> /tmp/spasspass.in <<'EOF'
I'm here. "Quotes" and $dollars need no escaping.\nThis is a second line of the same message.
EOF
```

A longer line is not sent (see `SKIPPED`); split long replies into several lines yourself.

**Log lines**, in the order they happen:

- `CONNECTED` / `DISCONNECTED`: the human's page opened or closed. Greet them on `CONNECTED`.
- `MSG <JSON string>`: a message from them. The payload is a JSON string literal, not an object: `MSG "it's\nfine"` decodes with `JSON.parse` / `json.loads` to the text itself, two lines. Messages arrive in the order sent.
- `SKIPPED <n> bytes; the limit is 190`: your line was too long and was not sent.
- `REJECTED <letters>`: a second, different key tried to join and was refused (`REJECTED INVALID` for a key that is not 2048-bit RSA with exponent 65537); the chat goes on with the trusted one.
- `ENDED completed` or `ENDED expired`, then `EXIT 0`: the other side ended the session, or it expired.
- `error: …`, then `EXIT <code>`: the chat failed (codes below).
- Without `--quiet-acks`, each of your lines also logs `SENT <id> <n> bytes <JSON string>` (the text sent) and then `DELIVERED <id>` once their browser decrypts it, so the log is a full transcript. Each of those lines also wakes the waits below, which is why the recipes leave them out.

**Waiting for messages.** If your harness can stream a command's output to you line by line (a monitor or streaming tool), run the chat as that command instead of the background job above, so each message reaches you the moment it arrives:

```
: > /tmp/spasspass.in
tail -f /tmp/spasspass.in | node /tmp/spasspass.mjs chat --quiet-acks --base https://spasspass.com 2>&1 | tee /tmp/spasspass.log
```

Do not filter it through `grep` or `awk`: some environments wrap `grep` in a tool that holds each line until the next one arrives, and mawk (Debian's default `awk`) buffers its input until EOF.

Otherwise, run this after each reply. It returns as soon as the log grows, printing the new lines, or after 110 s with nothing, which fits a 2-minute shell tool timeout; then run it again:

```
f=/tmp/spasspass.log; n=$(cat $f.seen 2>/dev/null || echo 0)
for i in $(seq 110); do [ "$(wc -l < $f)" -gt "$n" ] && break; sleep 1; done
m=$(wc -l < $f); sed -n "$((n + 1)),${m}p" $f; echo $m > $f.seen
```

**Handing the chat to another agent.** Start the chat yourself, read `LINK` and `CODE` from the log and give them to the human yourself: a subagent's retelling can be wrong. Then any process that can read the log and append to the input file can hold the conversation. Have it waiting before the human gets the link, and pass it this section in full rather than a summary.

### Exit codes

- 0: done
- 1: error (bad arguments or unknown flags, output file exists, secret too long or not valid UTF-8, connection lost)
- 2: timed out waiting for the human
- 3: a second, different key joined the session; it was rejected. For `receive`, nothing was written. For `send`, the secret may already have gone to the first key, if the other key joined after it was sent and before the receipt. Tell the human and start over; if the secret may have been delivered to the wrong party, rotate it.

### Trust

The CLI comes from the same host that relays the ciphertext, the same trust a browser user extends to this site's JavaScript. To reduce that: review the file, pin the SHA-256 above, or implement the protocol below yourself. Always show the human the CODE line (the fingerprint).

- `receive`: the human compares the fingerprints before pasting the secret, so a malicious relay that swaps keys is caught before anything is encrypted.
- `send`: the secret is encrypted to whichever key joins first, before the human can compare fingerprints, so protection against a malicious relay rests on trusting the host. If the human reports a fingerprint mismatch, treat the secret as exposed and rotate it.
- Who can hand you a secret depends on who created the session. When you create it (`receive` without a link), anyone who holds the link, or the relay, could join and send a ciphertext; `peer=` names the trusted key, not proof of who wrote the secret. When you join the human's session (`receive <share-url>`), only their page can send to you: sending needs the sender's secret, which only their browser holds, so only the relay could still inject one. Prefer joining when it matters who sent the secret.

## Protocol

Use this to write your own client.

1. Pick an 11-character id from `[A-Za-z0-9_-]`. `POST https://spasspass.com/api/session/create` with `Content-Type: application/json` and body `{"sessionId":"<id>"}`. The response is `{"sessionId","senderSecret"}`.
2. Open a WebSocket to `https://spasspass.com/ws/?sessionId=<id>&role=sender&senderSecret=<secret>` with `http` replaced by `ws` (`https` by `wss`). The human opens `https://spasspass.com/share/<id>`. To join a session the human created instead, skip step 1 and connect with `role=recipient` and no `senderSecret`; the roles in the steps below swap.
3. Both sides send `{"type":"PUBLIC_KEY","data":{"publicKey":<JWK>}}` right after connecting. The key is an RSA-OAEP 2048 key with SHA-256 and public exponent 65537 (`"e":"AQAB"`), exported as JWK. A key of any other size or exponent is never trusted. The server relays the key to the other side, or replays it if that side connects later.
4. Compute the fingerprint as `h = SHA-256(UTF-8(JSON.stringify({kty, n, e})))`. The fingerprint is `h` read as one big-endian 256-bit integer `N`, written in base 26 with the least significant digit first and exactly 55 digits (digit `d` is the letter `A`+`d`, so `A`=0 … `Z`=25): `letter[i] = 'A' + (floor(N / 26^i) mod 26)` for `i` = 0..54. Split into groups of 4 letters (the last group has 3). The CLI prints the first 4 groups. The human's page shows only the first group by default and more on hover, so compare at least the first group; more is better.
5. Encrypt the secret's UTF-8 bytes (at most 190) as one RSA-OAEP block. Send `{"type":"ENCRYPTED_PASSWORD","data":{"encryptedPassword":[<ciphertext bytes as ints>],"passwordId":"<uuid>"}}`.
6. The receiver replies `{"type":"PASSWORD_RECEIPT_CONFIRMATION","data":{"passwordId"}}`, which the sender gets as `PASSWORD_RECEIPT_CONFIRMED`.
7. A recipient key that arrives while a recipient is already connected comes with `"pending":true`; the server holds that connection until the sender sends `{"type":"TRUST_DECISION","data":{"accepted":<bool>}}`. If the key matches the one you trust, it is the same recipient reconnecting: accept it. A different key means someone else joined: reject it.
8. Send `{"type":"SESSION_COMPLETE"}` to end the session. Sessions expire 30 minutes after creation. Either way the server sends every connection `{"type":"SESSION_ENDED","data":{"reason":"completed"|"expired"}}` and closes it; do not reconnect after that.
