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>
27 KiB
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, motivated by YIP-0001. See README.
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 ids 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
idis in its keyring. Keys are exchanged out of band (paste the hex, a QR code, oryaw://key/<id>). - Human verification: compare short ids ("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 signedHELLOover 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:
- Signaling (WebSocket,
wss://) — authenticates members of a network and relays opaque sealed blobs between them byid. - 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))whereb = 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 sendssig = hex(ed25519_sign(B))inhello. - 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 thatpeer_idequals 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
helloconfirmation 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 types 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 withhops>0whosemidis new MAY re-send it to its other peers withhops-1, restoring connectivity across pairs that couldn't form a direct session. v1 senders sethops: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) |
| Bind (sign) | `utf8("yaw/2 bind") |
| 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
nonceis single-use (server-issued per connection); DTLS prevents transport replay;middedups 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:
- Open the WebSocket, complete the
join(§5.1), and you'll seepeers/peer-join— that alone confirms your Ed25519 join signature is correct. - Pick a shared network name with your test partner; both hash it identically (§5 conventions). Then run the connection flow (§6).
- 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)withoutbase64_variants.ORIGINALproduces an unopenable box. - bind byte order on verify. The sender signs
prefix||local||remote; the verifier must reconstructprefix||remote||local(its remote = the sender's local). Getting this backwards verifies nothing. - answerer DataChannel open-race. The received
"yaw"channel is often alreadyopenwhen you get it; send yourhelloonreadyState==="open"too, not only theopenevent, 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 and motivated in YIP-0001; 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.
2.1 = 2.0 + forward-secret signaling. This document is a delta: everything in 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 noekey), 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
ekeyfirst (no offerer/answerer distinction forekey). - The offerer sends the
offeronly after it holdsepk_B; the answerer sends theansweronly after it has both sent itsekeyand received theoffer. Because a sender'sekeyprecedes its ephemeral boxes and the channel is ordered, the recipient always holds the peer'sepkbefore 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:
- A 2.1 peer sends its
ekey, then starts a short timer (recommended 2 s). - 2.1 offerer: if
epk_Barrives before the timer, send theofferwithsealEph. If the timer fires first (noekey— peer is 2.0), send theofferwithsealStaticand mark the session non-FS. - 2.1 answerer: if an
offerarrives and you holdepk_A, replysealEph. If anofferarrives and you do not holdepk_A(2.0 offerer), replysealStaticand mark the session non-FS. - A 2.0 peer ignores the unknown
ekeykind (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 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.