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

@@ -379,3 +379,141 @@ The reference infra is **live**: STUN `stun:<anchor-host>:3478` and signaling
*Implement against §5§9; everything else is rationale. Clients MUST 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 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.

View File

@@ -50,7 +50,7 @@ func main() {
}) })
if autoJoinNetwork != "" { if autoJoinNetwork != "" {
if _, err := mgr.Join(autoJoinNetwork); err != nil { if _, err := mgr.Join(autoJoinNetwork, ""); err != nil {
log.Fatalf("auto-join: %v", err) log.Fatalf("auto-join: %v", err)
} }
} }

View File

@@ -117,12 +117,12 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
send(errMsg("join_network: network_name is required")) send(errMsg("join_network: network_name is required"))
continue continue
} }
netID, err := mgr.Join(cmd.NetworkName) netID, err := mgr.Join(cmd.NetworkName, cmd.ShareDir)
if err != nil { if err != nil {
send(errMsg(fmt.Sprintf("join_network: %v", err))) send(errMsg(fmt.Sprintf("join_network: %v", err)))
continue continue
} }
// network_joined event is emitted by Manager.Join; nothing extra needed. // network_joined event (with share_dir) is emitted by Manager.Join.
_ = netID _ = netID
case proto.CmdLeaveNetwork: case proto.CmdLeaveNetwork:
@@ -246,6 +246,17 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
InviteString: inv, 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: case proto.CmdSendFile:
n := mgr.Resolve(cmd.NetworkID) n := mgr.Resolve(cmd.NetworkID)
if n == nil { if n == nil {
@@ -292,6 +303,8 @@ func stateSnapshot(mgr *netmgr.Manager) proto.IpcMessage {
NetworkID: n.ID, NetworkID: n.ID,
NetworkName: n.Name, NetworkName: n.Name,
LocalPeer: &pi, LocalPeer: &pi,
ShareDir: n.Mesh.ShareDir,
DownloadDir: n.Mesh.DownloadDir,
}) })
} }
msg.Networks = netInfos msg.Networks = netInfos
@@ -322,7 +335,7 @@ func randomHex(n int) string {
// It joins the network before the IPC listener starts accepting clients. // It joins the network before the IPC listener starts accepting clients.
func AutoJoin(ctx context.Context, mgr *netmgr.Manager, networkName string) { func AutoJoin(ctx context.Context, mgr *netmgr.Manager, networkName string) {
_ = ctx // Manager owns the context internally _ = 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) log.Printf("ipc: auto-join %q failed: %v", networkName, err)
} }
} }

View File

