feat: per-network share directories + isolation test

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>
This commit is contained in:
Fredrik Johansson
2026-06-22 15:13:26 +02:00
parent f437fe94f4
commit d02e18e212
7 changed files with 277 additions and 42 deletions

View File

@@ -378,4 +378,142 @@ The reference infra is **live**: STUN `stun:<anchor-host>:3478` and signaling
*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.*
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.