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
2026-06-22 15:13:26 +02:00
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.