@@ -25,7 +25,7 @@ type Config struct {
MasterIdentity *crypto.Identity MasterIdentity *crypto.Identity
StoreDir string // base directory for per-network SQLite files StoreDir string // base directory for per-network SQLite files
AnchorURL string // WebSocket anchor URL used for all networks 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. // Network is a single joined network context.
@@ -41,6 +41,9 @@ type Network struct {
cancel context.CancelFunc 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. // Manager owns all joined networks and fans out their events to IPC subscribers.
type Manager struct { type Manager struct {
cfg Config cfg Config
@@ -63,7 +66,8 @@ func New(cfg Config) *Manager {
// Join creates or rejoins a named network. Returns the network ID. // Join creates or rejoins a named network. Returns the network ID.
// If the network is already joined the existing ID is returned immediately. // 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) netHash := hashNetName(name)
netID := netHash[:8] netID := netHash[:8]
@@ -89,7 +93,10 @@ func (mgr *Manager) Join(name string) (string, error) {
} }
m := mesh.New(derived, st) 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.ShareDir = mgr.cfg.ShareDir
} }
m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID_full) 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{ mgr.emit(proto.IpcMessage{
Type: proto.EvtNetworkJoined, Type: proto.EvtNetworkJoined,
NetworkID: netID, NetworkID: netID,
NetworkName: name, NetworkName: name,
LocalPeer: peerPtr(derived.PeerInfo()), LocalPeer: peerPtr(derived.PeerInfo()),
ShareDir: m.ShareDir,
}) })
return netID, nil return netID, nil
@@ -159,6 +167,20 @@ func (mgr *Manager) Leave(netID string) {
mgr.emit(proto.IpcMessage{Type: proto.EvtNetworkLeft, NetworkID: netID}) 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. // LeaveAll leaves every joined network.
func (mgr *Manager) LeaveAll() { func (mgr *Manager) LeaveAll() {
mgr.mu.RLock() mgr.mu.RLock()

View File

@@ -217,6 +217,7 @@ const (
CmdLeaveNetwork IpcMsgType = "leave_network" CmdLeaveNetwork IpcMsgType = "leave_network"
CmdGetState IpcMsgType = "get_state" CmdGetState IpcMsgType = "get_state"
CmdSendFile IpcMsgType = "send_file" CmdSendFile IpcMsgType = "send_file"
CmdSetShareDir IpcMsgType = "set_share_dir" // set per-network share directory at runtime
CmdGenerateInvite IpcMsgType = "generate_invite" CmdGenerateInvite IpcMsgType = "generate_invite"
CmdGetFileList IpcMsgType = "get_file_list" CmdGetFileList IpcMsgType = "get_file_list"
@@ -241,6 +242,8 @@ type NetworkInfo struct {
NetworkID string `json:"network_id"` NetworkID string `json:"network_id"`
NetworkName string `json:"network_name"` NetworkName string `json:"network_name"`
LocalPeer *PeerInfo `json:"local_peer,omitempty"` 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. // IpcMessage covers both commands and events.
@@ -258,8 +261,9 @@ type IpcMessage struct {
// join_network / leave_network // join_network / leave_network
NetworkName string `json:"network_name,omitempty"` 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"` Path string `json:"path,omitempty"`
// events // events

View File

@@ -25,10 +25,19 @@ DATA_ROOT="/tmp/waste-test"
rm -rf "$DATA_ROOT" rm -rf "$DATA_ROOT"
mkdir -p "$DATA_ROOT/bin" mkdir -p "$DATA_ROOT/bin"
# Seed per-peer share directories with dummy files. # Per-network share directories — isolation is the whole point:
mkdir -p "$DATA_ROOT/alice/share" "$DATA_ROOT/bob/share" "$DATA_ROOT/charlie/share" # alice/friends-share → shared on the "friends" network only
echo "alice's notes" > "$DATA_ROOT/alice/share/notes.txt" # alice/work-share → shared on the "work" network only (alice joins both)
dd if=/dev/urandom bs=1K count=64 2>/dev/null | base64 > "$DATA_ROOT/alice/share/photo.jpg.b64" # 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" 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 -e "file1.mp3\nfile2.mp3" > "$DATA_ROOT/bob/share/playlist.m3u"
echo "charlie's doc" > "$DATA_ROOT/charlie/share/document.txt" 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" \ -data-dir "$DATA_ROOT/alice" \
-ipc-port "$ALICE_IPC" \ -ipc-port "$ALICE_IPC" \
-anchor "$ANCHOR_URL" \ -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) & 2> >(tee "$DATA_ROOT/alice/daemon.log" | while IFS= read -r l; do echo -e "${ALICE_COLOR}${DIM}[alice] ${l}${RESET}"; done) &
PIDS+=($!) PIDS+=($!)
@@ -245,7 +253,6 @@ log "$BOB_COLOR" "bob" "starting daemon (ipc :${BOB_IPC})"
-data-dir "$DATA_ROOT/bob" \ -data-dir "$DATA_ROOT/bob" \
-ipc-port "$BOB_IPC" \ -ipc-port "$BOB_IPC" \
-anchor "$ANCHOR_URL" \ -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) & 2> >(tee "$DATA_ROOT/bob/daemon.log" | while IFS= read -r l; do echo -e "${BOB_COLOR}${DIM}[bob] ${l}${RESET}"; done) &
PIDS+=($!) PIDS+=($!)
@@ -255,7 +262,6 @@ log "$CHARLIE_COLOR" "charlie" "starting daemon (ipc :${CHARLIE_IPC})"
-data-dir "$DATA_ROOT/charlie" \ -data-dir "$DATA_ROOT/charlie" \
-ipc-port "$CHARLIE_IPC" \ -ipc-port "$CHARLIE_IPC" \
-anchor "$ANCHOR_URL" \ -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) & 2> >(tee "$DATA_ROOT/charlie/daemon.log" | while IFS= read -r l; do echo -e "${CHARLIE_COLOR}${DIM}[charlie]${l}${RESET}"; done) &
PIDS+=($!) PIDS+=($!)
@@ -267,14 +273,18 @@ echo ""
# ── join network ────────────────────────────────────────────────────────────── # ── join network ──────────────────────────────────────────────────────────────
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}" echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
echo -e "joining all peers to network: ${BOLD}${NETWORK_NAME}${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}" echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
JOIN=$(printf '{"type":"join_network","network_name":"%s"}' "$NETWORK_NAME") # Each peer passes share_dir scoped to this network.
ipc "$ALICE_IPC" "$JOIN" 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 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 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 sleep 0.3
# ── resolve peer IDs ────────────────────────────────────────────────────────── # ── 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 \ alice_nets=$(echo '{"type":"get_state"}' | timeout 2 nc 127.0.0.1 "$ALICE_IPC" 2>/dev/null \
| grep '"type":"state_snapshot"' | head -1 \ | 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}" echo -e "${DIM} alice networks: [${alice_nets:-none}]${RESET}"
for peer_ipc in "$ALICE_IPC" "$BOB_IPC" "$CHARLIE_IPC"; do 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 done
sleep 0.5 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:-<empty>}"
echo -e " ${BOLD}alice/work${RESET}: ${work_files:-<empty>}"
# 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 ──────────────────────────────────────────────────────────── # ── file transfer ────────────────────────────────────────────────────────────
echo "" echo ""
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}" echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
@@ -435,7 +493,7 @@ done
if [ "$received" = "1" ]; then if [ "$received" = "1" ]; then
rx_file=$(ls "$DATA_ROOT/bob/downloads-"*/notes.txt 2>/dev/null | head -1) 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}') rx_sha=$(sha256sum "$rx_file" | awk '{print $1}')
if [ "$orig_sha" = "$rx_sha" ]; then if [ "$orig_sha" = "$rx_sha" ]; then
echo -e " ${GREEN}✓ notes.txt received by bob, sha256 matches${RESET}" echo -e " ${GREEN}✓ notes.txt received by bob, sha256 matches${RESET}"

