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:
138
PROTOCOL.md
138
PROTOCOL.md
@@ -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
|
||||
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](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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:-<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 ────────────────────────────────────────────────────────────
|
||||
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}"
|
||||
|
||||
34
test-tui.sh
34
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user