Each network now carries its own share dir, set at join_network time via optional share_dir field or updated live with set_share_dir. The global -share-dir daemon flag becomes a fallback default. - proto: add ShareDir/DownloadDir to NetworkInfo and IpcMessage - netmgr: Join accepts shareDir override; SetShareDir updates live - ipc: wire join_network share_dir and set_share_dir command - daemon: remove -share-dir from auto-join path (pass "" for default) - test-network.sh: per-network join with share_dir; isolation verification section confirms alice/friends and alice/work share dirs are independent - test-tui.sh: join_network with share_dir; peer IDs resolved after join All tests pass: YAW/2.1 FS, share isolation, file transfer, persistence. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
519 lines
27 KiB
Markdown
519 lines
27 KiB
Markdown
# YAW/2.0 — Protocol Specification
|
||
|
||
**Version:** `yaw/2.0` · **Status:** 🔒 **LOCKED** (frozen wire — interop baseline)
|
||
· clean break from WASTE 1.x.
|
||
|
||
> **This document is frozen.** Independent implementations interoperate against
|
||
> this exact wire and the live server. Do **not** change 2.0. New protocol work —
|
||
> currently forward-secret signaling — lives in
|
||
> [yaw2.1-protocol.md](yaw2.1-protocol.md), motivated by
|
||
> [YIP-0001](proposals/yip-0001-forward-secret-signaling.md). See [README](README.md).
|
||
|
||
YAW/2 is a small, trusted, peer-to-peer encrypted mesh — chat, presence, and file
|
||
transfer — that pierces NAT the way the modern web does (ICE) and rides a modern
|
||
encrypted transport (WebRTC DataChannels). A lightweight server (the **anchor**)
|
||
helps peers *find* and *introduce* each other but never carries their data.
|
||
|
||
## 0. Design goals & decisions
|
||
|
||
- **True peer-to-peer.** Data flows directly between peers. The anchor only does
|
||
signaling + STUN. *No relay (TURN).* Consequence accepted: peers learn each
|
||
other's IP on a direct link, and a minority of peers behind symmetric NAT will
|
||
fail to connect.
|
||
- **Modern transport.** WebRTC **DataChannels** — DTLS 1.2/1.3 (PFS, AEAD),
|
||
congestion control, multiplexed reliable streams. File transfer is just a stream.
|
||
- **Modern identity.** **Ed25519** identities. Trust is friend-to-friend: you talk
|
||
to a peer only if its public key is in your keyring (exchanged out-of-band).
|
||
- **Browser-first.** The reference client is a web app (WebRTC native). A **Tauri**
|
||
shell wraps it for desktop (Electron is dropped); a **Python CLI** (`aiortc`)
|
||
speaks the same protocol.
|
||
- **The server learns as little as possible.** Signaling payloads are sealed end
|
||
to end, so the anchor never sees SDP, candidate IPs, chat, or files — only that
|
||
two fingerprints are online in the same (hashed) network and exchanging blobs.
|
||
|
||
## 1. Terminology
|
||
|
||
| Term | Meaning |
|
||
|------|---------|
|
||
| **node / peer** | A participant, identified by its Ed25519 public key. |
|
||
| **id** | Lowercase hex of the 32-byte Ed25519 public key (64 chars). The node's identity. |
|
||
| **short id** | First 16 hex chars of `id`, grouped in 4s, for human verification. |
|
||
| **keyring** | The set of peer `id`s you have accepted (trust). |
|
||
| **network** | A named group. Scoped on the server by `net = hex(sha256("yaw2-net:" + name))`, so the server never sees the name. |
|
||
| **anchor** | The server: a WebSocket **signaling** endpoint + a **STUN** server. |
|
||
| **session** | One established WebRTC PeerConnection between two peers. |
|
||
|
||
## 2. Identity & trust
|
||
|
||
- Each node has an **Ed25519** keypair. The 32-byte public key (hex) is its `id`.
|
||
- A node connects to another **only if that peer's `id` is in its keyring.** Keys
|
||
are exchanged out of band (paste the hex, a QR code, or `yaw://key/<id>`).
|
||
- Human verification: compare **short id**s ("read me yours") before accepting.
|
||
- The keyring is the sole trust root. The anchor is *not* trusted to vouch for
|
||
identities; it cannot impersonate a peer (§7).
|
||
|
||
## 3. Cryptography
|
||
|
||
| Purpose | Primitive |
|
||
|---------|-----------|
|
||
| Identity / signatures | **Ed25519** (libsodium `crypto_sign_detached`) |
|
||
| Signaling confidentiality + sender auth | **X25519 + crypto_box** (XSalsa20-Poly1305). X25519 keys derived from the Ed25519 identity via `crypto_sign_ed25519_{pk,sk}_to_curve25519`. |
|
||
| Transport | **WebRTC DTLS** (ECDHE, AES-GCM/ChaCha20-Poly1305) over SCTP DataChannels. Per-session forward secrecy. |
|
||
| Hashes | SHA-256 (network scoping, file integrity) |
|
||
|
||
> **Why the DTLS cert is *not* your Ed25519 key.** Browsers generate their own
|
||
> ephemeral cert for `RTCPeerConnection`; you cannot make it your identity key.
|
||
> Instead we **bind** the identity to the session: the SDP (which contains the
|
||
> DTLS certificate fingerprint) travels inside an Ed25519-authenticated sealed
|
||
> box, and peers re-confirm with a signed `HELLO` over both fingerprints once the
|
||
> channel opens (§6). The result is equivalent: the encrypted channel is provably
|
||
> to the holder of the trusted Ed25519 key.
|
||
|
||
All implementations use **libsodium** (PyNaCl, libsodium.js, or a Rust binding) so
|
||
the signing and sealing are byte-identical across clients.
|
||
|
||
## 4. Architecture
|
||
|
||
```
|
||
┌──────────────── anchor (server) ────────────────┐
|
||
│ WSS signaling (relays sealed blobs by id) │
|
||
│ STUN udp/3478 (public-address discovery) │
|
||
└───────▲───────────────────────────▲─────────────┘
|
||
│ sealed offer/answer/cands │
|
||
┌───────┴───────┐ ┌───────┴───────┐
|
||
│ peer A │◀═══════════▶│ peer B │
|
||
│ web / cli / │ WebRTC │ web / cli / │
|
||
│ tauri │ DataChannel │ tauri │
|
||
└───────────────┘ (DTLS, P2P) └───────────────┘
|
||
direct, encrypted, no server in path
|
||
```
|
||
|
||
The anchor has **two** jobs and sees **no** user data:
|
||
|
||
1. **Signaling (WebSocket, `wss://`)** — authenticates members of a network and
|
||
relays opaque sealed blobs between them by `id`.
|
||
2. **STUN (UDP/3478)** — standard STUN (RFC 5389), e.g. `stun:<anchor-host>:3478`,
|
||
used as an ICE server so peers learn their public (server-reflexive) address.
|
||
*STUN only — no TURN relay.*
|
||
|
||
## 5. Signaling protocol (WebSocket, JSON)
|
||
|
||
One WebSocket per node. All frames are **UTF-8 text** JSON objects with a `type`
|
||
(binary WebSocket frames are ignored).
|
||
|
||
**Encoding conventions (apply everywhere unless stated):**
|
||
|
||
- **hex** = lowercase, no separators.
|
||
- **base64** = standard RFC 4648 **with padding** (libsodium `base64_variants.ORIGINAL`
|
||
— *not* the url-safe / no-padding default).
|
||
- **`net`** = `hex(sha256(b))` where `b = utf8("yaw2-net:" + name)`; the name is taken
|
||
**verbatim** (case-sensitive, no normalization/trimming).
|
||
- **`id`** = `hex(ed25519_public_key)` (32 bytes → 64 hex).
|
||
- Signature inputs are raw bytes (concatenated as written); signatures are Ed25519
|
||
detached (64 bytes), hex-encoded.
|
||
|
||
### 5.1 Join (authentication)
|
||
|
||
```
|
||
server → { "v":"yaw/2.0", "type":"challenge", "nonce":"<32-byte hex>" }
|
||
client → { "type":"join",
|
||
"id": "<ed25519 pubkey hex>",
|
||
"net": "<net hex>",
|
||
"sig": "<ed25519 sig, hex>" }
|
||
server → { "type":"joined", "peers":[ "<id>", ... ] } // current members of net
|
||
```
|
||
|
||
`sig` is over the **exact bytes** `nonce_raw || net_ascii`, where `nonce_raw =
|
||
hex_decode(nonce)` (32 bytes) and `net_ascii = utf8(net)` (the 64 ASCII hex
|
||
chars). The server verifies `sig` against `id`, then registers the socket under
|
||
`(net, id)`. A node may join only one `net` per socket. Bad signature → the server
|
||
closes with code **4001**; a second connection for the same `(net, id)` displaces
|
||
the first with code **4002**.
|
||
|
||
### 5.2 Presence
|
||
|
||
```
|
||
server → { "type":"peer-join", "id":"<id>" }
|
||
server → { "type":"peer-leave", "id":"<id>" }
|
||
```
|
||
|
||
Pushed to all members of the same `net` as peers come and go.
|
||
|
||
### 5.3 Sealed relay
|
||
|
||
```
|
||
client → { "type":"to", "to":"<id>", "box":"<base64 crypto_box>" }
|
||
server → { "type":"from", "from":"<id>", "box":"<base64 crypto_box>" }
|
||
```
|
||
|
||
The server forwards `box` verbatim to the socket registered for `(net, to)`,
|
||
stamping the real `from`. **The server cannot read `box`.** If `to` is offline,
|
||
the server replies `{ "type":"no-peer", "to":"<id>" }`.
|
||
|
||
### 5.4 Sealed payload (inside `box`)
|
||
|
||
`box = crypto_box(plaintext, nonce, recipient_x25519_pub, sender_x25519_priv)`
|
||
(X25519 + XSalsa20-Poly1305), serialized as **`base64(nonce(24) || mac(16) ||
|
||
ciphertext)`** (i.e. the 24-byte nonce prepended to libsodium's combined-mode
|
||
output; PyNaCl's `Box.encrypt(msg, nonce)` already produces exactly this). The
|
||
X25519 keys are derived from the Ed25519 identities (§3); the recipient uses the
|
||
sender's, taken from the `from` id. `plaintext` is JSON:
|
||
|
||
```
|
||
{ "kind": "offer" | "answer" | "candidate" | "bye",
|
||
"sdp": "<full SDP>", // for offer/answer (candidates embedded; see §6)
|
||
"cand": "<ICE candidate line>", "mid":"0", "mline":0 } // optional trickle (§6)
|
||
```
|
||
|
||
Because the box is authenticated by the sender's identity key, a received offer's
|
||
SDP — **including the DTLS fingerprint** — is bound to that identity.
|
||
|
||
## 6. Connection establishment
|
||
|
||
Both peers are joined to the same `net` and **each has the other's `id` in its
|
||
keyring**. (Untrusted `id` → ignore, or hold for manual accept.)
|
||
|
||
**Who offers:** the peer with the lexicographically **smaller `id`** is the
|
||
*offerer* (deterministic; avoids glare).
|
||
|
||
```
|
||
A = offerer (smaller id) B = answerer
|
||
─────────────────────────────── ───────────────────────────────
|
||
pc = RTCPeerConnection({iceServers:[stun]}) pc = RTCPeerConnection({iceServers:[stun]})
|
||
dc = pc.createDataChannel("yaw") pc.ondatachannel = …
|
||
createOffer; setLocalDescription
|
||
WAIT for ICE gathering complete ◀── candidates embedded in SDP (non-trickle)
|
||
seal(offer.sdp) ─────────"to B"────────────────▶ verify from∈keyring; setRemoteDescription
|
||
createAnswer; setLocalDescription
|
||
WAIT for ICE gathering complete
|
||
verify; setRemoteDescription ◀──"to A"─seal(answer.sdp)
|
||
ICE connectivity checks (host + srflx) → DTLS handshake
|
||
"yaw" DataChannel opens on both sides
|
||
───────────────── identity confirm (mandatory) ─────────────────
|
||
each side, on open, sends on "yaw":
|
||
{ "type":"hello", "id":"<self id>", "nick":"…", "sig":"<hex>" }
|
||
verify (below). Mismatch → close the connection.
|
||
```
|
||
|
||
**ICE is non-trickle (baseline).** After `setLocalDescription`, wait until ICE
|
||
gathering is `complete`, then send the SDP with candidates embedded. Sending extra
|
||
`{kind:"candidate"}` messages is **optional** and additive; receivers MUST accept
|
||
candidates from the SDP and SHOULD accept trickled ones. Gather **host +
|
||
server-reflexive** (STUN) candidates; no TURN. If ICE fails (both behind symmetric
|
||
NAT) the session is abandoned (relay is optional, §8.4).
|
||
|
||
**DataChannels are in-band negotiated** (`negotiated:false`): the offerer creates
|
||
`"yaw"`; the answerer receives it via `ondatachannel`. ⚠️ *A received channel may
|
||
already be `open` when `ondatachannel`/event fires — send your `hello` both on the
|
||
`open` event **and** immediately if `readyState === "open"`, or you will deadlock.*
|
||
|
||
**Identity confirm — exact bytes.** Let `lfp`/`rfp` be the **raw 32-byte** SHA-256
|
||
DTLS fingerprints parsed from the local/remote SDP `a=fingerprint:sha-256 …` lines
|
||
(strip the colons, hex-decode). Implementations MUST use `sha-256` fingerprints.
|
||
|
||
- **Sender** signs `B = utf8("yaw/2 bind") || lfp || rfp` (its *own* local then
|
||
remote) and sends `sig = hex(ed25519_sign(B))` in `hello`.
|
||
- **Verifier** reconstructs the sender's bytes — which are the verifier's **remote
|
||
then local** — i.e. checks `ed25519_verify(sig, utf8("yaw/2 bind") || rfp || lfp,
|
||
peer_id)` **and** that `peer_id` equals the expected (keyring) id. Either check
|
||
failing ⇒ close.
|
||
|
||
After `hello` verification the session is **trusted and live**.
|
||
|
||
## 7. Why this is safe against a malicious anchor
|
||
|
||
- The anchor relays only **sealed, sender-authenticated** blobs, so it cannot read
|
||
or forge SDP/candidates, and cannot inject its own DTLS fingerprint (that would
|
||
require an Ed25519 signature it cannot produce).
|
||
- The `hello` confirmation re-binds the live channel to both DTLS fingerprints
|
||
under each identity's signature.
|
||
- Therefore a hostile anchor can: see who is online in a `net`, see *that* two ids
|
||
exchange blobs, drop/delay messages, and learn timing. It **cannot**: read or
|
||
alter chat/files, MITM the channel, learn candidate IPs, or recover the network
|
||
name (only confirm a guess of it).
|
||
|
||
## 8. Application protocol (over the `yaw` DataChannel)
|
||
|
||
The `yaw` channel is reliable + ordered. Each DataChannel message is one
|
||
UTF-8 JSON object (DataChannels are message-framed — no length prefix needed).
|
||
Unknown `type`s and unknown fields are ignored (forward compatibility). In v1
|
||
(full mesh, §8.4) there are no duplicates, so **`mid` is optional**; it becomes
|
||
**required only when relay (`hops`) is used**, as a random 16-byte hex id for dedup.
|
||
|
||
| type | fields | meaning |
|
||
|------|--------|---------|
|
||
| `hello` | `id, nick, sig` (+ optional `caps[]`) | identity confirm (§6); first message |
|
||
| `presence` | `online:bool, nick` | online/away |
|
||
| `chat` | `room, text, ts` | group message to a room (default `#main`) |
|
||
| `pm` | `text, ts` | private message (this link only) |
|
||
| `file-offer` | `xid, name, size, sha256` | offer to send a file |
|
||
| `file-accept` | `xid` | accept an offer |
|
||
| `file-cancel` | `xid, reason` | decline / abort |
|
||
| `file-done` | `xid, sha256` | sender finished; verify hash |
|
||
| `bye` | — | graceful close |
|
||
|
||
`ts` is Unix milliseconds (advisory). `room` names are app-defined strings.
|
||
|
||
### 8.4 Group delivery (v1 = full mesh)
|
||
|
||
In v1 each peer connects **directly to every other** peer in the network (full
|
||
mesh of sessions). `chat`/`presence` are sent to **all** open sessions; `pm` to one.
|
||
Each message is received once per session, so no dedup is needed (and `mid` may be
|
||
omitted). Dedup matters only once relay is enabled below.
|
||
|
||
> *Forward-compatible relay (optional, v1.1):* messages may carry `hops` (int, ≤4).
|
||
> A node receiving a message with `hops>0` whose `mid` is new MAY re-send it to its
|
||
> other peers with `hops-1`, restoring connectivity across pairs that couldn't form
|
||
> a direct session. v1 senders set `hops:0` (no relay).
|
||
|
||
## 9. File transfer (over a dedicated DataChannel)
|
||
|
||
Files ride their own channel so a large transfer never blocks chat.
|
||
|
||
```
|
||
sender receiver
|
||
file-offer {xid,name,size,sha256} ──"yaw"───────▶ (user accepts)
|
||
◀──"yaw"──── file-accept {xid}
|
||
open DataChannel label="f:<xid>" (ordered,binary)
|
||
stream raw chunks (default 64 KiB), honoring
|
||
bufferedAmountLowThreshold for backpressure ───▶ append to file; running sha256
|
||
close "f:<xid>" after last chunk
|
||
file-done {xid, sha256} ────────"yaw"───────────▶ verify sha256; success/failure
|
||
```
|
||
|
||
- Chunk size: **64 KiB** default — but never exceed the session's negotiated
|
||
`a=max-message-size` (SDP); clamp down if the peer advertises a smaller limit.
|
||
- Integrity: SHA-256 over the whole file, sent in the offer and re-asserted in
|
||
`file-done`; the receiver verifies before accepting the file.
|
||
- The transport (DTLS) already encrypts; no extra app-layer file encryption.
|
||
- Either side may `file-cancel {xid}`; the data channel is closed.
|
||
|
||
## 10. Reference parameters
|
||
|
||
| Parameter | Value |
|
||
|-----------|-------|
|
||
| Protocol version | `yaw/2.0` |
|
||
| Signaling | `wss://<your-anchor>/<secret-path>/signal` (WebSocket) **[deployed & verified]** |
|
||
| STUN | `stun:<anchor-host>:3478` (coturn, STUN-only, **deployed & verified**) |
|
||
| Network scope | `net = hex(sha256("yaw2-net:" + name))` |
|
||
| Identity | Ed25519; `id = hex(pubkey)` (64 chars) |
|
||
| Signaling seal | libsodium `crypto_box`, `base64_ORIGINAL(nonce(24)||mac(16)||ct)` |
|
||
| Bind (sign) | `utf8("yaw/2 bind") || local_fp || remote_fp` (raw 32-byte fps) |
|
||
| Signaling close codes | 4001 = auth failed · 4002 = displaced by reconnect |
|
||
| DataChannel (control) | label `yaw`, reliable, ordered |
|
||
| DataChannel (file) | label `f:<xid>`, reliable, ordered, binary |
|
||
| File chunk | 64 KiB |
|
||
| Default room | `#main` |
|
||
|
||
## 11. Security considerations
|
||
|
||
- **IP exposure (by design).** On a direct session each peer sees the other's host
|
||
(LAN) and server-reflexive (public) addresses. The *anchor* does not (sealed
|
||
signaling). To reduce LAN leakage, a client MAY gather srflx-only candidates at
|
||
some connectivity cost.
|
||
- **Symmetric-NAT pairs may not connect** (no TURN). Optional app-relay (§8.4) or a
|
||
future opt-in TURN can recover these.
|
||
- **Signaling metadata.** The anchor learns presence and the contact graph within a
|
||
`net`, plus timing. It does not learn names, content, or IPs.
|
||
- **Signaling boxes are not forward-secret** (static X25519). They carry only
|
||
short-lived SDP/candidates; the *session* keys are DTLS-ephemeral (PFS). A future
|
||
revision may use ephemeral signaling keys.
|
||
- **Trust bootstrapping is out of band.** Compromise of the keyring exchange (e.g.
|
||
accepting a wrong `id`) defeats the system — verify short ids.
|
||
- **Replay.** The join `nonce` is single-use (server-issued per connection); DTLS
|
||
prevents transport replay; `mid` dedups app messages once relay is enabled.
|
||
|
||
## 12. Differences from YAW/1 (WASTE)
|
||
|
||
| | YAW/1 (WASTE-faithful) | YAW/2 |
|
||
|--|--|--|
|
||
| Identity | RSA + SHA-1 fingerprint | Ed25519, key = id |
|
||
| Transport crypto | Blowfish-PCBC (legacy) | WebRTC DTLS (AEAD, PFS) |
|
||
| Handshake | custom 30-step | ICE + DTLS + signed bind |
|
||
| NAT traversal | none (needs reachable peer) | ICE/STUN (true P2P) |
|
||
| Server role | rendezvous *directory* | signaling + STUN, sealed |
|
||
| Topology | flood mesh w/ TTL | direct full mesh (relay optional) |
|
||
| Transport library | hand-rolled sockets | WebRTC (browser/aiortc) |
|
||
|
||
## 13. Open questions / future
|
||
|
||
- Opt-in **TURN** for symmetric-NAT pairs (breaks "no relay" — explicit choice).
|
||
- **Gossip relay** (§8.4 `hops`) for partial-connectivity resilience.
|
||
- **Ephemeral signaling keys** for forward-secret signaling.
|
||
- **Post-quantum**: hybrid X25519+ML-KEM once WebRTC/libsodium support is routine.
|
||
- **Room key distribution** (anchor optionally serves a network's member ids to
|
||
ease group bootstrapping, still keyring-gated).
|
||
|
||
## 14. Implementing & testing against the live server
|
||
|
||
The reference infra is **live**: STUN `stun:<anchor-host>:3478` and signaling
|
||
`wss://<your-anchor>/<secret-path>/signal` (both deployed & verified). To interop:
|
||
|
||
1. Open the WebSocket, complete the `join` (§5.1), and you'll see `peers` /
|
||
`peer-join` — that alone confirms your Ed25519 join signature is correct.
|
||
2. Pick a shared **network name** with your test partner; both hash it identically
|
||
(§5 conventions). Then run the connection flow (§6).
|
||
3. Cross-check against the reference client: run `cli/spike_peer.py <network>`
|
||
(Python/aiortc) — it will dial your implementation and chat/transfer a file.
|
||
|
||
**Three gotchas that break naïve implementations (learned the hard way):**
|
||
|
||
- **base64 variant.** The seal is **standard padded** base64, not libsodium's
|
||
default url-safe/no-padding. Using `to_base64(x)` without
|
||
`base64_variants.ORIGINAL` produces an unopenable box.
|
||
- **bind byte order on verify.** The sender signs `prefix||local||remote`; the
|
||
verifier must reconstruct `prefix||remote||local` (its remote = the sender's
|
||
local). Getting this backwards verifies nothing.
|
||
- **answerer DataChannel open-race.** The received `"yaw"` channel is often already
|
||
`open` when you get it; send your `hello` on `readyState==="open"` too, not only
|
||
the `open` event, or both sides wait forever.
|
||
|
||
> **Versioning.** 2.0 is **locked**. The one known weakness — signaling is not
|
||
> forward-secret (§11) — is intentionally left as-is here so interop is stable. The
|
||
> fix is specified separately as **[yaw/2.1](yaw2.1-protocol.md)** and motivated in
|
||
> **[YIP-0001](proposals/yip-0001-forward-secret-signaling.md)**; 2.1 peers fall
|
||
> back to 2.0 so both versions interoperate.
|
||
|
||
---
|
||
|
||
*Implement against §5–§9; everything else is rationale. Clients MUST interoperate
|
||
at the signaling JSON, the sealed-payload format, the identity-confirm `hello`, and
|
||
the application message types.*
|
||
|
||
|
||
# YAW/2.1 — Protocol Specification (forward-secret signaling)
|
||
|
||
**Version:** `yaw/2.1` · **Status:** 📝 **DRAFT** (proposed) · motivated by
|
||
[YIP-0001](proposals/yip-0001-forward-secret-signaling.md).
|
||
|
||
> **2.1 = [2.0](yaw2.0-protocol.md) + forward-secret signaling.** This document is a
|
||
> **delta**: everything in [yaw2.0-protocol.md](yaw2.0-protocol.md) still applies
|
||
> *except* the sections replaced below (§3, §5.4, §6). Identity, signaling
|
||
> transport (§5.1–§5.3), the application protocol (§8), and file transfer (§9) are
|
||
> **unchanged**. 2.1 peers **interoperate with 2.0** by falling back (§6.1).
|
||
|
||
## What changes vs 2.0
|
||
|
||
The only change is the key material used to seal `offer`/`answer`/`candidate`
|
||
signaling payloads: 2.0 uses **static** X25519 keys (from the long-term Ed25519
|
||
identity); 2.1 uses **per-session ephemeral** X25519 keys, wiped after the session,
|
||
introduced by a new signed **`ekey`** message. This makes the signaling
|
||
forward-secret (see the YIP for the threat it closes). Nothing else changes.
|
||
|
||
---
|
||
|
||
## §3′ Cryptography (replaces 2.0 §3)
|
||
|
||
Unchanged from 2.0 **except** the "Signaling confidentiality" row:
|
||
|
||
| Purpose | Primitive |
|
||
|---------|-----------|
|
||
| Identity / signatures | **Ed25519** (unchanged) |
|
||
| **Signaling — `ekey` exchange** | sealed with **static** X25519 (`crypto_box`, keys derived from Ed25519 as in 2.0). Carries only ephemeral *public* keys. |
|
||
| **Signaling — offer/answer/candidate** | sealed with **ephemeral** X25519: `crypto_box(plaintext, nonce, peer_epk, my_esk)`, where `(esk, epk)` is a fresh per-session X25519 keypair. |
|
||
| Transport | **WebRTC DTLS** (unchanged; already PFS) |
|
||
| Hashes | SHA-256 (unchanged) |
|
||
|
||
Each peer generates `(esk, epk) = crypto_box_keypair()` **per session** and
|
||
**securely wipes `esk`** when the session ends or is abandoned. `epk` is exchanged
|
||
and authenticated via the `ekey` message (§5.4′).
|
||
|
||
All seal serialization (`base64_ORIGINAL(nonce(24)||mac(16)||ct)`) is exactly as in
|
||
2.0 — only the *keys* differ.
|
||
|
||
## §5.4′ Sealed payloads (replaces 2.0 §5.4)
|
||
|
||
The relay envelope (`{type:"to"/"from", box}`) is unchanged. Two keying schemes now
|
||
exist for the inner `box`:
|
||
|
||
**(a) `ekey` — sealed under STATIC keys** (as in 2.0):
|
||
|
||
```
|
||
{ "kind":"ekey",
|
||
"v": "yaw/2.1",
|
||
"epk": "<x25519 ephemeral public key, hex (32 bytes)>",
|
||
"sig": "<ed25519 sig, hex>" }
|
||
```
|
||
|
||
`sig` is over the exact bytes
|
||
`utf8("yaw/2.1 ekey") || my_id_raw(32) || peer_id_raw(32) || epk_raw(32)`
|
||
(`*_raw = hex_decode`). Binding both ids prevents an `ekey` from being replayed to
|
||
a third party. The recipient verifies `sig` against the sender id and that the
|
||
embedded `peer_id` is *itself*.
|
||
|
||
**(b) `offer` / `answer` / `candidate` / `bye` — sealed under EPHEMERAL keys:**
|
||
identical JSON to 2.0 §5.4, but the `box` is `crypto_box(…, peer_epk, my_esk)`.
|
||
|
||
**Which key opens an incoming box?** Determined by ordering, not a plaintext tag
|
||
(so the server learns nothing extra): a peer always sends its `ekey` *before* any
|
||
ephemeral box, and the WebSocket preserves per-sender order. Therefore:
|
||
|
||
- `kind:"ekey"` → open with **static** keys.
|
||
- any other kind → if you already hold the sender's `epk`, open with **ephemeral**
|
||
keys; if you do not (sender sent no `ekey`), the sender is a 2.0 peer → open with
|
||
**static** keys (§6.1). Implementations MAY also try-both (a wrong key fails the
|
||
Poly1305 tag cleanly) for robustness.
|
||
|
||
## §6′ Connection establishment (replaces 2.0 §6)
|
||
|
||
Preconditions as in 2.0 (same `net`, peer id in keyring). Offerer = smaller id.
|
||
|
||
```
|
||
A = offerer (smaller id) B = answerer
|
||
────────────────────────────── ──────────────────────────────
|
||
esk_A, epk_A = box_keypair() esk_B, epk_B = box_keypair()
|
||
sealStatic(ekey{epk_A,sig}) ──"to B"───────────▶ verify ekey; store epk_A
|
||
store epk_B ◀──────"to A"── sealStatic(ekey{epk_B,sig})
|
||
createOffer; setLocalDescription; gather-complete
|
||
sealEph(offer.sdp) ───"to B"───────────────────▶ verify from∈keyring; setRemoteDescription
|
||
createAnswer; setLocalDescription; gather-complete
|
||
verify; setRemoteDescription ◀──"to A"── sealEph(answer.sdp)
|
||
ICE checks (host + srflx) → DTLS → "yaw" DataChannel opens
|
||
identity-confirm `hello` exactly as in 2.0 §6
|
||
── on session close/abandon: WIPE esk ──
|
||
```
|
||
|
||
- `sealStatic(...)` = 2.0 static-key box; `sealEph(...)` = ephemeral-key box.
|
||
- Both peers send `ekey` first (no offerer/answerer distinction for `ekey`).
|
||
- The offerer sends the `offer` only after it holds `epk_B`; the answerer sends the
|
||
`answer` only after it has both sent its `ekey` and received the `offer`. Because
|
||
a sender's `ekey` precedes its ephemeral boxes and the channel is ordered, the
|
||
recipient always holds the peer's `epk` before any ephemeral box arrives.
|
||
- Everything after the DataChannel opens (the signed `hello`, §8, §9) is **identical
|
||
to 2.0**.
|
||
|
||
### §6.1 Backward compatibility (opportunistic FS)
|
||
|
||
2.1 ↔ 2.0 must interoperate. Rules:
|
||
|
||
1. A 2.1 peer sends its `ekey`, then starts a short timer (recommended **2 s**).
|
||
2. **2.1 offerer:** if `epk_B` arrives before the timer, send the `offer` with
|
||
`sealEph`. If the timer fires first (no `ekey` — peer is 2.0), send the `offer`
|
||
with `sealStatic` and mark the session **non-FS**.
|
||
3. **2.1 answerer:** if an `offer` arrives and you hold `epk_A`, reply `sealEph`.
|
||
If an `offer` arrives and you do **not** hold `epk_A` (2.0 offerer), reply
|
||
`sealStatic` and mark the session **non-FS**.
|
||
4. A 2.0 peer ignores the unknown `ekey` kind (2.0 §8: "unknown types ignored") and
|
||
behaves exactly as 2.0.
|
||
|
||
A client MAY enforce a **require-FS** policy (refuse / close non-FS sessions);
|
||
otherwise it MUST surface the non-FS status to the user.
|
||
|
||
## §10′ Reference parameters (additions to 2.0 §10)
|
||
|
||
| Parameter | Value |
|
||
|-----------|-------|
|
||
| Protocol version | `yaw/2.1` (advertised in the `ekey` `v` field) |
|
||
| Ephemeral key | X25519, `crypto_box_keypair()`, per session, `esk` wiped on close |
|
||
| `ekey` sign input | `utf8("yaw/2.1 ekey") \|\| my_id_raw \|\| peer_id_raw \|\| epk_raw` |
|
||
| `ekey` seal | static keys (2.0 scheme) |
|
||
| offer/answer/candidate seal | ephemeral keys `crypto_box(·, peer_epk, my_esk)` |
|
||
| FS-negotiation timeout | 2 s (then fall back to 2.0) |
|
||
|
||
## Security & compatibility notes
|
||
|
||
See [YIP-0001 §6](proposals/yip-0001-forward-secret-signaling.md) for the full
|
||
analysis. In short: pure-2.1 sessions are forward-secret (recorded signaling
|
||
unrecoverable after `esk` is wiped, even if long-term keys leak later); mixed 2.1/2.0
|
||
sessions fall back to 2.0 security and are flagged; authentication and the
|
||
malicious-server analysis (2.0 §7) are unchanged. |