View File

@@ -19,14 +19,14 @@ CYAN='\033[0;36m'; DIM='\033[2m'; BOLD='\033[1m'; RESET='\033[0m'
rm -rf "$DATA_ROOT" rm -rf "$DATA_ROOT"
mkdir -p "$DATA_ROOT/bin" 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" 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=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" 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 -e "file1.mp3\nfile2.mp3" > "$DATA_ROOT/bob/share/playlist.m3u"
echo "charlie's doc" > "$DATA_ROOT/charlie/share/document.txt" echo "charlie's doc" > "$DATA_ROOT/charlie/share/document.txt"
echo "#!/bin/sh" > "$DATA_ROOT/charlie/share/script.sh" echo "#!/bin/sh" > "$DATA_ROOT/charlie/share/script.sh"
# Kill any leftover processes holding our fixed ports. # Kill any leftover processes holding our fixed ports.
for port in "$ANCHOR_PORT" "$ALICE_IPC" "$BOB_IPC" "$CHARLIE_IPC"; do 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" 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" \ "$DATA_ROOT/bin/waste-daemon" -alias alice -data-dir "$DATA_ROOT/alice" \
-ipc-port "$ALICE_IPC" -anchor "$ANCHOR_URL" \ -ipc-port "$ALICE_IPC" -anchor "$ANCHOR_URL" \
-share-dir "$DATA_ROOT/alice/share" \
2>"$DATA_ROOT/alice/daemon.log" & 2>"$DATA_ROOT/alice/daemon.log" &
PIDS+=($!) PIDS+=($!)
"$DATA_ROOT/bin/waste-daemon" -alias bob -data-dir "$DATA_ROOT/bob" \ "$DATA_ROOT/bin/waste-daemon" -alias bob -data-dir "$DATA_ROOT/bob" \
-ipc-port "$BOB_IPC" -anchor "$ANCHOR_URL" \ -ipc-port "$BOB_IPC" -anchor "$ANCHOR_URL" \
-share-dir "$DATA_ROOT/bob/share" \
2>"$DATA_ROOT/bob/daemon.log" & 2>"$DATA_ROOT/bob/daemon.log" &
PIDS+=($!) PIDS+=($!)
"$DATA_ROOT/bin/waste-daemon" -alias charlie -data-dir "$DATA_ROOT/charlie" \ "$DATA_ROOT/bin/waste-daemon" -alias charlie -data-dir "$DATA_ROOT/charlie" \
-ipc-port "$CHARLIE_IPC" -anchor "$ANCHOR_URL" \ -ipc-port "$CHARLIE_IPC" -anchor "$ANCHOR_URL" \
-share-dir "$DATA_ROOT/charlie/share" \
2>"$DATA_ROOT/charlie/daemon.log" & 2>"$DATA_ROOT/charlie/daemon.log" &
PIDS+=($!) PIDS+=($!)
@@ -120,19 +117,22 @@ wait_port "$ALICE_IPC"
wait_port "$BOB_IPC" wait_port "$BOB_IPC"
wait_port "$CHARLIE_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') ALICE_ID=$(peer_field "$ALICE_IPC" '.local_peer.id')
BOB_ID=$(peer_field "$BOB_IPC" '.local_peer.id') BOB_ID=$(peer_field "$BOB_IPC" '.local_peer.id')
CHARLIE_ID=$(peer_field "$CHARLIE_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}" echo -e "${DIM}waiting for WebRTC DataChannels (6s)…${RESET}"
sleep 6 sleep 6