Files
waste-go/README.md
Fredrik Johansson 274ff423f6 feat: implement YAW/2.1 forward-secret signaling
Upgrades the signaling layer from static X25519 (2.0) to per-session
ephemeral X25519 (2.1). Recorded signaling traffic cannot be decrypted
even if long-term Ed25519 keys later leak, because esk is zeroed on
session close.

Protocol:
- Each peer generates a fresh X25519 keypair (esk/epk) per session.
- Peers exchange signed `ekey` messages sealed under static keys before
  the offer/answer. Offer/answer/candidate payloads are then sealed with
  ephemeral keys (crypto_box(·, peer_epk, my_esk)).
- ekey sig binds both peer IDs and the epk to prevent replay to third parties.
- Offerer waits up to 2 s for the peer's ekey; if none arrives it falls back
  to YAW/2.0 static-key sealing and logs "2.0 fallback offer".
- 2.0 peers silently ignore the unknown `ekey` kind — full interop preserved.

Implementation:
- crypto.go: add EphemeralKey.PublicRaw/PrivateRaw/Wipe helpers.
- proto.go: add SigEkey kind; EPK/V/EkeySig fields on SignalingPayload.
- anchor/client.go: replace flat pcs map with peerSession struct tracking
  ephemeral keys, peerEPK, and fs flag; openBoxAuto tries ephemeral then
  static; sealAndSend chooses seal based on session state.
- test-network.sh: pipe daemon stderr through tee to daemon.log; add
  YAW/2.1 FS verification section.
- test-tui.sh: same daemon.log capture.
- README.md: document 2.1 forward secrecy, file transfer IPC, updated roadmap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 14:45:15 +02:00

254 lines
11 KiB
Markdown

