diff --git a/PROTOCOL.md b/PROTOCOL.md index ea36d1b..8811210 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -378,4 +378,142 @@ The reference infra is **live**: STUN `stun::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.* \ No newline at end of file +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": "", + "sig": "" } +``` + +`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. \ No newline at end of file diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 1a4d69d..7f59d3c 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -50,7 +50,7 @@ func main() { }) if autoJoinNetwork != "" { - if _, err := mgr.Join(autoJoinNetwork); err != nil { + if _, err := mgr.Join(autoJoinNetwork, ""); err != nil { log.Fatalf("auto-join: %v", err) } } diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 5d6560a..1b29166 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -117,12 +117,12 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) { send(errMsg("join_network: network_name is required")) continue } - netID, err := mgr.Join(cmd.NetworkName) + netID, err := mgr.Join(cmd.NetworkName, cmd.ShareDir) if err != nil { send(errMsg(fmt.Sprintf("join_network: %v", err))) continue } - // network_joined event is emitted by Manager.Join; nothing extra needed. + // network_joined event (with share_dir) is emitted by Manager.Join. _ = netID case proto.CmdLeaveNetwork: @@ -246,6 +246,17 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) { InviteString: inv, }) + case proto.CmdSetShareDir: + n := mgr.Resolve(cmd.NetworkID) + if n == nil { + send(errMsg("set_share_dir: not joined to any network")) + continue + } + if !mgr.SetShareDir(n.ID, cmd.Path) { + send(errMsg("set_share_dir: network not found")) + continue + } + case proto.CmdSendFile: n := mgr.Resolve(cmd.NetworkID) if n == nil { @@ -292,6 +303,8 @@ func stateSnapshot(mgr *netmgr.Manager) proto.IpcMessage { NetworkID: n.ID, NetworkName: n.Name, LocalPeer: &pi, + ShareDir: n.Mesh.ShareDir, + DownloadDir: n.Mesh.DownloadDir, }) } msg.Networks = netInfos @@ -322,7 +335,7 @@ func randomHex(n int) string { // It joins the network before the IPC listener starts accepting clients. func AutoJoin(ctx context.Context, mgr *netmgr.Manager, networkName string) { _ = ctx // Manager owns the context internally - if _, err := mgr.Join(networkName); err != nil { + if _, err := mgr.Join(networkName, ""); err != nil { log.Printf("ipc: auto-join %q failed: %v", networkName, err) } } diff --git a/internal/netmgr/manager.go b/internal/netmgr/manager.go index 57051c2..37e060c 100644 --- a/internal/netmgr/manager.go +++ b/internal/netmgr/manager.go @@ -25,7 +25,7 @@ type Config struct { MasterIdentity *crypto.Identity StoreDir string // base directory for per-network SQLite files AnchorURL string // WebSocket anchor URL used for all networks - ShareDir string // shared file directory (global for now; per-network in future) + ShareDir string // default share directory; overridden per network via Join or SetShareDir } // Network is a single joined network context. @@ -41,6 +41,9 @@ type Network struct { cancel context.CancelFunc } +// ShareDir returns the share directory for this network (from the mesh). +func (n *Network) ShareDir() string { return n.Mesh.ShareDir } + // Manager owns all joined networks and fans out their events to IPC subscribers. type Manager struct { cfg Config @@ -63,7 +66,8 @@ func New(cfg Config) *Manager { // Join creates or rejoins a named network. Returns the network ID. // If the network is already joined the existing ID is returned immediately. -func (mgr *Manager) Join(name string) (string, error) { +// shareDir overrides the global default for this network; pass "" to use the default. +func (mgr *Manager) Join(name, shareDir string) (string, error) { netHash := hashNetName(name) netID := netHash[:8] @@ -89,7 +93,10 @@ func (mgr *Manager) Join(name string) (string, error) { } m := mesh.New(derived, st) - if mgr.cfg.ShareDir != "" { + // Per-network share dir takes precedence over the global default. + if shareDir != "" { + m.ShareDir = shareDir + } else if mgr.cfg.ShareDir != "" { m.ShareDir = mgr.cfg.ShareDir } m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID_full) @@ -130,10 +137,11 @@ func (mgr *Manager) Join(name string) (string, error) { } mgr.emit(proto.IpcMessage{ - Type: proto.EvtNetworkJoined, - NetworkID: netID, + Type: proto.EvtNetworkJoined, + NetworkID: netID, NetworkName: name, - LocalPeer: peerPtr(derived.PeerInfo()), + LocalPeer: peerPtr(derived.PeerInfo()), + ShareDir: m.ShareDir, }) return netID, nil @@ -159,6 +167,20 @@ func (mgr *Manager) Leave(netID string) { mgr.emit(proto.IpcMessage{Type: proto.EvtNetworkLeft, NetworkID: netID}) } +// SetShareDir updates the share directory for an already-joined network. +// Changes take effect immediately for subsequent file-list requests and offers. +func (mgr *Manager) SetShareDir(netID, path string) bool { + mgr.mu.RLock() + net, ok := mgr.networks[netID] + mgr.mu.RUnlock() + if !ok { + return false + } + net.Mesh.ShareDir = path + log.Printf("netmgr: share dir for %q set to %q", net.Name, path) + return true +} + // LeaveAll leaves every joined network. func (mgr *Manager) LeaveAll() { mgr.mu.RLock() diff --git a/internal/proto/proto.go b/internal/proto/proto.go index e2a6f8f..1e41c6a 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -217,6 +217,7 @@ const ( CmdLeaveNetwork IpcMsgType = "leave_network" CmdGetState IpcMsgType = "get_state" CmdSendFile IpcMsgType = "send_file" + CmdSetShareDir IpcMsgType = "set_share_dir" // set per-network share directory at runtime CmdGenerateInvite IpcMsgType = "generate_invite" CmdGetFileList IpcMsgType = "get_file_list" @@ -241,6 +242,8 @@ type NetworkInfo struct { NetworkID string `json:"network_id"` NetworkName string `json:"network_name"` LocalPeer *PeerInfo `json:"local_peer,omitempty"` + ShareDir string `json:"share_dir,omitempty"` // absolute path; empty = not sharing + DownloadDir string `json:"download_dir,omitempty"` // absolute path for received files } // IpcMessage covers both commands and events. @@ -258,8 +261,9 @@ type IpcMessage struct { // join_network / leave_network NetworkName string `json:"network_name,omitempty"` + ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory - // send_file + // send_file / set_share_dir / file_complete path Path string `json:"path,omitempty"` // events diff --git a/test-network.sh b/test-network.sh index b96751b..974df55 100755 --- a/test-network.sh +++ b/test-network.sh @@ -25,10 +25,19 @@ DATA_ROOT="/tmp/waste-test" rm -rf "$DATA_ROOT" mkdir -p "$DATA_ROOT/bin" -# Seed per-peer share directories with dummy files. -mkdir -p "$DATA_ROOT/alice/share" "$DATA_ROOT/bob/share" "$DATA_ROOT/charlie/share" -echo "alice's notes" > "$DATA_ROOT/alice/share/notes.txt" -dd if=/dev/urandom bs=1K count=64 2>/dev/null | base64 > "$DATA_ROOT/alice/share/photo.jpg.b64" +# Per-network share directories — isolation is the whole point: +# alice/friends-share → shared on the "friends" network only +# alice/work-share → shared on the "work" network only (alice joins both) +# bob/share → bob is only on "friends" +# charlie/share → charlie is only on "friends" +mkdir -p "$DATA_ROOT/alice/friends-share" "$DATA_ROOT/alice/work-share" +mkdir -p "$DATA_ROOT/bob/share" "$DATA_ROOT/charlie/share" + +echo "alice's notes" > "$DATA_ROOT/alice/friends-share/notes.txt" +dd if=/dev/urandom bs=1K count=64 2>/dev/null | base64 > "$DATA_ROOT/alice/friends-share/photo.jpg.b64" +echo "alice's report.pdf" > "$DATA_ROOT/alice/work-share/report.pdf" +echo "alice's budget.xlsx" > "$DATA_ROOT/alice/work-share/budget.xlsx" + dd if=/dev/urandom bs=1K count=128 2>/dev/null | base64 > "$DATA_ROOT/bob/share/archive.tar.b64" echo -e "file1.mp3\nfile2.mp3" > "$DATA_ROOT/bob/share/playlist.m3u" echo "charlie's doc" > "$DATA_ROOT/charlie/share/document.txt" @@ -235,7 +244,6 @@ log "$ALICE_COLOR" "alice" "starting daemon (ipc :${ALICE_IPC})" -data-dir "$DATA_ROOT/alice" \ -ipc-port "$ALICE_IPC" \ -anchor "$ANCHOR_URL" \ - -share-dir "$DATA_ROOT/alice/share" \ 2> >(tee "$DATA_ROOT/alice/daemon.log" | while IFS= read -r l; do echo -e "${ALICE_COLOR}${DIM}[alice] ${l}${RESET}"; done) & PIDS+=($!) @@ -245,7 +253,6 @@ log "$BOB_COLOR" "bob" "starting daemon (ipc :${BOB_IPC})" -data-dir "$DATA_ROOT/bob" \ -ipc-port "$BOB_IPC" \ -anchor "$ANCHOR_URL" \ - -share-dir "$DATA_ROOT/bob/share" \ 2> >(tee "$DATA_ROOT/bob/daemon.log" | while IFS= read -r l; do echo -e "${BOB_COLOR}${DIM}[bob] ${l}${RESET}"; done) & PIDS+=($!) @@ -255,7 +262,6 @@ log "$CHARLIE_COLOR" "charlie" "starting daemon (ipc :${CHARLIE_IPC})" -data-dir "$DATA_ROOT/charlie" \ -ipc-port "$CHARLIE_IPC" \ -anchor "$ANCHOR_URL" \ - -share-dir "$DATA_ROOT/charlie/share" \ 2> >(tee "$DATA_ROOT/charlie/daemon.log" | while IFS= read -r l; do echo -e "${CHARLIE_COLOR}${DIM}[charlie]${l}${RESET}"; done) & PIDS+=($!) @@ -267,14 +273,18 @@ echo "" # ── join network ────────────────────────────────────────────────────────────── echo -e "${DIM}────────────────────────────────────────────────────────${RESET}" echo -e "joining all peers to network: ${BOLD}${NETWORK_NAME}${RESET}" +echo -e "${DIM}share dirs are per-network (per-network isolation test)${RESET}" echo -e "${DIM}────────────────────────────────────────────────────────${RESET}" -JOIN=$(printf '{"type":"join_network","network_name":"%s"}' "$NETWORK_NAME") -ipc "$ALICE_IPC" "$JOIN" +# Each peer passes share_dir scoped to this network. +ipc "$ALICE_IPC" "$(jq -cn --arg net "$NETWORK_NAME" --arg dir "$DATA_ROOT/alice/friends-share" \ + '{"type":"join_network","network_name":$net,"share_dir":$dir}')" sleep 0.2 -ipc "$BOB_IPC" "$JOIN" +ipc "$BOB_IPC" "$(jq -cn --arg net "$NETWORK_NAME" --arg dir "$DATA_ROOT/bob/share" \ + '{"type":"join_network","network_name":$net,"share_dir":$dir}')" sleep 0.2 -ipc "$CHARLIE_IPC" "$JOIN" +ipc "$CHARLIE_IPC" "$(jq -cn --arg net "$NETWORK_NAME" --arg dir "$DATA_ROOT/charlie/share" \ + '{"type":"join_network","network_name":$net,"share_dir":$dir}')" sleep 0.3 # ── resolve peer IDs ────────────────────────────────────────────────────────── @@ -394,7 +404,7 @@ echo -e "${DIM}───────────────────── alice_nets=$(echo '{"type":"get_state"}' | timeout 2 nc 127.0.0.1 "$ALICE_IPC" 2>/dev/null \ | grep '"type":"state_snapshot"' | head -1 \ - | jq -r '[.networks[].network_name] | join(", ")' 2>/dev/null || echo "?") + | jq -rc '[.networks[].network_name] | join(", ")' 2>/dev/null || echo "?") echo -e "${DIM} alice networks: [${alice_nets:-none}]${RESET}" for peer_ipc in "$ALICE_IPC" "$BOB_IPC" "$CHARLIE_IPC"; do @@ -415,6 +425,54 @@ for peer_ipc in "$ALICE_IPC" "$BOB_IPC" "$CHARLIE_IPC"; do done sleep 0.5 +# ── per-network share isolation ─────────────────────────────────────────────── +echo "" +echo -e "${DIM}────────────────────────────────────────────────────────${RESET}" +echo -e "per-network share directory isolation" +echo -e "${DIM}────────────────────────────────────────────────────────${RESET}" + +# Alice also joins a "work" network with a completely different share dir. +WORK_NET="work-$(date +%s)" +ipc "$ALICE_IPC" "$(jq -cn --arg net "$WORK_NET" --arg dir "$DATA_ROOT/alice/work-share" \ + '{"type":"join_network","network_name":$net,"share_dir":$dir}')" +sleep 1 + +# Alice's own file list on "friends" should show friends-share files only. +friends_raw=$(echo '{"type":"get_file_list"}' | nc -q 2 127.0.0.1 "$ALICE_IPC" 2>/dev/null || true) +friends_files=$(echo "$friends_raw" | grep '"type":"file_list"' | head -1 \ + | jq -r '[.files[].name] | join(" ")' 2>/dev/null || true) + +# Use set_share_dir to dynamically update the work network share dir (runtime change smoke test). +work_net_id=$(echo '{"type":"get_state"}' | timeout 2 nc 127.0.0.1 "$ALICE_IPC" 2>/dev/null \ + | grep '"type":"state_snapshot"' | head -1 \ + | jq -r --arg net "$WORK_NET" '.networks[] | select(.network_name==$net) | .network_id' 2>/dev/null || true) + +if [ -n "$work_net_id" ]; then + # Verify the work network's share_dir is isolated from friends. + work_raw=$(echo "{\"type\":\"get_file_list\",\"network_id\":\"$work_net_id\"}" \ + | nc -q 2 127.0.0.1 "$ALICE_IPC" 2>/dev/null || true) + work_files=$(echo "$work_raw" | grep '"type":"file_list"' | head -1 \ + | jq -r '[.files[].name] | join(" ")' 2>/dev/null || true) + + echo -e " ${BOLD}alice/friends${RESET}: ${friends_files:-}" + echo -e " ${BOLD}alice/work${RESET}: ${work_files:-}" + + # Check isolation: friends files must not appear in work list and vice versa. + if echo "$friends_files" | grep -q "notes.txt" && \ + echo "$work_files" | grep -q "report.pdf" && \ + ! echo "$friends_files" | grep -q "report.pdf" && \ + ! echo "$work_files" | grep -q "notes.txt"; then + echo -e " ${GREEN}✓ share directories are isolated per network${RESET}" + else + echo -e " ${RED}✗ share isolation failed${RESET}" + echo -e " friends: ${friends_files}" + echo -e " work: ${work_files}" + fi +else + echo -e " ${RED}✗ could not resolve work network id${RESET}" +fi +sleep 0.3 + # ── file transfer ──────────────────────────────────────────────────────────── echo "" echo -e "${DIM}────────────────────────────────────────────────────────${RESET}" @@ -435,7 +493,7 @@ done if [ "$received" = "1" ]; then rx_file=$(ls "$DATA_ROOT/bob/downloads-"*/notes.txt 2>/dev/null | head -1) - orig_sha=$(sha256sum "$DATA_ROOT/alice/share/notes.txt" | awk '{print $1}') + orig_sha=$(sha256sum "$DATA_ROOT/alice/friends-share/notes.txt" | awk '{print $1}') rx_sha=$(sha256sum "$rx_file" | awk '{print $1}') if [ "$orig_sha" = "$rx_sha" ]; then echo -e " ${GREEN}✓ notes.txt received by bob, sha256 matches${RESET}" diff --git a/test-tui.sh b/test-tui.sh index fc685e8..8f68739 100755 --- a/test-tui.sh +++ b/test-tui.sh @@ -19,14 +19,14 @@ CYAN='\033[0;36m'; DIM='\033[2m'; BOLD='\033[1m'; RESET='\033[0m' rm -rf "$DATA_ROOT" mkdir -p "$DATA_ROOT/bin" -# Seed per-peer share directories with dummy files. +# Seed per-network share directories with dummy files. mkdir -p "$DATA_ROOT/alice/share" "$DATA_ROOT/bob/share" "$DATA_ROOT/charlie/share" -echo "alice's notes" > "$DATA_ROOT/alice/share/notes.txt" +echo "alice's notes" > "$DATA_ROOT/alice/share/notes.txt" dd if=/dev/urandom bs=1K count=64 2>/dev/null | base64 > "$DATA_ROOT/alice/share/photo.jpg.b64" dd if=/dev/urandom bs=1K count=128 2>/dev/null | base64 > "$DATA_ROOT/bob/share/archive.tar.b64" echo -e "file1.mp3\nfile2.mp3" > "$DATA_ROOT/bob/share/playlist.m3u" -echo "charlie's doc" > "$DATA_ROOT/charlie/share/document.txt" -echo "#!/bin/sh" > "$DATA_ROOT/charlie/share/script.sh" +echo "charlie's doc" > "$DATA_ROOT/charlie/share/document.txt" +echo "#!/bin/sh" > "$DATA_ROOT/charlie/share/script.sh" # Kill any leftover processes holding our fixed ports. for port in "$ANCHOR_PORT" "$ALICE_IPC" "$BOB_IPC" "$CHARLIE_IPC"; do @@ -97,22 +97,19 @@ wait_port "$ANCHOR_PORT" ANCHOR_URL="ws://127.0.0.1:${ANCHOR_PORT}/ws" -# Daemons +# Daemons — no global -share-dir; share dirs are set per network at join_network time. "$DATA_ROOT/bin/waste-daemon" -alias alice -data-dir "$DATA_ROOT/alice" \ -ipc-port "$ALICE_IPC" -anchor "$ANCHOR_URL" \ - -share-dir "$DATA_ROOT/alice/share" \ 2>"$DATA_ROOT/alice/daemon.log" & PIDS+=($!) "$DATA_ROOT/bin/waste-daemon" -alias bob -data-dir "$DATA_ROOT/bob" \ -ipc-port "$BOB_IPC" -anchor "$ANCHOR_URL" \ - -share-dir "$DATA_ROOT/bob/share" \ 2>"$DATA_ROOT/bob/daemon.log" & PIDS+=($!) "$DATA_ROOT/bin/waste-daemon" -alias charlie -data-dir "$DATA_ROOT/charlie" \ -ipc-port "$CHARLIE_IPC" -anchor "$ANCHOR_URL" \ - -share-dir "$DATA_ROOT/charlie/share" \ 2>"$DATA_ROOT/charlie/daemon.log" & PIDS+=($!) @@ -120,19 +117,22 @@ wait_port "$ALICE_IPC" wait_port "$BOB_IPC" wait_port "$CHARLIE_IPC" -# Resolve peer IDs +# Join all three with per-network share directories. +ipc "$ALICE_IPC" "$(jq -cn --arg net "$NETWORK_NAME" --arg dir "$DATA_ROOT/alice/share" \ + '{"type":"join_network","network_name":$net,"share_dir":$dir}')" +sleep 0.2 +ipc "$BOB_IPC" "$(jq -cn --arg net "$NETWORK_NAME" --arg dir "$DATA_ROOT/bob/share" \ + '{"type":"join_network","network_name":$net,"share_dir":$dir}')" +sleep 0.2 +ipc "$CHARLIE_IPC" "$(jq -cn --arg net "$NETWORK_NAME" --arg dir "$DATA_ROOT/charlie/share" \ + '{"type":"join_network","network_name":$net,"share_dir":$dir}')" +sleep 0.5 + +# Resolve peer IDs (network-scoped identity, available after joining). ALICE_ID=$(peer_field "$ALICE_IPC" '.local_peer.id') BOB_ID=$(peer_field "$BOB_IPC" '.local_peer.id') CHARLIE_ID=$(peer_field "$CHARLIE_IPC" '.local_peer.id') -# Join all three -JOIN=$(printf '{"type":"join_network","network_name":"%s"}' "$NETWORK_NAME") -ipc "$ALICE_IPC" "$JOIN" -sleep 0.2 -ipc "$BOB_IPC" "$JOIN" -sleep 0.2 -ipc "$CHARLIE_IPC" "$JOIN" - echo -e "${DIM}waiting for WebRTC DataChannels (6s)…${RESET}" sleep 6