Compare commits
5 Commits
0051c8fdbf
...
f1498697b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1498697b6 | ||
|
|
d02e18e212 | ||
|
|
f437fe94f4 | ||
|
|
274ff423f6 | ||
|
|
13b30ca0cb |
126
FUTURE.md
126
FUTURE.md
@@ -11,10 +11,10 @@ Two clean layers, connected by the IPC port.
|
||||
### Daemon
|
||||
The real application. A long-running background process that handles everything:
|
||||
|
||||
- Peer mesh and connection management
|
||||
- Cryptography and handshake
|
||||
- NAT traversal and relay fallback
|
||||
- File transfer
|
||||
- Peer mesh and connection management (WebRTC DataChannels, DTLS, ICE)
|
||||
- Cryptography and handshake (Ed25519 identity, nacl/box signaling, YAW/2.1 FS)
|
||||
- NAT traversal (ICE/STUN via pion/webrtc — no custom relay needed)
|
||||
- File transfer (dedicated binary DataChannels per transfer)
|
||||
|
||||
Exposes a local JSON API over TCP (`127.0.0.1:17337`). Can run headlessly — SSH into a box and the mesh stays alive even with no UI attached.
|
||||
|
||||
@@ -23,95 +23,73 @@ Talks to the daemon over the IPC port. The separation means the UI is replaceabl
|
||||
|
||||
Target: a web frontend (React or similar) wrapped in a native binary using a Tauri-style approach — native packaging, OS webview, no Electron weight. Avoids the wxWidgets ugliness of the old wxWASTE fork and the Qt licensing headaches of the VIA fork.
|
||||
|
||||
#### TUI (near-term)
|
||||
A terminal UI is worth building first, as a `cmd/tui` using [Bubble Tea](https://github.com/charmbracelet/bubbletea). Since the IPC contract is already the full boundary, a TUI is just another client — connect to `127.0.0.1:17337`, receive the `state_snapshot`, then funnel incoming events into Bubble Tea's update loop. Incoming mesh events map naturally onto its Elm-style message model.
|
||||
|
||||
Benefits over jumping straight to a native GUI:
|
||||
- Works over SSH; zero packaging complexity
|
||||
- Validates the full IPC protocol and message flow end-to-end
|
||||
- Useful day-to-day while the native UI is still future work
|
||||
|
||||
The TUI doesn't replace the long-term GUI — it won't serve non-technical friends — but it's the right first UI milestone.
|
||||
#### TUI ✅ (shipped)
|
||||
A terminal UI (`cmd/tui`) using [Bubble Tea](https://github.com/charmbracelet/bubbletea). Three-pane layout: rooms, messages, peers. Supports group chat, DMs, room switching, invite generation. Works over SSH.
|
||||
|
||||
---
|
||||
|
||||
## Protocol Modernization
|
||||
## Protocol
|
||||
|
||||
### NAT Traversal
|
||||
The main unsolved problem from the original WASTE — one party always needed an open port.
|
||||
### NAT Traversal ✅ (WebRTC ICE/STUN)
|
||||
Solved by using WebRTC DataChannels via pion. ICE gathers host + server-reflexive (STUN) candidates and performs UDP hole punching automatically. The anchor (`cmd/anchor`) doubles as a STUN server on UDP/3478.
|
||||
|
||||
- Try UDP hole punching (STUN) first
|
||||
- Fall back to an encrypted relay (DERP-style) when hole punching fails
|
||||
- Relay sees only opaque encrypted blobs — end-to-end encryption holds
|
||||
- Run the relay on a Hetzner VPS; `waste-relay` already implements the blind-forward model
|
||||
The one remaining gap: symmetric-NAT pairs fail without TURN. An optional TURN relay (coturn in relay mode) can recover these. The protocol spec (§11) explicitly leaves this as an opt-in — no relay is the default.
|
||||
|
||||
### Bootstrapping & Rendezvous
|
||||
No DHT needed at small group scale (10–50 nodes). Keep it simple:
|
||||
### Signaling ✅ YAW/2.1 (shipped)
|
||||
Forward-secret signaling via per-session ephemeral X25519 keys. See PROTOCOL.md §2.1 and `internal/anchor/client.go`. Falls back transparently to 2.0 static-key sealing for peers that don't speak 2.1.
|
||||
|
||||
- Each peer generates an Ed25519 keypair on first run — the public key **is** their identity
|
||||
- Share a small signed invite file (`.waste-invite`) out of band: email, Signal, whatever
|
||||
- The invite contains: current IP:port hint + public key + short-lived signature
|
||||
- Once two peers connect, they gossip each other's addresses to mutual friends
|
||||
- A local known-peers list in the data directory is sufficient at this scale
|
||||
### Multi-Network Support ✅ (shipped)
|
||||
One daemon, multiple simultaneously-joined networks. Per-network HKDF-derived identities prevent cross-network correlation. Network-scoped SQLite stores, IPC commands carry `network_id`.
|
||||
|
||||
### Identity
|
||||
- Persistent Ed25519 keypair, generated once
|
||||
- Public key = stable identity, not a mutable nickname
|
||||
- No phone number, no central registry — closer to Signal's model than WASTE's original unregistered aliases
|
||||
### Invite System ✅ (shipped)
|
||||
`waste:<base64>` URIs encoding anchor URL + network name. `--join` flag on daemon and TUI. Generated via IPC `generate_invite` or `Ctrl+I` in the TUI.
|
||||
|
||||
### File Transfer ✅ (shipped)
|
||||
Dedicated binary DataChannel per transfer (`f:<xid>`). SHA-256 integrity verification. 64 KiB chunks with backpressure. Auto-accept. Downloads to `<data-dir>/downloads-<netid>/`.
|
||||
|
||||
---
|
||||
|
||||
## Remaining Work
|
||||
|
||||
### Per-Network Share Directories
|
||||
The `-share-dir` flag is still global. A peer on two networks shares the same directory with both. The fix: make share dir per-network, configurable via an IPC command `set_share_dir {network_id, path}` or a config file, so Alice can share work files on "work" and music on "friends" independently.
|
||||
|
||||
The current `-share-dir` flag is a single global directory. Eventually, each network should have its own independent share set:
|
||||
- Alice shares `/home/alice/Downloads/work-files` on the "work" network
|
||||
- Alice shares `/home/alice/Music` on the "friends" network
|
||||
- Peers on "work" never see Alice's music, and vice versa
|
||||
### Peer Gossip (§8.4)
|
||||
The `peer_gossip` wire type exists in proto but is not implemented. When a peer knows about other peers (from prior connections), it could share address hints so the mesh can reconnect without the anchor. This makes the anchor optional once the initial handshake has happened — the network self-heals if the anchor goes down.
|
||||
|
||||
When multi-network support lands (see below), the `ShareDir` field on `networkCtx` replaces the current global `Mesh.ShareDir`. The IPC `get_file_list` and daemon `-share-dir` flag should move to be per-network configuration — either via a config file or via an IPC command `set_share_dir` scoped to a `network_id`.
|
||||
### File Transfer UX
|
||||
- Manual accept/reject (currently auto-accept)
|
||||
- Transfer cancellation via `file-cancel`
|
||||
- Progress display in TUI
|
||||
- Resume after disconnection
|
||||
|
||||
### Multi-Network Support
|
||||
### Native UI
|
||||
Web frontend (React) + Tauri/native packaging. The IPC protocol is already the full boundary — the UI is a pure consumer.
|
||||
|
||||
A single client should be able to participate in multiple networks simultaneously (e.g. "work" and "friends") without leaking that both identities belong to the same person.
|
||||
|
||||
**Privacy constraint:** if the same Ed25519 keypair is used across networks, any peer who is a member of both networks can trivially correlate you. The anchor also sees the same public key across networks.
|
||||
|
||||
**Solution — per-network derived identities:**
|
||||
- One master Ed25519 seed in `identity.json`
|
||||
- Per-network keypair = `HKDF(masterSeed, "yaw2-net", networkHash)`
|
||||
- Same master + same network name = same derived keypair (stable identity within a network)
|
||||
- Different networks = different peer IDs; correlation is impossible without knowing both network names
|
||||
- The anchor sees only the derived public key
|
||||
|
||||
**Daemon changes:**
|
||||
- Replace the single `networkCancel` with a `map[networkID]*networkCtx`
|
||||
- Each context holds its own: derived identity, mesh, anchor connection, store (`messages-<netHash>.db`)
|
||||
- `join_network` returns a `network_id` token used to scope subsequent commands
|
||||
|
||||
**IPC changes (breaking):**
|
||||
- All commands and events gain a `network_id` field
|
||||
- `get_state` returns an array of all joined networks
|
||||
- `join_network` responds with `network_joined` carrying the derived peer ID for that network
|
||||
|
||||
**TUI changes:**
|
||||
- Top-level network switcher (e.g. `[work] [friends]`)
|
||||
- Rooms and peers are scoped per network underneath
|
||||
|
||||
### Transport (Long-term)
|
||||
Current transport is TCP with custom framing. QUIC is worth revisiting once the core is solid — it gives multiplexing and better NAT traversal behavior essentially for free.
|
||||
### TURN relay (optional)
|
||||
For symmetric-NAT pairs. Opt-in, off by default. Operates blind (relay sees only opaque DTLS-encrypted blobs). Run coturn in relay mode on the same VPS as the anchor.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Priority | Item |
|
||||
| Status | Item |
|
||||
|---|---|
|
||||
| 1 | Deploy `waste-relay` to Hetzner; verify cross-internet NAT traversal |
|
||||
| 2 | Invite file format (`.waste-invite`) — solve bootstrapping without manual IP sharing |
|
||||
| 3 | Peer gossip — auto-connect to friends-of-friends after initial invite |
|
||||
| 4 | File transfer — chunked, encrypted, resumable |
|
||||
| 5 | Message persistence — SQLite via `modernc.org/sqlite` |
|
||||
| 6 | UI — web frontend consuming the IPC port; native packaging |
|
||||
| 7 | UDP hole punching — full STUN implementation in `internal/nat` |
|
||||
| 8 | QUIC transport — replace TCP framing for better NAT behavior |
|
||||
| ✅ shipped | Daemon + anchor server |
|
||||
| ✅ shipped | WebRTC DataChannels (ICE/STUN hole punching) |
|
||||
| ✅ shipped | Ed25519 identity, nacl/box signaling (YAW/2.0) |
|
||||
| ✅ shipped | IPC protocol — join/leave/chat/DM/state |
|
||||
| ✅ shipped | Message persistence (SQLite, per-network) |
|
||||
| ✅ shipped | TUI (`cmd/tui`, Bubble Tea) |
|
||||
| ✅ shipped | Invite system (`waste:` URI, `--join` flag) |
|
||||
| ✅ shipped | Multi-network support (HKDF derived identities) |
|
||||
| ✅ shipped | File transfer (dedicated binary DataChannels) |
|
||||
| ✅ shipped | Forward-secret signaling (YAW/2.1 ephemeral X25519) |
|
||||
| next | Per-network share directories |
|
||||
| next | Peer gossip (anchor-free reconnection) |
|
||||
| next | File transfer UX (manual accept, cancel, TUI progress) |
|
||||
| future | Native UI (React + Tauri) |
|
||||
| future | TURN relay (opt-in, for symmetric-NAT pairs) |
|
||||
|
||||
---
|
||||
|
||||
@@ -120,5 +98,5 @@ Current transport is TCP with custom framing. QUIC is worth revisiting once the
|
||||
- **Small group** — not a public network, not federated, not discoverable
|
||||
- **No registration** — no phone number, no email, no central service
|
||||
- **Encrypted everything** — at rest and in transit, end to end
|
||||
- **Equal nodes** — no peer is "the server"; the relay is dumb infrastructure only
|
||||
- **Equal nodes** — no peer is "the server"; the anchor is dumb infrastructure
|
||||
- **The soul** — a private overlay for people you actually trust
|
||||
|
||||
140
PROTOCOL.md
140
PROTOCOL.md
@@ -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.
|
||||
41
README.md
41
README.md
@@ -100,6 +100,9 @@ Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`.
|
||||
{"type":"send_message","room":"general","body":"hi"}
|
||||
{"type":"send_message","room":"dm:<peer-hex>","body":"hey","to":"<peer-hex>"}
|
||||
{"type":"get_state"}
|
||||
{"type":"get_file_list"} // own share dir
|
||||
{"type":"get_file_list","peer_id":"<64-hex>"} // remote peer's share dir
|
||||
{"type":"send_file","peer_id":"<64-hex>","path":"notes.txt"} // offer a file from share dir
|
||||
{"type":"generate_invite"}
|
||||
```
|
||||
|
||||
@@ -114,7 +117,13 @@ Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`.
|
||||
{"type":"peer_disconnected","peer_id":"<64-hex>"}
|
||||
|
||||
// Incoming message — mid is a 32-hex dedup token, to is set for DMs
|
||||
{"type":"message_received","message":{"mid":"<32-hex>","from":"<64-hex>","to":"<64-hex>","room":"general","body":"hi","sent_at":"..."}}
|
||||
{"type":"message_received","message":{"mid":"<32-hex>","from":"<64-hex>","room":"general","text":"hi","ts":1700000000000}}
|
||||
|
||||
// File events
|
||||
{"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"<32-hex>","name":"notes.txt","size":1024,"sha256":"<64-hex>"}}
|
||||
{"type":"file_progress","transfer_id":"<32-hex>","bytes_received":65536,"total_bytes":1048576}
|
||||
{"type":"file_complete","transfer_id":"<32-hex>","path":"/data-dir/downloads-<netid>/notes.txt"}
|
||||
{"type":"file_list","peer_id":"<64-hex>","files":[{"name":"notes.txt","size_bytes":1024}]}
|
||||
|
||||
// Invite generation response
|
||||
{"type":"invite_generated","invite":"waste:<base64>"}
|
||||
@@ -129,12 +138,25 @@ Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`.
|
||||
|---|---|---|
|
||||
| Identity | Ed25519 | Fast, small keys, standard |
|
||||
| Peer ID | Hex-encoded Ed25519 pubkey | 64 lowercase hex chars (YAW/2 §2) |
|
||||
| Signaling encryption | XSalsa20-Poly1305 (`nacl/box`) | X25519 keys derived from Ed25519 identity (YAW/2 §3) |
|
||||
| Signaling encryption (2.0) | XSalsa20-Poly1305 (`nacl/box`) | X25519 keys derived from Ed25519 identity (YAW/2 §3) |
|
||||
| **Signaling encryption (2.1)** | **XSalsa20-Poly1305, ephemeral X25519** | **Per-session keypair; `esk` wiped on close → forward secrecy** |
|
||||
| Transport | WebRTC DataChannels (DTLS+SCTP) | pion/webrtc — ICE, hole punching included |
|
||||
| Hashing | SHA-256 | File integrity, network name hashing |
|
||||
|
||||
Replaces WASTE's original Blowfish/PCBC (broken cipher mode) + RSA.
|
||||
|
||||
### Forward-secret signaling (YAW/2.1)
|
||||
|
||||
By default waste-go speaks **YAW/2.1**: before sending an offer each peer generates a fresh
|
||||
X25519 keypair (`esk`/`epk`), broadcasts its `epk` in a signed `ekey` message, then seals
|
||||
`offer`/`answer`/`candidate` payloads with the *ephemeral* keys. `esk` is zeroed when the
|
||||
session ends. Recorded signaling traffic cannot be decrypted even if the long-term Ed25519
|
||||
keys later leak.
|
||||
|
||||
A 2.0 peer ignores the `ekey` message (unknown type → silently dropped) and the offerer
|
||||
falls back to static-key sealing after a 2 s timeout, so **2.1 ↔ 2.0 sessions work** — the
|
||||
session just isn't forward-secret. The log line `anchor: 2.0 fallback offer to …` flags this.
|
||||
|
||||
> Peer IDs are 64-char lowercase hex (Ed25519 public key). Existing `identity.json` files
|
||||
> on disk are unaffected — only the over-the-wire representation changed from base64url.
|
||||
|
||||
@@ -202,12 +224,20 @@ A self-contained test script boots anchor + three peers, joins them to a named n
|
||||
Data lands at `/tmp/waste-test` (wiped on each run). Inspect after a run:
|
||||
|
||||
```bash
|
||||
sqlite3 /tmp/waste-test/alice/messages.db
|
||||
# DB name includes the network ID (first 8 hex chars of sha256("yaw2-net:"+name))
|
||||
sqlite3 /tmp/waste-test/alice/messages-<netid>.db
|
||||
.headers on
|
||||
SELECT room, from_peer, body, sent_at FROM messages;
|
||||
SELECT room, from_peer, text, sent_at FROM messages;
|
||||
SELECT peer_id, alias, last_seen FROM peers;
|
||||
```
|
||||
|
||||
There is also a TUI integration test that boots the same three-peer network and
|
||||
launches the Bubble Tea UI as alice:
|
||||
|
||||
```bash
|
||||
./test-tui.sh
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] **Crypto layer** — hex peer IDs, `nacl/box` signaling, Ed25519→X25519 key derivation
|
||||
@@ -218,5 +248,6 @@ SELECT peer_id, alias, last_seen FROM peers;
|
||||
- [x] **IPC updates** — `join_network`/`leave_network`; `session_ready` event; DMs via `to` field
|
||||
- [x] **Message persistence** — SQLite (`internal/store`); messages and peer alias cache
|
||||
- [x] **TUI** — Bubble Tea terminal UI (`cmd/tui`); three-pane layout with room switching and DMs
|
||||
- [ ] **File transfer** — chunked binary DataChannel (`f:<xid>`)
|
||||
- [x] **File transfer** — chunked binary DataChannel (`f:<xid>`); SHA-256 verified; backpressure; auto-accept
|
||||
- [x] **Forward-secret signaling (YAW/2.1)** — ephemeral X25519 per session; `esk` wiped on close; 2.0 fallback
|
||||
- [ ] **Native UI** — web frontend with native packaging (Tauri-style)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package anchor implements the YAW/2 anchor client.
|
||||
// Package anchor implements the YAW/2 anchor client (with YAW/2.1 forward-secret signaling).
|
||||
// It connects to the anchor WebSocket, handles challenge/join, and routes
|
||||
// sealed signaling payloads. It manages PeerConnection lifecycle and delegates
|
||||
// DataChannel handling to internal/mesh.
|
||||
@@ -25,6 +25,38 @@ import (
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
const ekeyTimeout = 2 * time.Second
|
||||
|
||||
// peerSession holds per-peer state for one (potential or live) connection.
|
||||
type peerSession struct {
|
||||
pc *webrtc.PeerConnection
|
||||
ekey *crypto.EphemeralKey // our ephemeral keypair for this session
|
||||
peerEPK *[32]byte // peer's ephemeral pubkey (nil until ekey received)
|
||||
fs bool // forward-secret session (both sides exchanged ekey)
|
||||
ekeyRx chan struct{} // closed when peerEPK is set
|
||||
}
|
||||
|
||||
func newSession(pc *webrtc.PeerConnection) (*peerSession, error) {
|
||||
ek, err := crypto.GenerateEphemeral()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &peerSession{
|
||||
pc: pc,
|
||||
ekey: ek,
|
||||
ekeyRx: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *peerSession) close() {
|
||||
if s.ekey != nil {
|
||||
s.ekey.Wipe()
|
||||
}
|
||||
if s.pc != nil {
|
||||
s.pc.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Run connects to anchorURL, joins networkName, and blocks handling signaling.
|
||||
// Reconnects automatically on disconnect. Cancel ctx to stop.
|
||||
func Run(ctx context.Context, anchorURL, networkName string, id *crypto.Identity, m *mesh.Mesh) {
|
||||
@@ -62,11 +94,51 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
|
||||
}()
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
pcs = make(map[proto.PeerID]*webrtc.PeerConnection)
|
||||
mu sync.RWMutex
|
||||
sessions = make(map[proto.PeerID]*peerSession)
|
||||
)
|
||||
|
||||
sender := &sender{id: id, sendCh: sendCh}
|
||||
s := &sender{id: id, sendCh: sendCh}
|
||||
|
||||
// Drain gossip-discovered peers and initiate offers. Stopped when runOnce
|
||||
// returns (via drainDone) so stale sessions from a dead connection are never reused.
|
||||
drainDone := make(chan struct{})
|
||||
defer close(drainDone)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case pid, ok := <-m.PendingConnect:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
mu.RLock()
|
||||
_, already := sessions[pid]
|
||||
mu.RUnlock()
|
||||
if already {
|
||||
continue
|
||||
}
|
||||
// Use the same lexicographic tiebreak as anchor join to avoid
|
||||
// both sides trying to offer simultaneously.
|
||||
if strings.Compare(string(id.PeerID()), string(pid)) <= 0 {
|
||||
continue
|
||||
}
|
||||
go func(pid proto.PeerID) {
|
||||
sess, err := startOffer(ctx, pid, id, m, s)
|
||||
if err != nil {
|
||||
log.Printf("anchor: gossip offer to %s: %v", pid.Short(), err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
sessions[pid] = sess
|
||||
mu.Unlock()
|
||||
}(pid)
|
||||
case <-drainDone:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
var msg proto.AnchorMessage
|
||||
@@ -81,7 +153,6 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad challenge nonce: %w", err)
|
||||
}
|
||||
// §5.1: sig covers nonce_raw || net_ascii (64-char hex string as UTF-8)
|
||||
sig := id.Sign(append(nonceBytes, []byte(netHash)...))
|
||||
sendCh <- proto.AnchorMessage{
|
||||
Type: proto.AnchorJoin,
|
||||
@@ -96,13 +167,13 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
|
||||
pid := proto.PeerID(peerHex)
|
||||
if strings.Compare(string(id.PeerID()), peerHex) > 0 {
|
||||
go func(pid proto.PeerID) {
|
||||
pc, err := offer(pid, id, m, sender)
|
||||
sess, err := startOffer(ctx, pid, id, m, s)
|
||||
if err != nil {
|
||||
log.Printf("anchor: offer to %s: %v", pid.Short(), err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
pcs[pid] = pc
|
||||
sessions[pid] = sess
|
||||
mu.Unlock()
|
||||
}(pid)
|
||||
}
|
||||
@@ -113,13 +184,13 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
|
||||
log.Printf("anchor: peer joined: %s", pid.Short())
|
||||
if strings.Compare(string(id.PeerID()), msg.ID) > 0 {
|
||||
go func(pid proto.PeerID) {
|
||||
pc, err := offer(pid, id, m, sender)
|
||||
sess, err := startOffer(ctx, pid, id, m, s)
|
||||
if err != nil {
|
||||
log.Printf("anchor: offer to %s: %v", pid.Short(), err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
pcs[pid] = pc
|
||||
sessions[pid] = sess
|
||||
mu.Unlock()
|
||||
}(pid)
|
||||
}
|
||||
@@ -127,21 +198,61 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
|
||||
case proto.AnchorPeerLeave:
|
||||
pid := proto.PeerID(msg.ID)
|
||||
mu.Lock()
|
||||
if pc, ok := pcs[pid]; ok {
|
||||
pc.Close()
|
||||
delete(pcs, pid)
|
||||
if sess, ok := sessions[pid]; ok {
|
||||
sess.close()
|
||||
delete(sessions, pid)
|
||||
}
|
||||
mu.Unlock()
|
||||
log.Printf("anchor: peer left: %s", pid.Short())
|
||||
|
||||
case proto.AnchorFrom:
|
||||
fromID := proto.PeerID(msg.From)
|
||||
payload, err := openBox(msg.Box, fromID, id)
|
||||
|
||||
mu.RLock()
|
||||
sess := sessions[fromID]
|
||||
mu.RUnlock()
|
||||
|
||||
// Determine which key to try for opening the box.
|
||||
// If we already have the peer's ephemeral key, try ephemeral first.
|
||||
payload, usedEph, err := openBoxAuto(msg.Box, fromID, id, sess)
|
||||
if err != nil {
|
||||
log.Printf("anchor: open box from %s: %v", fromID.Short(), err)
|
||||
continue
|
||||
}
|
||||
dispatchSignaling(ctx, payload, fromID, id, m, sender, pcs, &mu)
|
||||
|
||||
if payload.Kind == proto.SigEkey {
|
||||
// Process ekey under static keys (we opened it correctly above).
|
||||
mu.Lock()
|
||||
if sess == nil {
|
||||
// Answerer: we haven't created a session yet, do it now.
|
||||
pc, err := newPC()
|
||||
if err != nil {
|
||||
mu.Unlock()
|
||||
log.Printf("anchor: new PC for answerer: %v", err)
|
||||
continue
|
||||
}
|
||||
sess, err = newSession(pc)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
mu.Unlock()
|
||||
log.Printf("anchor: ephemeral key gen: %v", err)
|
||||
continue
|
||||
}
|
||||
sessions[fromID] = sess
|
||||
// Send our ekey back immediately.
|
||||
go sendEkey(fromID, sess, id, s)
|
||||
}
|
||||
if err := receiveEkey(payload, fromID, id, sess); err != nil {
|
||||
mu.Unlock()
|
||||
log.Printf("anchor: bad ekey from %s: %v", fromID.Short(), err)
|
||||
continue
|
||||
}
|
||||
mu.Unlock()
|
||||
_ = usedEph
|
||||
continue
|
||||
}
|
||||
|
||||
dispatchSignaling(ctx, payload, usedEph, fromID, id, m, s, sessions, &mu)
|
||||
|
||||
case proto.AnchorNoPeer:
|
||||
log.Printf("anchor: no such peer: %s", proto.PeerID(msg.ID).Short())
|
||||
@@ -149,41 +260,118 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
|
||||
}
|
||||
}
|
||||
|
||||
// ── ekey helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
// sendEkey seals and transmits our ephemeral public key to peerID.
|
||||
func sendEkey(peerID proto.PeerID, sess *peerSession, id *crypto.Identity, s *sender) {
|
||||
epkHex := hex.EncodeToString(sess.ekey.PublicRaw()[:])
|
||||
sig := signEkey(id, peerID, sess.ekey.PublicRaw())
|
||||
payload := proto.SignalingPayload{
|
||||
Kind: proto.SigEkey,
|
||||
V: "yaw/2.1",
|
||||
EPK: epkHex,
|
||||
EkeySig: sig,
|
||||
}
|
||||
// ekey is always sealed under static keys (§5.4′ (a)).
|
||||
if err := s.SendTo(peerID, payload); err != nil {
|
||||
log.Printf("anchor: send ekey to %s: %v", peerID.Short(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// receiveEkey validates and stores the peer's ephemeral public key.
|
||||
func receiveEkey(payload proto.SignalingPayload, from proto.PeerID, id *crypto.Identity, sess *peerSession) error {
|
||||
epkBytes, err := hex.DecodeString(payload.EPK)
|
||||
if err != nil || len(epkBytes) != 32 {
|
||||
return fmt.Errorf("bad epk hex")
|
||||
}
|
||||
// Verify sig: "yaw/2.1 ekey" || from_id_raw(32) || our_id_raw(32) || epk_raw(32)
|
||||
fromRaw, err := hex.DecodeString(string(from))
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad from id")
|
||||
}
|
||||
ourRaw, err := hex.DecodeString(string(id.PeerID()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad own id")
|
||||
}
|
||||
msg := append([]byte("yaw/2.1 ekey"), fromRaw...)
|
||||
msg = append(msg, ourRaw...)
|
||||
msg = append(msg, epkBytes...)
|
||||
if err := crypto.Verify(string(from), msg, payload.EkeySig); err != nil {
|
||||
return fmt.Errorf("ekey sig: %w", err)
|
||||
}
|
||||
var epk [32]byte
|
||||
copy(epk[:], epkBytes)
|
||||
sess.peerEPK = &epk
|
||||
sess.fs = true
|
||||
close(sess.ekeyRx) // signal waiters
|
||||
return nil
|
||||
}
|
||||
|
||||
// signEkey produces the Ed25519 signature for our ekey message.
|
||||
// Input: "yaw/2.1 ekey" || our_id_raw(32) || peer_id_raw(32) || epk_raw(32)
|
||||
func signEkey(id *crypto.Identity, peerID proto.PeerID, epk *[32]byte) string {
|
||||
ourRaw, _ := hex.DecodeString(string(id.PeerID()))
|
||||
peerRaw, _ := hex.DecodeString(string(peerID))
|
||||
msg := append([]byte("yaw/2.1 ekey"), ourRaw...)
|
||||
msg = append(msg, peerRaw...)
|
||||
msg = append(msg, epk[:]...)
|
||||
return id.Sign(msg)
|
||||
}
|
||||
|
||||
// ── signaling dispatch ────────────────────────────────────────────────────────
|
||||
|
||||
func dispatchSignaling(
|
||||
ctx context.Context,
|
||||
payload proto.SignalingPayload,
|
||||
usedEph bool,
|
||||
fromID proto.PeerID,
|
||||
id *crypto.Identity,
|
||||
m *mesh.Mesh,
|
||||
s *sender,
|
||||
pcs map[proto.PeerID]*webrtc.PeerConnection,
|
||||
sessions map[proto.PeerID]*peerSession,
|
||||
mu *sync.RWMutex,
|
||||
) {
|
||||
switch payload.Kind {
|
||||
|
||||
case proto.SigOffer:
|
||||
go func() {
|
||||
pc, err := answer(payload, fromID, id, m, s)
|
||||
mu.Lock()
|
||||
sess := sessions[fromID]
|
||||
mu.Unlock()
|
||||
|
||||
pc, err := answerOffer(ctx, payload, fromID, id, m, s, sess)
|
||||
if err != nil {
|
||||
log.Printf("anchor: answer to %s: %v", fromID.Short(), err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
pcs[fromID] = pc
|
||||
if existing, ok := sessions[fromID]; ok && existing != sess {
|
||||
// Session was already replaced; close the new PC.
|
||||
pc.Close()
|
||||
} else {
|
||||
if sess == nil {
|
||||
// No session yet (2.0 peer — no ekey): create a minimal one.
|
||||
sess2, _ := newSession(pc)
|
||||
if sess2 != nil {
|
||||
sess2.fs = false
|
||||
sessions[fromID] = sess2
|
||||
}
|
||||
} else {
|
||||
sess.pc = pc
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
case proto.SigAnswer:
|
||||
mu.RLock()
|
||||
pc, ok := pcs[fromID]
|
||||
sess, ok := sessions[fromID]
|
||||
mu.RUnlock()
|
||||
if !ok {
|
||||
if !ok || sess.pc == nil {
|
||||
log.Printf("anchor: answer from %s but no PeerConnection", fromID.Short())
|
||||
return
|
||||
}
|
||||
if err := pc.SetRemoteDescription(webrtc.SessionDescription{
|
||||
if err := sess.pc.SetRemoteDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeAnswer,
|
||||
SDP: payload.SDP,
|
||||
}); err != nil {
|
||||
@@ -192,12 +380,12 @@ func dispatchSignaling(
|
||||
|
||||
case proto.SigCandidate:
|
||||
mu.RLock()
|
||||
pc, ok := pcs[fromID]
|
||||
sess, ok := sessions[fromID]
|
||||
mu.RUnlock()
|
||||
if !ok {
|
||||
if !ok || sess.pc == nil {
|
||||
return
|
||||
}
|
||||
if err := pc.AddICECandidate(webrtc.ICECandidateInit{
|
||||
if err := sess.pc.AddICECandidate(webrtc.ICECandidateInit{
|
||||
Candidate: payload.Cand,
|
||||
SDPMid: strPtr(payload.Mid),
|
||||
SDPMLineIndex: uint16Ptr(uint16(payload.MLine)),
|
||||
@@ -207,9 +395,9 @@ func dispatchSignaling(
|
||||
|
||||
case proto.SigBye:
|
||||
mu.Lock()
|
||||
if pc, ok := pcs[fromID]; ok {
|
||||
pc.Close()
|
||||
delete(pcs, fromID)
|
||||
if sess, ok := sessions[fromID]; ok {
|
||||
sess.close()
|
||||
delete(sessions, fromID)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
@@ -217,39 +405,82 @@ func dispatchSignaling(
|
||||
|
||||
// ── offer / answer helpers ────────────────────────────────────────────────────
|
||||
|
||||
func offer(peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*webrtc.PeerConnection, error) {
|
||||
// startOffer creates a session, sends our ekey, waits up to ekeyTimeout for
|
||||
// the peer's ekey, then sends the offer (ephemeral or static).
|
||||
func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*peerSession, error) {
|
||||
pc, err := newPC()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dc, err := pc.CreateDataChannel("yaw", &webrtc.DataChannelInit{Ordered: boolPtr(true)})
|
||||
sess, err := newSession(pc)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dc, err := pc.CreateDataChannel("yaw", &webrtc.DataChannelInit{Ordered: boolPtr(true)})
|
||||
if err != nil {
|
||||
sess.close()
|
||||
return nil, err
|
||||
}
|
||||
mesh.WireDataChannel(dc, pc, peerID, id, m)
|
||||
mesh.WireCandidateTrickle(pc, peerID, s)
|
||||
|
||||
sdpOffer, err := pc.CreateOffer(nil)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := pc.SetLocalDescription(sdpOffer); err != nil {
|
||||
pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
return pc, s.SendTo(peerID, proto.SignalingPayload{Kind: proto.SigOffer, SDP: sdpOffer.SDP})
|
||||
// Handle file DataChannels opened by the remote peer.
|
||||
pc.OnDataChannel(func(dc *webrtc.DataChannel) {
|
||||
if strings.HasPrefix(dc.Label(), "f:") {
|
||||
xid := strings.TrimPrefix(dc.Label(), "f:")
|
||||
m.HandleInboundFileDC(dc, xid, peerID)
|
||||
}
|
||||
})
|
||||
|
||||
// Send our ekey immediately.
|
||||
sendEkey(peerID, sess, id, s)
|
||||
|
||||
// Build the offer in a goroutine so we don't block the read loop.
|
||||
go func() {
|
||||
// Wait for peer's ekey or fall back after timeout.
|
||||
select {
|
||||
case <-sess.ekeyRx:
|
||||
log.Printf("anchor: 2.1 FS offer to %s", peerID.Short())
|
||||
case <-time.After(ekeyTimeout):
|
||||
log.Printf("anchor: 2.0 fallback offer to %s (no ekey received)", peerID.Short())
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
sdpOffer, err := pc.CreateOffer(nil)
|
||||
if err != nil {
|
||||
log.Printf("anchor: create offer to %s: %v", peerID.Short(), err)
|
||||
return
|
||||
}
|
||||
if err := pc.SetLocalDescription(sdpOffer); err != nil {
|
||||
log.Printf("anchor: set local offer to %s: %v", peerID.Short(), err)
|
||||
return
|
||||
}
|
||||
payload := proto.SignalingPayload{Kind: proto.SigOffer, SDP: sdpOffer.SDP}
|
||||
if err := s.sealAndSend(peerID, payload, sess); err != nil {
|
||||
log.Printf("anchor: send offer to %s: %v", peerID.Short(), err)
|
||||
}
|
||||
}()
|
||||
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func answer(payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*webrtc.PeerConnection, error) {
|
||||
// answerOffer processes an incoming offer and returns the PeerConnection.
|
||||
func answerOffer(ctx context.Context, payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender, sess *peerSession) (*webrtc.PeerConnection, error) {
|
||||
pc, err := newPC()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pc.OnDataChannel(func(dc *webrtc.DataChannel) {
|
||||
if dc.Label() == "yaw" {
|
||||
switch {
|
||||
case dc.Label() == "yaw":
|
||||
mesh.WireDataChannel(dc, pc, fromID, id, m)
|
||||
case strings.HasPrefix(dc.Label(), "f:"):
|
||||
xid := strings.TrimPrefix(dc.Label(), "f:")
|
||||
m.HandleInboundFileDC(dc, xid, fromID)
|
||||
}
|
||||
})
|
||||
mesh.WireCandidateTrickle(pc, fromID, s)
|
||||
@@ -270,7 +501,13 @@ func answer(payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Iden
|
||||
pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
return pc, s.SendTo(fromID, proto.SignalingPayload{Kind: proto.SigAnswer, SDP: sdpAnswer.SDP})
|
||||
|
||||
answerPayload := proto.SignalingPayload{Kind: proto.SigAnswer, SDP: sdpAnswer.SDP}
|
||||
if err := s.sealAndSend(fromID, answerPayload, sess); err != nil {
|
||||
pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
return pc, nil
|
||||
}
|
||||
|
||||
// ── sender implements mesh.Anchor ────────────────────────────────────────────
|
||||
@@ -280,6 +517,7 @@ type sender struct {
|
||||
sendCh chan proto.AnchorMessage
|
||||
}
|
||||
|
||||
// SendTo seals with STATIC keys (used for ekey and 2.0 fallback).
|
||||
func (s *sender) SendTo(peerID proto.PeerID, payload proto.SignalingPayload) error {
|
||||
plaintext, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -290,8 +528,33 @@ func (s *sender) SendTo(peerID proto.PeerID, payload proto.SignalingPayload) err
|
||||
return fmt.Errorf("derive curve key for %s: %w", peerID.Short(), err)
|
||||
}
|
||||
sealed := crypto.SignalingBox(plaintext, recipientCurve, s.id.CurvePrivateKey())
|
||||
return s.enqueue(peerID, sealed)
|
||||
}
|
||||
|
||||
// sealAndSend seals with ephemeral keys if available, static otherwise.
|
||||
func (s *sender) sealAndSend(peerID proto.PeerID, payload proto.SignalingPayload, sess *peerSession) error {
|
||||
plaintext, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var sealed string
|
||||
if sess != nil && sess.peerEPK != nil {
|
||||
// Ephemeral seal (2.1 FS).
|
||||
sealed = crypto.SignalingBox(plaintext, sess.peerEPK, sess.ekey.PrivateRaw())
|
||||
} else {
|
||||
// Static seal (2.0 compatible).
|
||||
recipientCurve, err := curveFromPeerID(peerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive curve key for %s: %w", peerID.Short(), err)
|
||||
}
|
||||
sealed = crypto.SignalingBox(plaintext, recipientCurve, s.id.CurvePrivateKey())
|
||||
}
|
||||
return s.enqueue(peerID, sealed)
|
||||
}
|
||||
|
||||
func (s *sender) enqueue(peerID proto.PeerID, box string) error {
|
||||
select {
|
||||
case s.sendCh <- proto.AnchorMessage{Type: proto.AnchorTo, To: string(peerID), Box: sealed}:
|
||||
case s.sendCh <- proto.AnchorMessage{Type: proto.AnchorTo, To: string(peerID), Box: box}:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("send queue full")
|
||||
@@ -300,23 +563,37 @@ func (s *sender) SendTo(peerID proto.PeerID, payload proto.SignalingPayload) err
|
||||
|
||||
func (s *sender) LocalID() proto.PeerID { return s.id.PeerID() }
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
// ── box opening ───────────────────────────────────────────────────────────────
|
||||
|
||||
func openBox(b64box string, fromID proto.PeerID, localID *crypto.Identity) (proto.SignalingPayload, error) {
|
||||
// openBoxAuto opens an incoming box, trying ephemeral keys first (if available),
|
||||
// then falling back to static. Returns the payload and whether ephemeral was used.
|
||||
func openBoxAuto(b64box string, fromID proto.PeerID, localID *crypto.Identity, sess *peerSession) (proto.SignalingPayload, bool, error) {
|
||||
senderCurve, err := curveFromPeerID(fromID)
|
||||
if err != nil {
|
||||
return proto.SignalingPayload{}, err
|
||||
return proto.SignalingPayload{}, false, err
|
||||
}
|
||||
|
||||
// Try ephemeral first if we have the peer's epk.
|
||||
if sess != nil && sess.peerEPK != nil {
|
||||
if pt, err := crypto.SignalingOpen(b64box, sess.peerEPK, sess.ekey.PrivateRaw()); err == nil {
|
||||
var p proto.SignalingPayload
|
||||
if err := json.Unmarshal(pt, &p); err == nil {
|
||||
return p, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to static keys.
|
||||
plaintext, err := crypto.SignalingOpen(b64box, senderCurve, localID.CurvePrivateKey())
|
||||
if err != nil {
|
||||
return proto.SignalingPayload{}, err
|
||||
return proto.SignalingPayload{}, false, fmt.Errorf("open box: %w", err)
|
||||
}
|
||||
var p proto.SignalingPayload
|
||||
return p, json.Unmarshal(plaintext, &p)
|
||||
return p, false, json.Unmarshal(plaintext, &p)
|
||||
}
|
||||
|
||||
// curveFromPeerID derives an X25519 public key from a hex Ed25519 peer id
|
||||
// using the Montgomery conversion, identical to crypto.Identity.CurvePublicKey().
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func curveFromPeerID(id proto.PeerID) (*[32]byte, error) {
|
||||
pubBytes, err := hex.DecodeString(string(id))
|
||||
if err != nil || len(pubBytes) != 32 {
|
||||
@@ -343,6 +620,6 @@ func newPC() (*webrtc.PeerConnection, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
func strPtr(s string) *string { return &s }
|
||||
func uint16Ptr(v uint16) *uint16 { return &v }
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
func strPtr(s string) *string { return &s }
|
||||
func uint16Ptr(v uint16) *uint16 { return &v }
|
||||
|
||||
@@ -231,6 +231,21 @@ func (ek *EphemeralKey) PublicKeyB64() string {
|
||||
return b64.EncodeToString(ek.public[:])
|
||||
}
|
||||
|
||||
// PublicRaw returns a pointer to the raw 32-byte X25519 public key.
|
||||
// The returned pointer is valid for the lifetime of the EphemeralKey.
|
||||
func (ek *EphemeralKey) PublicRaw() *[32]byte { return &ek.public }
|
||||
|
||||
// PrivateRaw returns a pointer to the raw 32-byte X25519 private key.
|
||||
// Use only when you need to pass it directly to SignalingBox.
|
||||
func (ek *EphemeralKey) PrivateRaw() *[32]byte { return &ek.private }
|
||||
|
||||
// Wipe zeroes the private key. Call when the session ends.
|
||||
func (ek *EphemeralKey) Wipe() {
|
||||
for i := range ek.private {
|
||||
ek.private[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// SharedSecret performs ECDH with the other party's public key.
|
||||
// Returns a 32-byte shared secret suitable for use as an AEAD key.
|
||||
func (ek *EphemeralKey) SharedSecret(theirPublicB64 string) ([32]byte, error) {
|
||||
|
||||
@@ -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,8 +246,34 @@ 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:
|
||||
send(errMsg("file transfer not yet implemented"))
|
||||
n := mgr.Resolve(cmd.NetworkID)
|
||||
if n == nil {
|
||||
send(errMsg("send_file: not joined to any network"))
|
||||
continue
|
||||
}
|
||||
if cmd.PeerID == nil {
|
||||
send(errMsg("send_file: peer_id is required"))
|
||||
continue
|
||||
}
|
||||
if cmd.Path == "" {
|
||||
send(errMsg("send_file: path is required"))
|
||||
continue
|
||||
}
|
||||
if err := n.Mesh.OfferFile(*cmd.PeerID, cmd.Path); err != nil {
|
||||
send(errMsg(fmt.Sprintf("send_file: %v", err)))
|
||||
}
|
||||
|
||||
default:
|
||||
send(errMsg(fmt.Sprintf("unknown command: %s", cmd.Type)))
|
||||
@@ -277,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
|
||||
@@ -307,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/pion/webrtc/v3"
|
||||
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/proto"
|
||||
"github.com/waste-go/internal/store"
|
||||
@@ -16,18 +18,30 @@ type PeerConn struct {
|
||||
Info proto.PeerInfo
|
||||
// Send a line of JSON to this peer (pre-encrypted by the sender goroutine).
|
||||
Send chan<- []byte
|
||||
// PC is the underlying PeerConnection, used to open additional DataChannels.
|
||||
PC *webrtc.PeerConnection
|
||||
}
|
||||
|
||||
// Mesh is the shared state of the local node.
|
||||
// All methods are safe to call from multiple goroutines.
|
||||
type Mesh struct {
|
||||
Identity *crypto.Identity
|
||||
Store *store.Store // may be nil if persistence is disabled
|
||||
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
||||
Identity *crypto.Identity
|
||||
Store *store.Store // may be nil if persistence is disabled
|
||||
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
||||
DownloadDir string // directory where received files are saved
|
||||
|
||||
mu sync.RWMutex
|
||||
peers map[proto.PeerID]*PeerConn
|
||||
|
||||
// file transfer state
|
||||
transferMu sync.Mutex
|
||||
outbound map[string]*outboundTransfer // xid → pending outbound
|
||||
inbound map[string]*inboundTransfer // xid → pending inbound
|
||||
|
||||
// PendingConnect receives peer IDs discovered via gossip that we should
|
||||
// attempt to connect to. Drained by the anchor client's runOnce loop.
|
||||
PendingConnect chan proto.PeerID
|
||||
|
||||
// subscribers receive a copy of every event (fan-out to IPC clients)
|
||||
subMu sync.Mutex
|
||||
subs []chan proto.IpcMessage
|
||||
@@ -37,9 +51,12 @@ type Mesh struct {
|
||||
// Pass a non-nil store to enable message and peer persistence.
|
||||
func New(id *crypto.Identity, st *store.Store) *Mesh {
|
||||
return &Mesh{
|
||||
Identity: id,
|
||||
Store: st,
|
||||
peers: make(map[proto.PeerID]*PeerConn),
|
||||
Identity: id,
|
||||
Store: st,
|
||||
peers: make(map[proto.PeerID]*PeerConn),
|
||||
outbound: make(map[string]*outboundTransfer),
|
||||
inbound: make(map[string]*inboundTransfer),
|
||||
PendingConnect: make(chan proto.PeerID, 32),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ func WireDataChannel(
|
||||
peerConn := &PeerConn{
|
||||
Info: proto.PeerInfo{ID: peerID, Alias: string(peerID.Short())},
|
||||
Send: sendCh,
|
||||
PC: pc,
|
||||
}
|
||||
m.AddPeer(peerConn)
|
||||
|
||||
@@ -149,6 +150,8 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m
|
||||
PeerID: peerIDPtr(from),
|
||||
Nick: hello.Nick,
|
||||
})
|
||||
// Tell the new peer about everyone we can currently see.
|
||||
go m.sendGossipTo(from)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -211,11 +214,40 @@ func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
|
||||
PeerID: peerIDPtr(from),
|
||||
Offer: &proto.FileOffer{Xid: msg.Xid, Name: msg.Name, Size: msg.Size, SHA256: msg.SHA256},
|
||||
})
|
||||
m.acceptIncoming(msg, from)
|
||||
|
||||
case proto.MsgFileAccept:
|
||||
m.startSend(msg.Xid, from)
|
||||
|
||||
case proto.MsgFileCancel:
|
||||
log.Printf("mesh: file-cancel from %s xid=%s reason=%s", from.Short(), msg.Xid, msg.Reason)
|
||||
|
||||
case proto.MsgFileDone:
|
||||
log.Printf("mesh: file-done from %s xid=%s", from.Short(), msg.Xid)
|
||||
|
||||
case proto.MsgPeerGossip:
|
||||
if msg.Gossip != nil {
|
||||
log.Printf("mesh: gossip from %s: %d peer hints", from.Short(), len(msg.Gossip.Peers))
|
||||
if msg.Gossip == nil {
|
||||
return
|
||||
}
|
||||
// Build set of peers we already know about (connected + self).
|
||||
connected := m.ConnectedPeers()
|
||||
known := make(map[proto.PeerID]bool, len(connected)+1)
|
||||
known[m.Identity.PeerID()] = true
|
||||
for _, p := range connected {
|
||||
known[p.ID] = true
|
||||
}
|
||||
newPeers := 0
|
||||
for _, entry := range msg.Gossip.Peers {
|
||||
if known[entry.Peer.ID] {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case m.PendingConnect <- entry.Peer.ID:
|
||||
newPeers++
|
||||
default:
|
||||
}
|
||||
}
|
||||
log.Printf("mesh: gossip from %s: %d hints, %d new", from.Short(), len(msg.Gossip.Peers), newPeers)
|
||||
case proto.MsgPing:
|
||||
log.Printf("mesh: ping from %s", from.Short())
|
||||
case proto.MsgPong:
|
||||
@@ -261,4 +293,29 @@ func fingerprintFromSDP(sdp string) []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendGossipTo sends the current live peer list to peerID so they can discover
|
||||
// and connect to peers they haven't met yet. Called after hello is verified.
|
||||
func (m *Mesh) sendGossipTo(peerID proto.PeerID) {
|
||||
peers := m.ConnectedPeers()
|
||||
entries := make([]proto.GossipEntry, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
if p.ID == peerID {
|
||||
continue // don't include the recipient in their own gossip
|
||||
}
|
||||
entries = append(entries, proto.GossipEntry{Peer: p})
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
wire, err := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgPeerGossip,
|
||||
Gossip: &proto.PeerGossip{Peers: entries},
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.SendTo(peerID, wire)
|
||||
log.Printf("mesh: sent gossip to %s: %d peer(s)", peerID.Short(), len(entries))
|
||||
}
|
||||
|
||||
func peerIDPtr(p proto.PeerID) *proto.PeerID { return &p }
|
||||
|
||||
351
internal/mesh/transfer.go
Normal file
351
internal/mesh/transfer.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/pion/webrtc/v3"
|
||||
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
fileChunkSize = 64 * 1024 // 64 KiB per chunk
|
||||
fileBufferHighWater = 512 * 1024 // pause sending above this
|
||||
fileBufferLowWater = 256 * 1024 // resume when it drops here
|
||||
)
|
||||
|
||||
type outboundTransfer struct {
|
||||
peerID proto.PeerID
|
||||
path string
|
||||
size int64
|
||||
sha256 string
|
||||
}
|
||||
|
||||
type inboundTransfer struct {
|
||||
from proto.PeerID
|
||||
name string
|
||||
size int64
|
||||
sha256 string
|
||||
mu sync.Mutex
|
||||
tmp *os.File
|
||||
hasher hash.Hash
|
||||
written int64
|
||||
}
|
||||
|
||||
// OfferFile reads filename from ShareDir, computes its SHA-256, and sends a
|
||||
// file-offer to peerID over the existing "yaw" DataChannel.
|
||||
func (m *Mesh) OfferFile(peerID proto.PeerID, filename string) error {
|
||||
if m.ShareDir == "" {
|
||||
return fmt.Errorf("no share directory configured")
|
||||
}
|
||||
if strings.ContainsAny(filename, "/\\") || filename == ".." {
|
||||
return fmt.Errorf("invalid filename %q", filename)
|
||||
}
|
||||
path := filepath.Join(m.ShareDir, filename)
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %q: %w", filename, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return fmt.Errorf("hash %q: %w", filename, err)
|
||||
}
|
||||
shaHex := hex.EncodeToString(h.Sum(nil))
|
||||
xid := newXid()
|
||||
|
||||
m.transferMu.Lock()
|
||||
m.outbound[xid] = &outboundTransfer{
|
||||
peerID: peerID,
|
||||
path: path,
|
||||
size: info.Size(),
|
||||
sha256: shaHex,
|
||||
}
|
||||
m.transferMu.Unlock()
|
||||
|
||||
wire, err := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgFileOffer,
|
||||
Xid: xid,
|
||||
Name: filename,
|
||||
Size: info.Size(),
|
||||
SHA256: shaHex,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !m.SendTo(peerID, wire) {
|
||||
m.transferMu.Lock()
|
||||
delete(m.outbound, xid)
|
||||
m.transferMu.Unlock()
|
||||
return fmt.Errorf("peer %s not connected", peerID.Short())
|
||||
}
|
||||
log.Printf("transfer: offered %s (%d bytes) to %s xid=%s", filename, info.Size(), peerID.Short(), xid[:8])
|
||||
return nil
|
||||
}
|
||||
|
||||
// acceptIncoming is called when we receive file-offer from a peer.
|
||||
// It stores the inbound state and immediately sends file-accept (auto-accept).
|
||||
func (m *Mesh) acceptIncoming(msg proto.PeerMessage, from proto.PeerID) {
|
||||
m.transferMu.Lock()
|
||||
m.inbound[msg.Xid] = &inboundTransfer{
|
||||
from: from,
|
||||
name: msg.Name,
|
||||
size: msg.Size,
|
||||
sha256: msg.SHA256,
|
||||
}
|
||||
m.transferMu.Unlock()
|
||||
|
||||
accept, _ := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgFileAccept,
|
||||
Xid: msg.Xid,
|
||||
})
|
||||
m.SendTo(from, accept)
|
||||
log.Printf("transfer: auto-accepted %s (%d bytes) from %s xid=%s", msg.Name, msg.Size, from.Short(), msg.Xid[:8])
|
||||
}
|
||||
|
||||
// startSend opens the binary DataChannel "f:<xid>" and streams the file.
|
||||
// Called when we receive file-accept from the peer.
|
||||
func (m *Mesh) startSend(xid string, from proto.PeerID) {
|
||||
m.transferMu.Lock()
|
||||
t, ok := m.outbound[xid]
|
||||
m.transferMu.Unlock()
|
||||
if !ok {
|
||||
log.Printf("transfer: file-accept for unknown xid %s", xid[:8])
|
||||
return
|
||||
}
|
||||
if t.peerID != from {
|
||||
log.Printf("transfer: file-accept from unexpected peer %s", from.Short())
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
conn, ok := m.peers[from]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
log.Printf("transfer: peer %s not connected for xid %s", from.Short(), xid[:8])
|
||||
return
|
||||
}
|
||||
|
||||
ordered := true
|
||||
dc, err := conn.PC.CreateDataChannel("f:"+xid, &webrtc.DataChannelInit{Ordered: &ordered})
|
||||
if err != nil {
|
||||
log.Printf("transfer: create file DC xid=%s: %v", xid[:8], err)
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for receiver to signal "ok" before sending — this ensures the receiver
|
||||
// has its OnMessage/OnClose handlers registered before any data arrives.
|
||||
dc.OnOpen(func() {
|
||||
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
if msg.IsString && string(msg.Data) == "ok" {
|
||||
go m.sendFileChunks(dc, t, xid)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Mesh) sendFileChunks(dc *webrtc.DataChannel, t *outboundTransfer, xid string) {
|
||||
defer func() {
|
||||
m.transferMu.Lock()
|
||||
delete(m.outbound, xid)
|
||||
m.transferMu.Unlock()
|
||||
}()
|
||||
|
||||
f, err := os.Open(t.path)
|
||||
if err != nil {
|
||||
log.Printf("transfer: open file xid=%s: %v", xid[:8], err)
|
||||
dc.Close()
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Backpressure: block when the DC buffer is full.
|
||||
resume := make(chan struct{}, 1)
|
||||
dc.SetBufferedAmountLowThreshold(fileBufferLowWater)
|
||||
dc.OnBufferedAmountLow(func() {
|
||||
select {
|
||||
case resume <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
buf := make([]byte, fileChunkSize)
|
||||
var sent int64
|
||||
|
||||
for {
|
||||
n, readErr := f.Read(buf)
|
||||
if n > 0 {
|
||||
for dc.BufferedAmount() > fileBufferHighWater {
|
||||
<-resume
|
||||
}
|
||||
if err := dc.Send(buf[:n]); err != nil {
|
||||
log.Printf("transfer: send chunk xid=%s: %v", xid[:8], err)
|
||||
return
|
||||
}
|
||||
sent += int64(n)
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtFileProgress,
|
||||
TransferID: xid,
|
||||
BytesReceived: sent,
|
||||
TotalBytes: t.size,
|
||||
})
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
log.Printf("transfer: read file xid=%s: %v", xid[:8], readErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
dc.Close()
|
||||
|
||||
done, _ := json.Marshal(proto.PeerMessage{
|
||||
Type: proto.MsgFileDone,
|
||||
Xid: xid,
|
||||
SHA256: t.sha256,
|
||||
})
|
||||
m.SendTo(t.peerID, done)
|
||||
log.Printf("transfer: sent %s (%d bytes) xid=%s", filepath.Base(t.path), sent, xid[:8])
|
||||
}
|
||||
|
||||
// HandleInboundFileDC is called from the anchor when a "f:<xid>" DataChannel
|
||||
// arrives on a PeerConnection. It writes chunks to a temp file, verifies the
|
||||
// SHA-256 on close, and emits EvtFileComplete.
|
||||
func (m *Mesh) HandleInboundFileDC(dc *webrtc.DataChannel, xid string, from proto.PeerID) {
|
||||
m.transferMu.Lock()
|
||||
t, ok := m.inbound[xid]
|
||||
m.transferMu.Unlock()
|
||||
if !ok {
|
||||
log.Printf("transfer: no pending inbound for xid %s", xid[:8])
|
||||
dc.Close()
|
||||
return
|
||||
}
|
||||
|
||||
var once sync.Once
|
||||
doOpen := func() {
|
||||
if err := os.MkdirAll(m.DownloadDir, 0o755); err != nil {
|
||||
log.Printf("transfer: mkdir %s: %v", m.DownloadDir, err)
|
||||
return
|
||||
}
|
||||
tmp, err := os.CreateTemp(m.DownloadDir, "dl-*.tmp")
|
||||
if err != nil {
|
||||
log.Printf("transfer: create temp file: %v", err)
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.tmp = tmp
|
||||
t.hasher = sha256.New()
|
||||
t.mu.Unlock()
|
||||
log.Printf("transfer: receiving %s xid=%s", t.name, xid[:8])
|
||||
// Signal sender that we are ready — it will not start streaming until
|
||||
// it receives this, guaranteeing our OnMessage/OnClose are registered first.
|
||||
dc.SendText("ok") //nolint:errcheck
|
||||
}
|
||||
|
||||
// Register OnMessage first so pion queues any early binary chunks.
|
||||
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
if msg.IsString {
|
||||
return // "ok" echo or any other text; ignore
|
||||
}
|
||||
t.mu.Lock()
|
||||
tmp, h := t.tmp, t.hasher
|
||||
t.mu.Unlock()
|
||||
if tmp == nil {
|
||||
return
|
||||
}
|
||||
if _, err := tmp.Write(msg.Data); err != nil {
|
||||
log.Printf("transfer: write xid=%s: %v", xid[:8], err)
|
||||
return
|
||||
}
|
||||
h.Write(msg.Data)
|
||||
t.mu.Lock()
|
||||
t.written += int64(len(msg.Data))
|
||||
written := t.written
|
||||
t.mu.Unlock()
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtFileProgress,
|
||||
TransferID: xid,
|
||||
BytesReceived: written,
|
||||
TotalBytes: t.size,
|
||||
})
|
||||
})
|
||||
|
||||
dc.OnOpen(func() { once.Do(doOpen) })
|
||||
if dc.ReadyState() == webrtc.DataChannelStateOpen {
|
||||
once.Do(doOpen)
|
||||
}
|
||||
|
||||
dc.OnClose(func() {
|
||||
t.mu.Lock()
|
||||
tmp := t.tmp
|
||||
actualSha := ""
|
||||
if t.hasher != nil {
|
||||
actualSha = hex.EncodeToString(t.hasher.Sum(nil))
|
||||
}
|
||||
name, expectedSha := t.name, t.sha256
|
||||
t.mu.Unlock()
|
||||
|
||||
m.transferMu.Lock()
|
||||
delete(m.inbound, xid)
|
||||
m.transferMu.Unlock()
|
||||
|
||||
if tmp == nil {
|
||||
return
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
if actualSha != expectedSha {
|
||||
os.Remove(tmp.Name())
|
||||
log.Printf("transfer: sha256 mismatch xid=%s got=%s want=%s", xid[:8], actualSha[:8], expectedSha[:8])
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtError,
|
||||
TransferID: xid,
|
||||
ErrorMessage: fmt.Sprintf("file %s: sha256 mismatch", name),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
final := filepath.Join(m.DownloadDir, name)
|
||||
if _, err := os.Stat(final); err == nil {
|
||||
ext := filepath.Ext(name)
|
||||
base := strings.TrimSuffix(name, ext)
|
||||
final = filepath.Join(m.DownloadDir, base+"-"+xid[:8]+ext)
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), final); err != nil {
|
||||
log.Printf("transfer: rename xid=%s: %v", xid[:8], err)
|
||||
return
|
||||
}
|
||||
log.Printf("transfer: saved %s -> %s", name, final)
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtFileComplete,
|
||||
TransferID: xid,
|
||||
Path: final,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func newXid() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b) //nolint:errcheck
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -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,9 +93,13 @@ 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)
|
||||
|
||||
// Forward all mesh events to the Manager's fan-out, tagging with network_id.
|
||||
meshEvents := m.Subscribe()
|
||||
@@ -129,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
|
||||
@@ -158,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()
|
||||
|
||||
@@ -159,15 +159,21 @@ const (
|
||||
SigAnswer SignalingKind = "answer"
|
||||
SigCandidate SignalingKind = "candidate"
|
||||
SigBye SignalingKind = "bye"
|
||||
SigEkey SignalingKind = "ekey" // YAW/2.1: ephemeral key exchange
|
||||
)
|
||||
|
||||
// SignalingPayload is the JSON plaintext sealed inside a crypto_box (YAW/2 §5).
|
||||
// SignalingPayload is the JSON plaintext sealed inside a crypto_box (YAW/2 §5 / §5.4′).
|
||||
type SignalingPayload struct {
|
||||
Kind SignalingKind `json:"kind"`
|
||||
SDP string `json:"sdp,omitempty"` // offer / answer
|
||||
Cand string `json:"cand,omitempty"` // trickle ICE candidate line
|
||||
Mid string `json:"mid,omitempty"` // media stream id for candidate
|
||||
MLine int `json:"mline,omitempty"` // media line index
|
||||
|
||||
// YAW/2.1 ekey fields (sealed under static keys)
|
||||
V string `json:"v,omitempty"` // "yaw/2.1"
|
||||
EPK string `json:"epk,omitempty"` // hex-encoded ephemeral X25519 pubkey (32 bytes)
|
||||
EkeySig string `json:"ekey_sig,omitempty"` // hex Ed25519 sig over ekey bind bytes
|
||||
}
|
||||
|
||||
// ── Anchor WebSocket wire types (YAW/2 §5) ────────────────────────────────────
|
||||
@@ -211,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"
|
||||
|
||||
@@ -225,6 +232,7 @@ const (
|
||||
EvtError IpcMsgType = "error"
|
||||
EvtInviteGenerated IpcMsgType = "invite_generated"
|
||||
EvtFileList IpcMsgType = "file_list"
|
||||
EvtFileComplete IpcMsgType = "file_complete"
|
||||
EvtNetworkJoined IpcMsgType = "network_joined"
|
||||
EvtNetworkLeft IpcMsgType = "network_left"
|
||||
)
|
||||
@@ -234,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.
|
||||
@@ -251,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
|
||||
|
||||
173
test-network.sh
173
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"
|
||||
@@ -114,6 +123,26 @@ pretty() {
|
||||
echo -e "${color}${BOLD}[${label}]${RESET} ${BOLD}💬 #${room}${RESET} ${DIM}<${from}…>${RESET} ${body}"
|
||||
fi
|
||||
;;
|
||||
incoming_file)
|
||||
local name size xid
|
||||
name=$(echo "$line" | jq -r '.offer.name // "?"' 2>/dev/null)
|
||||
size=$(echo "$line" | jq -r '.offer.size // 0' 2>/dev/null)
|
||||
xid=$(echo "$line" | jq -r '.offer.xid[:8] // "?"' 2>/dev/null)
|
||||
echo -e "${color}${BOLD}[${label}]${RESET} ${YELLOW}↓ incoming_file${RESET} ${BOLD}${name}${RESET} (${size}B) xid=${DIM}${xid}…${RESET}"
|
||||
;;
|
||||
file_progress)
|
||||
local xid rx total
|
||||
xid=$(echo "$line" | jq -r '.transfer_id[:8] // "?"' 2>/dev/null)
|
||||
rx=$(echo "$line" | jq -r '.bytes_received // 0' 2>/dev/null)
|
||||
total=$(echo "$line" | jq -r '.total_bytes // 0' 2>/dev/null)
|
||||
echo -e "${color}${BOLD}[${label}]${RESET} ${DIM}file_progress${RESET} xid=${xid}… ${rx}/${total}B"
|
||||
;;
|
||||
file_complete)
|
||||
local xid path
|
||||
xid=$(echo "$line" | jq -r '.transfer_id[:8] // "?"' 2>/dev/null)
|
||||
path=$(echo "$line" | jq -r '.path // "?"' 2>/dev/null)
|
||||
echo -e "${color}${BOLD}[${label}]${RESET} ${GREEN}✓ file_complete${RESET} xid=${DIM}${xid}…${RESET} → ${BOLD}${path}${RESET}"
|
||||
;;
|
||||
error)
|
||||
local msg
|
||||
msg=$(echo "$line" | jq -r '.error_message // .' 2>/dev/null)
|
||||
@@ -215,8 +244,7 @@ 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> >(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+=($!)
|
||||
|
||||
log "$BOB_COLOR" "bob" "starting daemon (ipc :${BOB_IPC})"
|
||||
@@ -225,8 +253,7 @@ 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> >(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+=($!)
|
||||
|
||||
log "$CHARLIE_COLOR" "charlie" "starting daemon (ipc :${CHARLIE_IPC})"
|
||||
@@ -235,8 +262,7 @@ 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> >(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+=($!)
|
||||
|
||||
wait_port "$ALICE_IPC" "alice"
|
||||
@@ -247,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 ──────────────────────────────────────────────────────────
|
||||
@@ -277,6 +307,37 @@ subscribe "$CHARLIE_COLOR" "charlie" "$CHARLIE_IPC"
|
||||
echo -e "${DIM}waiting for ICE / DataChannel setup (up to 10s)…${RESET}"
|
||||
sleep 6
|
||||
|
||||
# ── YAW/2.1 forward-secrecy check ────────────────────────────────────────────
|
||||
# The daemon logs "2.1 FS offer" when forward-secret signaling was negotiated,
|
||||
# or "2.0 fallback offer" if the peer didn't respond to the ekey in time.
|
||||
# We verify by counting FS vs fallback lines in the daemon log files.
|
||||
echo ""
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
echo -e "YAW/2.1 forward-secret signaling check"
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
|
||||
fs_ok=0
|
||||
fs_fail=0
|
||||
for peer_data in "$DATA_ROOT/alice" "$DATA_ROOT/bob" "$DATA_ROOT/charlie"; do
|
||||
logfile="$peer_data/daemon.log"
|
||||
if [ -f "$logfile" ]; then
|
||||
ok=$(grep -c "2\.1 FS offer" "$logfile" 2>/dev/null || true)
|
||||
fail=$(grep -c "2\.0 fallback" "$logfile" 2>/dev/null || true)
|
||||
fs_ok=$(( fs_ok + ok ))
|
||||
fs_fail=$(( fs_fail + fail ))
|
||||
fi
|
||||
done
|
||||
|
||||
# Also check stderr captured above (it was piped to terminal; count from the
|
||||
# variable output buffer if possible — or just note the result from logs).
|
||||
# Simpler: re-check via a short daemon log we write below.
|
||||
# For now just print what we know from the test run output.
|
||||
if [ "$fs_fail" -eq 0 ]; then
|
||||
echo -e " ${GREEN}✓ all sessions negotiated YAW/2.1 (forward-secret)${RESET}"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠ ${fs_fail} session(s) fell back to YAW/2.0 (non-FS)${RESET}"
|
||||
fi
|
||||
|
||||
# ── group chat ────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
@@ -343,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
|
||||
@@ -364,6 +425,88 @@ 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}"
|
||||
echo -e "file transfer (alice → bob: notes.txt)"
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
|
||||
ipc "$ALICE_IPC" "$(jq -cn --arg peer "$BOB_ID" --arg path "notes.txt" \
|
||||
'{"type":"send_file","peer_id":$peer,"path":$path}')"
|
||||
|
||||
# Wait up to 10s for bob to receive the file (glob over downloads-* dir)
|
||||
received=0
|
||||
for i in $(seq 1 50); do
|
||||
sleep 0.2
|
||||
if ls "$DATA_ROOT/bob/downloads-"*/notes.txt 2>/dev/null | grep -q .; then
|
||||
received=1; break
|
||||
fi
|
||||
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/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}"
|
||||
else
|
||||
echo -e " ${RED}✗ notes.txt received but sha256 mismatch!${RESET}"
|
||||
echo -e " orig: $orig_sha"
|
||||
echo -e " got: $rx_sha"
|
||||
fi
|
||||
else
|
||||
echo -e " ${RED}✗ notes.txt not received by bob within 10s${RESET}"
|
||||
fi
|
||||
sleep 0.5
|
||||
|
||||
# ── leave ─────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||
|
||||
37
test-tui.sh
37
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,39 +97,42 @@ 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>/dev/null &
|
||||
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>/dev/null &
|
||||
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>/dev/null &
|
||||
2>"$DATA_ROOT/charlie/daemon.log" &
|
||||
PIDS+=($!)
|
||||
|
||||
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