# waste-go
A modern reimagining of [WASTE](https://en.wikipedia.org/wiki/WASTE) — decentralized,
friend-to-friend encrypted mesh networking with chat and file sharing. Written in Go.
## Project layout
```
waste-go/
├── cmd/
│ ├── daemon/ The peer process — run one on each friend's machine
│ ├── anchor/ WebSocket signaling server — run this on your Hetzner VPS
│ └── tui/ Bubble Tea terminal UI (connects to a running daemon)
└── internal/
├── proto/ All wire types (shared by daemon and anchor)
├── crypto/ Ed25519 identity, nacl/box signaling, ChaCha20-Poly1305
├── mesh/ Connected peer state + DataChannel helpers
├── anchor/ Anchor client — WebRTC signaling via the anchor server
└── ipc/ Local JSON API (UI talks to daemon here, port 17337)
```
## Prerequisites
- Go 1.24+ → https://go.dev/dl/
- VS Code with the Go extension (`golang.go`)
On first open VS Code will prompt you to install `gopls`, `dlv`, and `goimports` — accept all of them.
## Getting started
```bash
# Fetch dependencies
go mod tidy
# Build everything (confirms it compiles)
go build ./...
# Terminal 1 — anchor (required for peers to find each other)
go run ./cmd/anchor -bind 127.0.0.1:17339
# Terminal 2 — peer A
go run ./cmd/daemon -alias alice -data-dir /tmp/waste-alice -ipc-port 17337 -anchor ws://127.0.0.1:17339/ws
# Terminal 3 — peer B (or use --join with an invite from peer A)
go run ./cmd/daemon -alias bob -data-dir /tmp/waste-bob -ipc-port 17341 -anchor ws://127.0.0.1:17339/ws
```
Both peers join the same named network via IPC:
```bash
# Join peer A to a network called "friends"
echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17337
# Join peer B to the same network
echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17341
# Subscribe to peer A's events (in a separate terminal)
nc 127.0.0.1 17337 &
# Send a message from B
echo '{"type":"send_message","room":"general","body":"hello from bob"}' | nc 127.0.0.1 17341
```
**On Windows** — use PowerShell's built-in TCP client instead of `nc`:
```powershell
$c = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 17341)
$w = [System.IO.StreamWriter]::new($c.GetStream()); $w.AutoFlush = $true
$w.WriteLine('{"type":"join_network","network_name":"friends"}')
$w.WriteLine('{"type":"send_message","room":"general","body":"hello from bob"}')
# In a separate terminal — subscribe to peer A's events
$r = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 17337)
$reader = [System.IO.StreamReader]::new($r.GetStream())
while ($true) { $reader.ReadLine() }
```
## Deploying the anchor on your Hetzner VPS
```bash
GOOS=linux GOARCH=amd64 go build -o bin/waste-anchor ./cmd/anchor
scp bin/waste-anchor user@your-vps:~/
# On the VPS (also run coturn in STUN-only mode on port 3478)
./waste-anchor -bind 0.0.0.0:17339
```
Then start daemons with `-anchor ws://your-vps-ip:17339/ws` and they'll connect via WebRTC
with ICE (STUN-assisted hole punching) through the anchor for signaling.
## IPC protocol (plain JSON over TCP)
Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`.
**Commands you send:**
```jsonc
{"type":"join_network","network_name":"friends"}
{"type":"leave_network"}
{"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"}
```
**Events the daemon pushes:**
```jsonc
// Sent immediately on connect and in response to get_state
{"type":"state_snapshot","local_peer":{"id":"<64-hex>","alias":"alice","public_key":"<64-hex>","created_at":"..."},"connected_peers":[...],"rooms":["general"]}
// Peer lifecycle
{"type":"peer_connected","peer":{"id":"<64-hex>","alias":"bob",...}}
{"type":"session_ready","peer_id":"<64-hex>","nick":"bob"}
{"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>","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>"}
// Error
{"type":"error","error_message":"..."}
```
## Crypto choices
| Purpose | Algorithm | Notes |
|---|---|---|
| Identity | Ed25519 | Fast, small keys, standard |
| Peer ID | Hex-encoded Ed25519 pubkey | 64 lowercase hex chars (YAW/2 §2) |
| 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.
## Onboarding a new peer
Alice is already on the network and wants to add Bob.
**Alice generates an invite** (from the TUI with `Ctrl+I`, or via IPC directly):
```bash
echo '{"type":"generate_invite"}' | nc 127.0.0.1 17337
# → {"type":"invite_generated","invite":"waste:eyJhbmNob3IiOiJ3czovL..."}
```
**Bob starts his daemon using the invite** — the `--join` flag sets the anchor URL and auto-joins the network:
```bash
go run ./cmd/daemon -alias bob -data-dir ~/.waste-bob --join 'waste:eyJhbmNob3IiOiJ3czovL...'
```
**Bob opens the TUI**`--join` also accepts the invite to skip the `-network` flag:
```bash
go run ./cmd/tui --join 'waste:eyJhbmNob3IiOiJ3czovL...'
```
The invite encodes the anchor URL and network name as a `waste:` URI. Share it over Signal, email, or any side channel — the anchor never sees plaintext messages, so the invite leaking to a third party only lets them join the same network (which is by design: same network = mutual trust).
## Terminal UI
Start the daemon first (see Getting started above), then:
```bash
go run ./cmd/tui -network friends
```
Options:
| Flag | Default | Description |
|---|---|---|
| `-network` | *(required unless -join)* | Network name to join on startup |
| `-join` | — | `waste:` invite string — sets the network name automatically |
| `-ipc` | `17337` | Daemon IPC port |
**Layout:**
```
╭─ Rooms ──────╮╭─── #general ────────────────╮╭─ Peers ──────╮
│ ▶ #general ││ 15:04 alice hey everyone ││ ◉ alice (me) │
│ @ bob ││ 15:04 bob hi alice! ││ ● bob │
│ ││ 15:05 charlie the mesh works ││ ● charlie │
╰──────────────╯╰─────────────────────────────╯╰──────────────╯
╭─────────────────────────────────────────────────────────────╮
│ Type a message… │
╰─────────────────────────────────────────────────────────────╯
```
**Key bindings:** `Tab` / `Shift+Tab` — switch rooms · `PgUp` / `PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close invite overlay · `Ctrl+C` — quit
## Testing
A self-contained test script boots anchor + three peers, joins them to a named network, exchanges group messages and DMs, and verifies SQLite persistence:
```bash
./test-network.sh
```
Data lands at `/tmp/waste-test` (wiped on each run). Inspect after a run:
```bash
# 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, 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
- [x] **Proto additions**`mid` dedup field, signaling types, anchor wire types, `hello` message
- [x] **Anchor server** (`cmd/anchor`) — WebSocket signaling server replacing TCP relay
- [x] **WebRTC peer connections** — pion/webrtc DataChannels; ICE hole-punching via STUN
- [x] **Anchor client** (`internal/anchor`) — offer/answer/candidate lifecycle, `nacl/box` sealing
- [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
- [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)