Files
waste-go/PROTOCOL.md

519 lines
27 KiB
Markdown
Raw Normal View History

Bring wire protocol into full YAW/2 spec compliance Five interop issues fixed against PROTOCOL.md: - Join signature now covers nonce_raw || net_ascii (64-char UTF-8 hex string) as specified in §5.1, not net_raw_bytes. Both anchor server and client updated to match. - Chat wire fields renamed to spec names: text/ts (Unix ms int64) replacing body/sent_at (ISO timestamp). Flat layout on PeerMessage matches §8 exactly; store and TUI updated accordingly. - Direct messages now use the spec "pm" type (flat {type,mid,text,ts}) instead of chat+to. Receiver reconstructs a ChatMessage with dm:<short-id> room for IPC/storage. §8 compliant. - File transfer message types changed to spec hyphenated names: file-offer, file-accept, file-cancel, file-done with spec field names (name/size not filename/size_bytes). §9 compliant. - DataChannel open-race (§14 gotcha #3) fixed with sync.Once: doOpen fires on OnOpen callback or immediately if the channel is already open when WireDataChannel is called (answerer race). Also fixes two bugs found during testing: - mid was missing from outgoing wire messages, causing all received messages to arrive with mid="" and collide on the UNIQUE DB constraint. mid is now included on all sent chat/pm messages; a random mid is generated for any received message that omits it. - Test scripts hardened: kill -9 + active lsof polling replaces blind sleep for port cleanup; join_network sent before peer_field queries (local_peer is now network-scoped and nil until joined). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 11:38:01 +02:00
# 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.