diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..68efba2 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,425 @@ +# Migrating waste-go → YAW/2 + +This document describes every change needed to make waste-go speak the YAW/2 +protocol and interoperate with the reference implementation. Read it top to +bottom before touching any code — the changes have an order that matters. + +--- + +## What stays, what goes, what changes + +| Package | Fate | Reason | +|---|---|---| +| `internal/crypto` | **Keep, modify** | Ed25519 is correct; swap encoding + add nacl/box | +| `internal/proto` | **Keep, extend** | Message shapes are close; add `mid`, tweak file transfer | +| `internal/mesh` (state) | **Keep, modify** | Peer map, event fan-out, IPC subscriptions all survive | +| `internal/mesh/peer.go` | **Replace** | Raw TCP → WebRTC DataChannel | +| `internal/ipc` | **Keep, minor tweaks** | IPC shape doesn't change | +| `internal/nat` | **Delete** | ICE/STUN inside pion/webrtc replaces this entirely | +| `cmd/relay` | **Replace** | TCP forwarder → WebSocket signaling anchor | + +--- + +## Step 1 — New dependency: pion/webrtc + +Add to `go.mod`: + +``` +github.com/pion/webrtc/v3 v3.x.x +golang.org/x/crypto (already present) +``` + +Run `go mod tidy` after editing. + +`pion/webrtc` is a pure-Go implementation of the WebRTC stack. It gives you ICE +(host + server-reflexive candidates), DTLS, SCTP, and DataChannels — everything +YAW/2's transport layer assumes. You no longer implement any of that yourself. + +--- + +## Step 2 — `internal/crypto`: three changes + +### 2a. Identity encoding: base64url → hex + +YAW/2 `id` is lowercase hex of the 32-byte Ed25519 public key (64 chars). + +```go +// Before +import "encoding/base64" +var b64 = base64.RawURLEncoding +func (id *Identity) PeerID() proto.PeerID { + return proto.PeerID(b64.EncodeToString(id.PublicKey)) +} + +// After +import "encoding/hex" +func (id *Identity) PeerID() proto.PeerID { + return proto.PeerID(hex.EncodeToString(id.PublicKey)) +} +``` + +Update `Verify` to decode hex instead of base64url. This is a **wire-breaking +change** — existing identity files still work (they store the raw key bytes), +only the over-the-wire representation changes. + +### 2b. Signaling crypto: add nacl/box + +YAW/2 seals signaling payloads (offer/answer/candidates) with libsodium's +`crypto_box` — XSalsa20-Poly1305. In Go this is `golang.org/x/crypto/nacl/box`, +which is byte-identical to libsodium. + +Add to `internal/crypto/crypto.go`: + +```go +import "golang.org/x/crypto/nacl/box" + +// SignalingBox seals a plaintext payload for a recipient. +// Returns base64(nonce(24) || ciphertext) as the YAW/2 spec requires. +func SignalingBox(plaintext []byte, recipientPub, senderPriv *[32]byte) string { + var nonce [24]byte + rand.Read(nonce[:]) + ct := box.Seal(nonce[:], plaintext, &nonce, recipientPub, senderPriv) + return base64.StdEncoding.EncodeToString(ct) +} + +// SignalingOpen opens a sealed box from a sender. +func SignalingOpen(b64box string, senderPub, recipientPriv *[32]byte) ([]byte, error) { + raw, err := base64.StdEncoding.DecodeString(b64box) + if err != nil || len(raw) < 24 { + return nil, errors.New("invalid box") + } + var nonce [24]byte + copy(nonce[:], raw[:24]) + out, ok := box.Open(nil, raw[24:], &nonce, senderPub, recipientPriv) + if !ok { + return nil, errors.New("box open failed") + } + return out, nil +} +``` + +### 2c. X25519 keys must be derived from the Ed25519 identity + +YAW/2 requires that signaling boxes are sealed using X25519 keys derived from +the Ed25519 identity — not independently generated. This is the libsodium +`crypto_sign_ed25519_pk_to_curve25519` / `crypto_sign_ed25519_sk_to_curve25519` +conversion. + +Add to `Identity`: + +```go +import "filippo.io/edwards25519" + +// CurvePublicKey returns the X25519 public key derived from this identity. +// Used for sealing signaling boxes (YAW/2 §3). +func (id *Identity) CurvePublicKey() *[32]byte { + edPoint, _ := new(edwards25519.Point).SetBytes(id.PublicKey) + mont := edPoint.BytesMontgomery() + var out [32]byte + copy(out[:], mont) + return &out +} + +// CurvePrivateKey returns the X25519 private key derived from this identity. +func (id *Identity) CurvePrivateKey() *[32]byte { + // Ed25519 private key is 64 bytes: scalar(32) || pubkey(32) + // The X25519 scalar is the clamped SHA-512 first half. + h := sha512.Sum512(id.privateKey[:32]) + h[0] &= 248 + h[31] &= 127 + h[31] |= 64 + var out [32]byte + copy(out[:], h[:32]) + return &out +} +``` + +Add `filippo.io/edwards25519` to `go.mod` (it's a Go standard library +dependency already pulled in transitively by `golang.org/x/crypto` — just +import it directly). + +--- + +## Step 3 — `internal/proto`: wire format updates + +### 3a. PeerID is now hex + +```go +// PeerID is lowercase hex of the 32-byte Ed25519 public key (64 chars). +type PeerID string + +func (p PeerID) Short() string { + // YAW/2 short id: first 16 hex chars, grouped in 4s: "a1b2 c3d4 e5f6 0718" + s := string(p) + if len(s) < 16 { + return s + } + return s[0:4] + " " + s[4:8] + " " + s[8:12] + " " + s[12:16] +} +``` + +### 3b. Every application message needs a `mid` + +YAW/2 requires a `mid` (random 16-byte hex) on every DataChannel message for +deduplication in the full mesh (§8). + +```go +type ChatMessage struct { + Mid string `json:"mid"` // add this — random 16-byte hex + ID string `json:"id"` // can keep for internal use + From PeerID `json:"from"` + // ... rest unchanged +} +``` + +Generate `mid` the same way as message IDs: `hex.EncodeToString(randomBytes(16))`. + +### 3c. Signaling payload types (new) + +Add a new group of types for the signaling layer — these go *inside* the sealed +boxes exchanged over the WebSocket anchor connection: + +```go +// SignalingKind identifies the kind of sealed signaling payload. +type SignalingKind string + +const ( + SigOffer SignalingKind = "offer" + SigAnswer SignalingKind = "answer" + SigCandidate SignalingKind = "candidate" + SigBye SignalingKind = "bye" +) + +// SignalingPayload is the JSON plaintext sealed inside a crypto_box. +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"` +} +``` + +### 3d. Anchor WebSocket wire types (new) + +The WebSocket connection to the anchor speaks its own JSON protocol (§5). +Add these alongside the existing IPC types: + +```go +// AnchorMsgType identifies anchor WebSocket messages. +type AnchorMsgType string + +const ( + AnchorChallenge AnchorMsgType = "challenge" + AnchorJoin AnchorMsgType = "join" + AnchorJoined AnchorMsgType = "joined" + AnchorPeerJoin AnchorMsgType = "peer-join" + AnchorPeerLeave AnchorMsgType = "peer-leave" + AnchorTo AnchorMsgType = "to" + AnchorFrom AnchorMsgType = "from" + AnchorNoPeer AnchorMsgType = "no-peer" +) + +// AnchorMessage covers all WebSocket frames to/from the anchor. +type AnchorMessage struct { + Type AnchorMsgType `json:"type"` + Nonce string `json:"nonce,omitempty"` // challenge nonce, hex + ID string `json:"id,omitempty"` // peer hex id + Net string `json:"net,omitempty"` // hashed network name + Sig string `json:"sig,omitempty"` // ed25519 sig, hex + Peers []string `json:"peers,omitempty"` // joined response + To string `json:"to,omitempty"` // sealed relay target + From string `json:"from,omitempty"` // sealed relay sender + Box string `json:"box,omitempty"` // base64 crypto_box +} +``` + +### 3e. DataChannel `hello` type (new) + +The first message on every opened DataChannel is a mandatory identity +confirmation (§6). Add: + +```go +// HelloMessage is the first message sent on the "yaw" DataChannel. +// The sig binds this identity to the specific DTLS session. +type HelloMessage struct { + Type string `json:"type"` // always "hello" + ID string `json:"id"` // hex pubkey + Nick string `json:"nick"` // alias + Caps []string `json:"caps"` // capability list, e.g. ["chat","file"] + Sig string `json:"sig"` // hex ed25519 sig over bind string (see below) +} + +// HelloBindString returns the bytes the hello signature covers: +// "yaw/2 bind" || localDTLSFP(32 bytes) || remoteDTLSFP(32 bytes) +func HelloBindString(localFP, remoteFP []byte) []byte { + buf := []byte("yaw/2 bind") + buf = append(buf, localFP...) + buf = append(buf, remoteFP...) + return buf +} +``` + +### 3f. File transfer: dedicated channel, no chunk type in main channel + +Remove `FileChunk` from `PeerMessage` — chunks go over a separate binary +DataChannel labeled `f:`. Keep `FileOffer`, `FileResponse`, and add +`FileDone`: + +```go +type FileDone struct { + Mid string `json:"mid"` + Xid string `json:"xid"` // transfer id + SHA256 string `json:"sha256"` // hex, for receiver to verify +} +``` + +--- + +## Step 4 — `internal/mesh/peer.go`: replace with pion/webrtc + +This is the largest single change. The current file (raw TCP dial/accept, +manual ECDH, encrypt/decrypt loop) is replaced entirely. + +The new shape: + +```go +// ConnectToPeer initiates a WebRTC session with a peer. +// Called when we are the offerer (our id < their id, lexicographically). +func ConnectToPeer(peerID proto.PeerID, m *Mesh, anchor Anchor) error { + pc, err := webrtc.NewPeerConnection(webrtc.Configuration{ + ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.fnlr.se:3478"}}}, + }) + // create "yaw" DataChannel + dc, _ := pc.CreateDataChannel("yaw", &webrtc.DataChannelInit{Ordered: ptr(true)}) + // wire up ICE candidate trickle → seal → send via anchor + pc.OnICECandidate(func(c *webrtc.ICECandidate) { /* seal and send */ }) + // create offer, set local description, seal it, send via anchor + offer, _ := pc.CreateOffer(nil) + pc.SetLocalDescription(offer) + // seal offer → anchor.SendTo(peerID, sealedOffer) + // ... dc.OnOpen → sendHello, dc.OnMessage → handleMessage +} + +// HandleAnswer processes a sealed answer received from the anchor. +func HandleAnswer(sealed string, fromID proto.PeerID, pc *webrtc.PeerConnection, ...) error { + // open the nacl box, unmarshal SignalingPayload, set remote description +} +``` + +Key things pion handles for you that you were going to write manually: +- All ICE candidate gathering (host + server-reflexive via STUN) +- The DTLS handshake and key material +- SCTP framing over UDP +- DataChannel reliable/ordered delivery + +Key things you still write: +- Sealing/opening signaling payloads with `nacl/box` before sending to anchor +- The `hello` confirmation on DataChannel open (mandatory per §6) +- Verifying the hello signature against DTLS fingerprints +- Deciding who offers (smaller id offers — one `strings.Compare` call) + +--- + +## Step 5 — `internal/nat`: delete it + +Remove the package entirely. ICE does what this package was going to do, and +does it correctly for both host-to-host and NAT-traversal cases. The only +"NAT" configuration you provide is the STUN server URL passed to +`webrtc.NewPeerConnection`. + +Update `cmd/daemon/main.go` to remove the `nat.Run(...)` goroutine and the +`-relay` flag. + +--- + +## Step 6 — `cmd/relay` → `cmd/anchor` + +Rename the binary and replace its internals. The anchor is a WebSocket server, +not a TCP server. It never reads the content of `box` fields. + +Rough structure: + +```go +// cmd/anchor/main.go +// Uses golang.org/x/net/websocket or nhooyr.io/websocket + +// Per-connection state +type client struct { + id string // hex peer id, set after join + net string // hashed network name + ch chan []byte // outbound message queue +} + +// On "join": verify ed25519 sig over (nonce || net), register (net, id) → client +// On "to": look up (net, to) → forward {type:"from", from:senderID, box:...} +// On disconnect: broadcast {type:"peer-leave", id:...} to net members +``` + +The anchor also runs (or points to) a STUN server. The simplest approach is +to run `coturn` on your Hetzner VPS in STUN-only mode alongside the anchor +binary, or use a public STUN server during development (`stun:stun.l.google.com:19302`). + +The network name is never stored in plaintext: + +```go +func hashNetName(name string) string { + h := sha256.Sum256([]byte("yaw2-net:" + name)) + return hex.EncodeToString(h[:]) +} +``` + +--- + +## Step 7 — `internal/ipc`: minor additions + +The IPC layer mostly survives. Add two new commands: + +```go +// Connect via anchor (replaces direct TCP connect) +CmdJoinNetwork IpcMsgType = "join_network" // fields: network_name (plaintext) +CmdLeaveNetwork IpcMsgType = "leave_network" + +// New event when a peer's DataChannel opens and hello is verified +EvtSessionReady IpcMsgType = "session_ready" // fields: peer_id, nick +``` + +Remove `CmdConnect` (direct TCP dial) — there's no direct dialing in YAW/2, +only joining a named network via the anchor. + +--- + +## Order to implement + +Do these in order — each step produces something testable before moving to the next. + +1. **Crypto changes** (Step 2) — unit-testable, no network involved. Write a + test that round-trips a `SignalingBox` seal/open and confirms the hex id format. + +2. **Proto additions** (Step 3) — add types, confirm it compiles. + +3. **Anchor server** (Step 6) — build this first so you have something to connect + to. Test with `websocat` or a browser `WebSocket` console. + +4. **WebRTC peer connection** (Step 4) — start with two daemons on the same LAN, + anchor running locally. Confirm ICE succeeds and the `hello` bind check passes. + +5. **Delete nat, update ipc, update daemon main** (Steps 5 + 7) — cleanup after + the above works. + +6. **File transfer** — once chat works, add the `f:` DataChannel flow. + +--- + +## What interoperability actually means in practice + +Once this is done, your Go daemon and your friend's web/Tauri/Python client can +be in the same named network. They'll connect to the same anchor WebSocket, +exchange sealed offers/answers, and open DataChannels directly to each other. +The `hello` message format, the signaling box format, and the DataChannel +message types are the shared surface — as long as those match §5–§9 of the +YAW/2 spec, the implementations are interoperable regardless of language. + +The reference STUN and signaling endpoints in the spec are `stun.fnlr.se:3478` +and `wss://fnlr.se/...` (path TBD) — coordinate with your friend on the final +WebSocket path before wiring it in. \ No newline at end of file diff --git a/README.md b/README.md index 8209b85..32e2f14 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,19 @@ friend-to-friend encrypted mesh networking with chat and file sharing. Written i ``` waste-go/ ├── cmd/ -│ ├── daemon/ The peer process — run one on each friend's machine -│ └── relay/ Bootstrap/relay server — run this on your Hetzner VPS +│ ├── daemon/ The peer process — run one on each friend's machine +│ └── anchor/ WebSocket signaling server — run this on your Hetzner VPS └── internal/ - ├── proto/ All wire types (shared by daemon and relay) - ├── crypto/ Ed25519 identity, X25519 ECDH, ChaCha20-Poly1305 - ├── mesh/ Connected peer state + per-connection handler - ├── ipc/ Local JSON API (UI talks to daemon here, port 17337) - └── nat/ Relay client (hole-punching lives here later) + ├── 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.22+ → https://go.dev/dl/ +- 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. @@ -34,38 +34,39 @@ go mod tidy # Build everything (confirms it compiles) go build ./... -# Terminal 1 — relay (optional for LAN testing, required across internet) -go run ./cmd/relay -bind 127.0.0.1:17339 +# 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 -peer-port 17338 -ipc-port 17337 +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 -go run ./cmd/daemon -alias bob -data-dir /tmp/waste-bob -peer-port 17340 -ipc-port 17341 +go run ./cmd/daemon -alias bob -data-dir /tmp/waste-bob -ipc-port 17341 -anchor ws://127.0.0.1:17339/ws ``` -Then connect B → A and send a message (netcat works fine as a quick test): +Both peers join the same named network via IPC: ```bash -# Tell peer B to connect to peer A -echo '{"type":"connect","addr":"127.0.0.1:17338"}' | nc 127.0.0.1 17341 +# Join peer A to a network called "friends" +echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17337 -# In another terminal — subscribe to peer A's events, then send a message from B +# 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 -# Open a connection to peer B's IPC port and keep it open $c = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 17341) $w = [System.IO.StreamWriter]::new($c.GetStream()); $w.AutoFlush = $true -# Tell peer B to connect to peer A -$w.WriteLine('{"type":"connect","addr":"127.0.0.1:17338"}') - -# Send a chat message from B +$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 @@ -74,20 +75,18 @@ $reader = [System.IO.StreamReader]::new($r.GetStream()) while ($true) { $reader.ReadLine() } ``` -Keep `$c` / `$w` in scope for the session; closing them disconnects the peer. - -## Deploying the relay on your Hetzner VPS +## Deploying the anchor on your Hetzner VPS ```bash -GOOS=linux GOARCH=amd64 go build -o bin/waste-relay ./cmd/relay -scp bin/waste-relay user@your-vps:~/ +GOOS=linux GOARCH=amd64 go build -o bin/waste-anchor ./cmd/anchor +scp bin/waste-anchor user@your-vps:~/ -# On the VPS -./waste-relay -bind 0.0.0.0:17339 +# 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 `-relay your-vps-ip:17339` and they'll register and -be able to find each other across NAT. +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) @@ -95,7 +94,8 @@ Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`. **Commands you send:** ```jsonc -{"type":"connect","addr":"1.2.3.4:17338"} +{"type":"join_network","network_name":"friends"} +{"type":"leave_network"} {"type":"send_message","room":"general","body":"hi"} {"type":"get_state"} ``` @@ -104,8 +104,9 @@ Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`. ```jsonc {"type":"state_snapshot","local_peer":{...},"connected_peers":[...]} {"type":"peer_connected","peer":{...}} -{"type":"message_received","message":{"from":"...","body":"hi","room":"general"}} -{"type":"peer_disconnected","peer_id":"..."} +{"type":"session_ready","peer_id":"","nick":"alice"} +{"type":"message_received","message":{"from":"","body":"hi","room":"general"}} +{"type":"peer_disconnected","peer_id":""} ``` ## Crypto choices @@ -113,17 +114,25 @@ Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`. | Purpose | Algorithm | Notes | |---|---|---| | Identity | Ed25519 | Fast, small keys, standard | -| Key exchange | X25519 ECDH | Per-session ephemeral keys | -| Symmetric | ChaCha20-Poly1305 | No AES-NI needed, authenticated | -| Hashing | SHA-256 | File integrity | +| 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) | +| 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. +> 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. + ## Roadmap -- [ ] UDP hole-punching (STUN) in `internal/nat` -- [ ] Invite file format (`.waste-invite`) — share a keypair + address hint -- [ ] Peer gossip → auto-connect to friends-of-friends -- [ ] File transfer -- [ ] Message persistence (SQLite via `modernc.org/sqlite`) -- [ ] Tauri or simple web UI consuming the IPC port +- [x] **Crypto layer** — hex peer IDs, `nacl/box` signaling, Ed25519→X25519 key derivation +- [x] **Proto additions** — `mid` field, signaling types, anchor wire types, `hello` message, `FileDone` +- [x] **Anchor server** (`cmd/anchor`) — WebSocket signaling server (replaces TCP relay) +- [x] **WebRTC peer connections** — pion/webrtc DataChannels replace raw TCP in `internal/mesh` +- [x] **Anchor client** (`internal/anchor`) — ICE offer/answer/candidate lifecycle, `nacl/box` sealing +- [x] **IPC updates** — `join_network`/`leave_network` replace `connect`; `session_ready` event added +- [ ] **File transfer** — chunked binary DataChannel (`f:`) +- [ ] **Message persistence** — SQLite via `modernc.org/sqlite` +- [ ] **TUI** — Bubble Tea client consuming the IPC port +- [ ] **Native UI** — web frontend with native packaging (Tauri-style) diff --git a/cmd/anchor/main.go b/cmd/anchor/main.go new file mode 100644 index 0000000..28c6e5e --- /dev/null +++ b/cmd/anchor/main.go @@ -0,0 +1,246 @@ +// waste-anchor: WebSocket signaling server for YAW/2. +// Deploy on your Hetzner VPS alongside a STUN server (coturn in STUN-only mode). +// The anchor never reads the content of "box" fields — it only routes sealed blobs. +package main + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "flag" + "log" + "net/http" + "sync" + "time" + + "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" + + "github.com/waste-go/internal/proto" +) + +func main() { + bind := flag.String("bind", "0.0.0.0:17339", "address to listen on") + flag.Parse() + + a := newAnchor() + http.HandleFunc("/ws", a.handleWS) + log.Printf("anchor: listening on %s", *bind) + if err := http.ListenAndServe(*bind, nil); err != nil { + log.Fatalf("anchor: %v", err) + } +} + +// ── Anchor ──────────────────────────────────────────────────────────────────── + +type client struct { + id string // hex peer id, set after join + net string // hashed network name, set after join + send chan proto.AnchorMessage + conn *websocket.Conn +} + +type anchor struct { + mu sync.RWMutex + clients map[string]*client // keyed by hex peer id +} + +func newAnchor() *anchor { + return &anchor{clients: make(map[string]*client)} +} + +func (a *anchor) register(c *client) { + a.mu.Lock() + a.clients[c.id] = c + a.mu.Unlock() +} + +func (a *anchor) unregister(c *client) { + if c.id == "" { + return + } + a.mu.Lock() + delete(a.clients, c.id) + a.mu.Unlock() + + // Notify everyone in the same network. + leave := proto.AnchorMessage{Type: proto.AnchorPeerLeave, ID: c.id} + a.mu.RLock() + for _, peer := range a.clients { + if peer.net == c.net { + select { + case peer.send <- leave: + default: + } + } + } + a.mu.RUnlock() + log.Printf("anchor: peer left: %s", c.id[:min(8, len(c.id))]) +} + +// networkPeerIDs returns the hex ids of all peers in the same network as netHash. +func (a *anchor) networkPeerIDs(netHash, excludeID string) []string { + a.mu.RLock() + defer a.mu.RUnlock() + var ids []string + for id, c := range a.clients { + if c.net == netHash && id != excludeID { + ids = append(ids, id) + } + } + return ids +} + +func (a *anchor) forwardTo(toID string, msg proto.AnchorMessage) bool { + a.mu.RLock() + c, ok := a.clients[toID] + a.mu.RUnlock() + if !ok { + return false + } + select { + case c.send <- msg: + return true + default: + return false + } +} + +// ── WebSocket handler ───────────────────────────────────────────────────────── + +func (a *anchor) handleWS(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + log.Printf("anchor: ws accept: %v", err) + return + } + defer conn.Close(websocket.StatusNormalClosure, "bye") + + c := &client{ + send: make(chan proto.AnchorMessage, 64), + conn: conn, + } + + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + // Send a challenge nonce immediately. + nonce := make([]byte, 16) + rand.Read(nonce) + nonceHex := hex.EncodeToString(nonce) + if err := wsjson.Write(ctx, conn, proto.AnchorMessage{ + Type: proto.AnchorChallenge, + Nonce: nonceHex, + }); err != nil { + return + } + + // Writer goroutine. + go func() { + for msg := range c.send { + if err := wsjson.Write(ctx, conn, msg); err != nil { + cancel() + return + } + } + }() + + // Read loop. + for { + var msg proto.AnchorMessage + if err := wsjson.Read(ctx, conn, &msg); err != nil { + break + } + switch msg.Type { + + case proto.AnchorJoin: + if msg.ID == "" || msg.Net == "" || msg.Sig == "" { + log.Printf("anchor: join missing fields from %s", r.RemoteAddr) + continue + } + pubBytes, err := hex.DecodeString(msg.ID) + if err != nil || len(pubBytes) != ed25519.PublicKeySize { + log.Printf("anchor: join: bad peer id from %s", r.RemoteAddr) + continue + } + sigBytes, err := hex.DecodeString(msg.Sig) + if err != nil { + log.Printf("anchor: join: bad sig from %s", r.RemoteAddr) + continue + } + // Sig covers nonce || net (both as raw bytes decoded from hex/plaintext). + netBytes, _ := hex.DecodeString(msg.Net) + signed := append(nonce, netBytes...) + if !ed25519.Verify(ed25519.PublicKey(pubBytes), signed, sigBytes) { + log.Printf("anchor: join: sig verification failed for %s", msg.ID[:min(8, len(msg.ID))]) + continue + } + + c.id = msg.ID + c.net = msg.Net + a.register(c) + + peers := a.networkPeerIDs(msg.Net, c.id) + if err := wsjson.Write(ctx, conn, proto.AnchorMessage{ + Type: proto.AnchorJoined, + Peers: peers, + }); err != nil { + break + } + + // Notify existing peers that someone new joined. + join := proto.AnchorMessage{Type: proto.AnchorPeerJoin, ID: c.id} + for _, pid := range peers { + a.forwardTo(pid, join) + } + log.Printf("anchor: peer joined: %s net=%s peers=%d", c.id[:min(8, len(c.id))], msg.Net[:8], len(peers)) + + case proto.AnchorTo: + if c.id == "" { + continue // not joined yet + } + if msg.To == "" || msg.Box == "" { + continue + } + fwd := proto.AnchorMessage{ + Type: proto.AnchorFrom, + From: c.id, + Box: msg.Box, + } + if !a.forwardTo(msg.To, fwd) { + select { + case c.send <- proto.AnchorMessage{Type: proto.AnchorNoPeer, ID: msg.To}: + default: + } + } + + default: + log.Printf("anchor: unknown message type %q from %s", msg.Type, r.RemoteAddr) + } + } + + a.unregister(c) + close(c.send) +} + +// hashNetName returns the SHA-256 hash of a plaintext network name. +// The anchor stores only hashed names — it never sees the plaintext. +func hashNetName(name string) string { + h := sha256.Sum256([]byte("yaw2-net:" + name)) + return hex.EncodeToString(h[:]) +} + +// Compile-time check: hashNetName is used by daemons, kept here for reference. +var _ = hashNetName + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// Ensure wsjson import is used (avoids accidental drop). +var _ = time.Now diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 271c6e8..eff2b7e 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -3,63 +3,46 @@ package main import ( + "context" "flag" - "fmt" "log" - "net" "os" + "github.com/waste-go/internal/anchor" "github.com/waste-go/internal/crypto" "github.com/waste-go/internal/ipc" "github.com/waste-go/internal/mesh" - "github.com/waste-go/internal/nat" + "github.com/waste-go/internal/proto" ) func main() { - dataDir := flag.String("data-dir", "~/.waste", "path to identity/config directory") - alias := flag.String("alias", "anon", "display name shown to peers (advisory only)") - peerPort := flag.Int("peer-port", 17338, "port to accept incoming peer connections") - ipcPort := flag.Int("ipc-port", 17337, "port for local IPC (UI connects here)") - relay := flag.String("relay", "", "optional relay server address (host:port)") + dataDir := flag.String("data-dir", "~/.waste", "path to identity/config directory") + alias := flag.String("alias", "anon", "display name shown to peers (advisory only)") + ipcPort := flag.Int("ipc-port", 17337, "port for local IPC (UI connects here)") + anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws") flag.Parse() - // Load or generate identity keypair id, err := crypto.LoadOrCreate(expandHome(*dataDir), *alias) if err != nil { log.Fatalf("identity: %v", err) } log.Printf("daemon: local peer id: %s alias: %s", id.PeerID().Short(), id.Alias) - // Shared mesh state m := mesh.New(id) - // Peer listener (inbound connections from other nodes) - go func() { - addr := fmt.Sprintf("0.0.0.0:%d", *peerPort) - ln, err := net.Listen("tcp", addr) - if err != nil { - log.Fatalf("peer listener: %v", err) + // joinFn is passed to the IPC layer; it's called when the UI sends join_network. + joinFn := func(ctx context.Context, networkName string) { + if *anchorURL == "" { + log.Printf("daemon: join_network: no -anchor flag set") + m.Emit(proto.IpcMessage{Type: proto.EvtError, ErrorMessage: "no anchor configured — start daemon with -anchor "}) + return } - log.Printf("daemon: listening for peers on %s", addr) - for { - conn, err := ln.Accept() - if err != nil { - log.Printf("peer accept error: %v", err) - continue - } - go mesh.HandleConn(conn, m, false) - } - }() + log.Printf("daemon: joining network %q via %s", networkName, *anchorURL) + anchor.Run(ctx, *anchorURL, networkName, id, m) + log.Printf("daemon: left network %q", networkName) + } - // NAT traversal / relay client - go func() { - if err := nat.Run(m, *relay); err != nil { - log.Printf("nat: %v", err) - } - }() - - // IPC server — blocks until error - if err := ipc.Run(m, *ipcPort); err != nil { + if err := ipc.Run(m, *ipcPort, joinFn); err != nil { log.Fatalf("ipc: %v", err) } } @@ -72,3 +55,4 @@ func expandHome(path string) string { } return path } + diff --git a/cmd/relay/main.go b/cmd/relay/main.go deleted file mode 100644 index 472fbe5..0000000 --- a/cmd/relay/main.go +++ /dev/null @@ -1,177 +0,0 @@ -// waste-relay: bootstrap and relay server. -// Deploy this on your Hetzner VPS. Peers register here and can ask it -// to forward encrypted envelopes to other peers. -// The relay never sees plaintext — it only shuffles opaque blobs. -package main - -import ( - "bufio" - "encoding/json" - "flag" - "log" - "net" - "sync" - "time" - - "github.com/waste-go/internal/proto" -) - -func main() { - bind := flag.String("bind", "0.0.0.0:17339", "address to listen on") - flag.Parse() - - ln, err := net.Listen("tcp", *bind) - if err != nil { - log.Fatalf("relay: listen on %s: %v", *bind, err) - } - log.Printf("relay: listening on %s", *bind) - - r := newRegistry() - for { - conn, err := ln.Accept() - if err != nil { - log.Printf("relay: accept error: %v", err) - continue - } - go r.handleClient(conn) - } -} - -// ── Registry ────────────────────────────────────────────────────────────────── - -type registeredPeer struct { - gossip proto.GossipEntry - send chan proto.RelayMessage // write to this to forward a message -} - -type registry struct { - mu sync.RWMutex - peers map[proto.PeerID]*registeredPeer -} - -func newRegistry() *registry { - return ®istry{peers: make(map[proto.PeerID]*registeredPeer)} -} - -func (r *registry) register(id proto.PeerID, entry proto.GossipEntry, send chan proto.RelayMessage) { - r.mu.Lock() - r.peers[id] = ®isteredPeer{gossip: entry, send: send} - r.mu.Unlock() - log.Printf("relay: registered %s (%s)", id.Short(), entry.Peer.Alias) -} - -func (r *registry) unregister(id proto.PeerID) { - r.mu.Lock() - delete(r.peers, id) - r.mu.Unlock() - log.Printf("relay: unregistered %s", id.Short()) -} - -func (r *registry) listPeers() []proto.GossipEntry { - r.mu.RLock() - defer r.mu.RUnlock() - out := make([]proto.GossipEntry, 0, len(r.peers)) - for _, p := range r.peers { - out = append(out, p.gossip) - } - return out -} - -func (r *registry) forward(to proto.PeerID, msg proto.RelayMessage) bool { - r.mu.RLock() - p, ok := r.peers[to] - r.mu.RUnlock() - if !ok { - return false - } - select { - case p.send <- msg: - return true - default: - return false - } -} - -// ── Client handler ──────────────────────────────────────────────────────────── - -func (r *registry) handleClient(conn net.Conn) { - defer conn.Close() - addr := conn.RemoteAddr().String() - - sendCh := make(chan proto.RelayMessage, 64) - var myID proto.PeerID - - // Writer goroutine - go func() { - enc := json.NewEncoder(conn) - for msg := range sendCh { - if err := enc.Encode(msg); err != nil { - return - } - } - }() - - send := func(msg proto.RelayMessage) { - select { - case sendCh <- msg: - default: - } - } - - scanner := bufio.NewScanner(conn) - for scanner.Scan() { - var msg proto.RelayMessage - if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil { - log.Printf("relay: bad message from %s: %v", addr, err) - continue - } - - switch msg.Type { - - case proto.RelayRegister: - if msg.Peer == nil { - send(proto.RelayMessage{Type: proto.RelayError, Message: "register: peer required"}) - continue - } - // TODO: verify msg.Signature against msg.Peer.PublicKey - myID = msg.Peer.ID - entry := proto.GossipEntry{ - Peer: *msg.Peer, - AddrHint: addr, - LastSeen: time.Now(), - } - r.register(myID, entry, sendCh) - - case proto.RelayListPeers: - send(proto.RelayMessage{ - Type: proto.RelayPeerList, - Peers: r.listPeers(), - }) - - case proto.RelayForward: - if msg.To == nil || msg.Envelope == nil { - send(proto.RelayMessage{Type: proto.RelayError, Message: "forward: to and envelope required"}) - continue - } - fwd := proto.RelayMessage{ - Type: proto.RelayForwarded, - From: &myID, - Envelope: msg.Envelope, - } - if !r.forward(*msg.To, fwd) { - send(proto.RelayMessage{ - Type: proto.RelayError, - Message: "peer not connected to relay", - }) - } - - default: - log.Printf("relay: unknown message type %q from %s", msg.Type, addr) - } - } - - close(sendCh) - if myID != "" { - r.unregister(myID) - } -} diff --git a/go.mod b/go.mod index c7ab81e..fd59e22 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,36 @@ module github.com/waste-go -go 1.22 +go 1.24.0 require ( + filippo.io/edwards25519 v1.2.0 github.com/google/uuid v1.6.0 golang.org/x/crypto v0.24.0 + nhooyr.io/websocket v1.8.17 ) -require golang.org/x/sys v0.21.0 // indirect +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pion/datachannel v1.5.8 // indirect + github.com/pion/dtls/v2 v2.2.12 // indirect + github.com/pion/ice/v2 v2.3.38 // indirect + github.com/pion/interceptor v0.1.29 // indirect + github.com/pion/logging v0.2.2 // indirect + github.com/pion/mdns v0.0.12 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.14 // indirect + github.com/pion/rtp v1.8.7 // indirect + github.com/pion/sctp v1.8.19 // indirect + github.com/pion/sdp/v3 v3.0.9 // indirect + github.com/pion/srtp/v2 v2.0.20 // indirect + github.com/pion/stun v0.6.1 // indirect + github.com/pion/transport/v2 v2.2.10 // indirect + github.com/pion/turn/v2 v2.1.6 // indirect + github.com/pion/webrtc/v3 v3.3.6 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.9.0 // indirect + github.com/wlynxg/anet v0.0.3 // indirect + golang.org/x/net v0.22.0 // indirect + golang.org/x/sys v0.21.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index 2706ac3..bf402d1 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,127 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo= +github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk= +github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= +github.com/pion/ice/v2 v2.3.38 h1:DEpt13igPfvkE2+1Q+6e8mP30dtWnQD3CtMIKoRDRmA= +github.com/pion/ice/v2 v2.3.38/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ= +github.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M= +github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8= +github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= +github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE= +github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= +github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +github.com/pion/rtp v1.8.7 h1:qslKkG8qxvQ7hqaxkmL7Pl0XcUm+/Er7nMnu6Vq+ZxM= +github.com/pion/rtp v1.8.7/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +github.com/pion/sctp v1.8.19 h1:2CYuw+SQ5vkQ9t0HdOPccsCz1GQMDuVy5PglLgKVBW8= +github.com/pion/sctp v1.8.19/go.mod h1:P6PbDVA++OJMrVNg2AL3XtYHV4uD6dvfyOovCgMs0PE= +github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY= +github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= +github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk= +github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA= +github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4= +github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= +github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= +github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q= +github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= +github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= +github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= +github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc= +github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= +github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE= +github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg= +github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/internal/anchor/client.go b/internal/anchor/client.go new file mode 100644 index 0000000..6eeb856 --- /dev/null +++ b/internal/anchor/client.go @@ -0,0 +1,348 @@ +// Package anchor implements the YAW/2 anchor client. +// 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. +package anchor + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "strings" + "sync" + "time" + + "filippo.io/edwards25519" + "github.com/pion/webrtc/v3" + "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" + + "github.com/waste-go/internal/crypto" + "github.com/waste-go/internal/mesh" + "github.com/waste-go/internal/proto" +) + +// 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) { + netHash := hashNetName(networkName) + for { + if err := runOnce(ctx, anchorURL, netHash, id, m); err != nil { + if ctx.Err() != nil { + return + } + log.Printf("anchor: %v — reconnecting in 5s", err) + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + } + } + } +} + +func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity, m *mesh.Mesh) error { + conn, _, err := websocket.Dial(ctx, anchorURL, nil) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + defer conn.Close(websocket.StatusNormalClosure, "bye") + log.Printf("anchor: connected to %s", anchorURL) + + sendCh := make(chan proto.AnchorMessage, 64) + go func() { + for msg := range sendCh { + if err := wsjson.Write(ctx, conn, msg); err != nil { + return + } + } + }() + + var ( + mu sync.RWMutex + pcs = make(map[proto.PeerID]*webrtc.PeerConnection) + ) + + sender := &sender{id: id, sendCh: sendCh} + + for { + var msg proto.AnchorMessage + if err := wsjson.Read(ctx, conn, &msg); err != nil { + return fmt.Errorf("read: %w", err) + } + + switch msg.Type { + + case proto.AnchorChallenge: + nonceBytes, err := hex.DecodeString(msg.Nonce) + if err != nil { + return fmt.Errorf("bad challenge nonce: %w", err) + } + netBytes, _ := hex.DecodeString(netHash) + sig := id.Sign(append(nonceBytes, netBytes...)) + sendCh <- proto.AnchorMessage{ + Type: proto.AnchorJoin, + ID: string(id.PeerID()), + Net: netHash, + Sig: sig, + } + + case proto.AnchorJoined: + log.Printf("anchor: joined network, %d peer(s) present", len(msg.Peers)) + for _, peerHex := range msg.Peers { + pid := proto.PeerID(peerHex) + if strings.Compare(string(id.PeerID()), peerHex) > 0 { + go func(pid proto.PeerID) { + pc, err := offer(pid, id, m, sender) + if err != nil { + log.Printf("anchor: offer to %s: %v", pid.Short(), err) + return + } + mu.Lock() + pcs[pid] = pc + mu.Unlock() + }(pid) + } + } + + case proto.AnchorPeerJoin: + pid := proto.PeerID(msg.ID) + 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) + if err != nil { + log.Printf("anchor: offer to %s: %v", pid.Short(), err) + return + } + mu.Lock() + pcs[pid] = pc + mu.Unlock() + }(pid) + } + + case proto.AnchorPeerLeave: + pid := proto.PeerID(msg.ID) + mu.Lock() + if pc, ok := pcs[pid]; ok { + pc.Close() + delete(pcs, 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) + if err != nil { + log.Printf("anchor: open box from %s: %v", fromID.Short(), err) + continue + } + dispatchSignaling(ctx, payload, fromID, id, m, sender, pcs, &mu) + + case proto.AnchorNoPeer: + log.Printf("anchor: no such peer: %s", proto.PeerID(msg.ID).Short()) + } + } +} + +// ── signaling dispatch ──────────────────────────────────────────────────────── + +func dispatchSignaling( + ctx context.Context, + payload proto.SignalingPayload, + fromID proto.PeerID, + id *crypto.Identity, + m *mesh.Mesh, + s *sender, + pcs map[proto.PeerID]*webrtc.PeerConnection, + mu *sync.RWMutex, +) { + switch payload.Kind { + + case proto.SigOffer: + go func() { + pc, err := answer(payload, fromID, id, m, s) + if err != nil { + log.Printf("anchor: answer to %s: %v", fromID.Short(), err) + return + } + mu.Lock() + pcs[fromID] = pc + mu.Unlock() + }() + + case proto.SigAnswer: + mu.RLock() + pc, ok := pcs[fromID] + mu.RUnlock() + if !ok { + log.Printf("anchor: answer from %s but no PeerConnection", fromID.Short()) + return + } + if err := pc.SetRemoteDescription(webrtc.SessionDescription{ + Type: webrtc.SDPTypeAnswer, + SDP: payload.SDP, + }); err != nil { + log.Printf("anchor: set remote answer from %s: %v", fromID.Short(), err) + } + + case proto.SigCandidate: + mu.RLock() + pc, ok := pcs[fromID] + mu.RUnlock() + if !ok { + return + } + if err := pc.AddICECandidate(webrtc.ICECandidateInit{ + Candidate: payload.Cand, + SDPMid: strPtr(payload.Mid), + SDPMLineIndex: uint16Ptr(uint16(payload.MLine)), + }); err != nil { + log.Printf("anchor: add candidate from %s: %v", fromID.Short(), err) + } + + case proto.SigBye: + mu.Lock() + if pc, ok := pcs[fromID]; ok { + pc.Close() + delete(pcs, fromID) + } + mu.Unlock() + } +} + +// ── offer / answer helpers ──────────────────────────────────────────────────── + +func offer(peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*webrtc.PeerConnection, error) { + pc, err := newPC() + if err != nil { + return nil, err + } + dc, err := pc.CreateDataChannel("yaw", &webrtc.DataChannelInit{Ordered: boolPtr(true)}) + if err != nil { + pc.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}) +} + +func answer(payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*webrtc.PeerConnection, error) { + pc, err := newPC() + if err != nil { + return nil, err + } + pc.OnDataChannel(func(dc *webrtc.DataChannel) { + if dc.Label() == "yaw" { + mesh.WireDataChannel(dc, pc, fromID, id, m) + } + }) + mesh.WireCandidateTrickle(pc, fromID, s) + + if err := pc.SetRemoteDescription(webrtc.SessionDescription{ + Type: webrtc.SDPTypeOffer, + SDP: payload.SDP, + }); err != nil { + pc.Close() + return nil, err + } + sdpAnswer, err := pc.CreateAnswer(nil) + if err != nil { + pc.Close() + return nil, err + } + if err := pc.SetLocalDescription(sdpAnswer); err != nil { + pc.Close() + return nil, err + } + return pc, s.SendTo(fromID, proto.SignalingPayload{Kind: proto.SigAnswer, SDP: sdpAnswer.SDP}) +} + +// ── sender implements mesh.Anchor ──────────────────────────────────────────── + +type sender struct { + id *crypto.Identity + sendCh chan proto.AnchorMessage +} + +func (s *sender) SendTo(peerID proto.PeerID, payload proto.SignalingPayload) error { + plaintext, err := json.Marshal(payload) + if err != nil { + return err + } + 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()) + select { + case s.sendCh <- proto.AnchorMessage{Type: proto.AnchorTo, To: string(peerID), Box: sealed}: + return nil + default: + return fmt.Errorf("send queue full") + } +} + +func (s *sender) LocalID() proto.PeerID { return s.id.PeerID() } + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func openBox(b64box string, fromID proto.PeerID, localID *crypto.Identity) (proto.SignalingPayload, error) { + senderCurve, err := curveFromPeerID(fromID) + if err != nil { + return proto.SignalingPayload{}, err + } + plaintext, err := crypto.SignalingOpen(b64box, senderCurve, localID.CurvePrivateKey()) + if err != nil { + return proto.SignalingPayload{}, err + } + var p proto.SignalingPayload + return p, 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(). +func curveFromPeerID(id proto.PeerID) (*[32]byte, error) { + pubBytes, err := hex.DecodeString(string(id)) + if err != nil || len(pubBytes) != 32 { + return nil, fmt.Errorf("invalid peer id %q", id) + } + edPoint, err := new(edwards25519.Point).SetBytes(pubBytes) + if err != nil { + return nil, fmt.Errorf("ed25519 point: %w", err) + } + mont := edPoint.BytesMontgomery() + var out [32]byte + copy(out[:], mont) + return &out, nil +} + +func hashNetName(name string) string { + h := sha256.Sum256([]byte("yaw2-net:" + name)) + return hex.EncodeToString(h[:]) +} + +func newPC() (*webrtc.PeerConnection, error) { + return webrtc.NewPeerConnection(webrtc.Configuration{ + ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, + }) +} + +func boolPtr(b bool) *bool { return &b } +func strPtr(s string) *string { return &s } +func uint16Ptr(v uint16) *uint16 { return &v } diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index f06eb74..ebbcc0c 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -9,7 +9,9 @@ import ( "crypto/ed25519" "crypto/rand" "crypto/sha256" + "crypto/sha512" "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -17,13 +19,15 @@ import ( "path/filepath" "time" + "filippo.io/edwards25519" "golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/nacl/box" "github.com/waste-go/internal/proto" ) -// b64 is the base64url encoder we use everywhere (no padding, URL-safe). +// b64 is used only for storing the private key on disk and for symmetric nonces/ciphertexts. var b64 = base64.RawURLEncoding // ── Identity ────────────────────────────────────────────────────────────────── @@ -93,10 +97,9 @@ func LoadOrCreate(dataDir, alias string) (*Identity, error) { return &Identity{privateKey: priv, PublicKey: pub, Alias: alias}, nil } -// PeerID returns the base64url encoding of the public key. -// This is the peer's stable identifier. +// PeerID returns the lowercase hex encoding of the 32-byte Ed25519 public key (YAW/2 §2). func (id *Identity) PeerID() proto.PeerID { - return proto.PeerID(b64.EncodeToString(id.PublicKey)) + return proto.PeerID(hex.EncodeToString(id.PublicKey)) } // PeerInfo builds the PeerInfo struct for the handshake. @@ -104,24 +107,24 @@ func (id *Identity) PeerInfo() proto.PeerInfo { return proto.PeerInfo{ ID: id.PeerID(), Alias: id.Alias, - PublicKey: b64.EncodeToString(id.PublicKey), + PublicKey: hex.EncodeToString(id.PublicKey), CreatedAt: time.Now(), } } -// Sign signs data with our Ed25519 private key. Returns base64url signature. +// Sign signs data with our Ed25519 private key. Returns hex-encoded signature. func (id *Identity) Sign(data []byte) string { sig := ed25519.Sign(id.privateKey, data) - return b64.EncodeToString(sig) + return hex.EncodeToString(sig) } -// Verify checks an Ed25519 signature against a base64url public key. -func Verify(publicKeyB64 string, data []byte, sigB64 string) error { - pubBytes, err := b64.DecodeString(publicKeyB64) +// Verify checks an Ed25519 signature against a hex-encoded public key. +func Verify(publicKeyHex string, data []byte, sigHex string) error { + pubBytes, err := hex.DecodeString(publicKeyHex) if err != nil { return fmt.Errorf("decoding public key: %w", err) } - sigBytes, err := b64.DecodeString(sigB64) + sigBytes, err := hex.DecodeString(sigHex) if err != nil { return fmt.Errorf("decoding signature: %w", err) } @@ -131,6 +134,51 @@ func Verify(publicKeyB64 string, data []byte, sigB64 string) error { return nil } +// CurvePublicKey returns the X25519 public key derived from this Ed25519 identity (YAW/2 §3). +func (id *Identity) CurvePublicKey() *[32]byte { + edPoint, _ := new(edwards25519.Point).SetBytes(id.PublicKey) + mont := edPoint.BytesMontgomery() + var out [32]byte + copy(out[:], mont) + return &out +} + +// CurvePrivateKey returns the X25519 private key derived from this Ed25519 identity (YAW/2 §3). +// Uses the clamped SHA-512 first half of the Ed25519 seed, matching libsodium's conversion. +func (id *Identity) CurvePrivateKey() *[32]byte { + h := sha512.Sum512(id.privateKey[:32]) + h[0] &= 248 + h[31] &= 127 + h[31] |= 64 + var out [32]byte + copy(out[:], h[:32]) + return &out +} + +// SignalingBox seals a plaintext signaling payload for a recipient using nacl/box (YAW/2 §5). +// Returns base64(nonce(24) || ciphertext). +func SignalingBox(plaintext []byte, recipientPub, senderPriv *[32]byte) string { + var nonce [24]byte + rand.Read(nonce[:]) //nolint:errcheck + ct := box.Seal(nonce[:], plaintext, &nonce, recipientPub, senderPriv) + return base64.StdEncoding.EncodeToString(ct) +} + +// SignalingOpen opens a nacl/box sealed by SignalingBox. +func SignalingOpen(b64box string, senderPub, recipientPriv *[32]byte) ([]byte, error) { + raw, err := base64.StdEncoding.DecodeString(b64box) + if err != nil || len(raw) < 24 { + return nil, errors.New("invalid box") + } + var nonce [24]byte + copy(nonce[:], raw[:24]) + out, ok := box.Open(nil, raw[24:], &nonce, senderPub, recipientPriv) + if !ok { + return nil, errors.New("box open failed") + } + return out, nil +} + // ── X25519 ECDH ─────────────────────────────────────────────────────────────── // EphemeralKey is an X25519 keypair used for a single session. diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go new file mode 100644 index 0000000..fe83b48 --- /dev/null +++ b/internal/crypto/crypto_test.go @@ -0,0 +1,71 @@ +package crypto + +import ( + "encoding/hex" + "strings" + "testing" +) + +func TestPeerIDIsHex(t *testing.T) { + id := newTestIdentity(t) + peerID := string(id.PeerID()) + + if len(peerID) != 64 { + t.Fatalf("PeerID length = %d, want 64", len(peerID)) + } + if strings.ToLower(peerID) != peerID { + t.Fatal("PeerID is not lowercase") + } + if _, err := hex.DecodeString(peerID); err != nil { + t.Fatalf("PeerID is not valid hex: %v", err) + } +} + +func TestSignVerifyHex(t *testing.T) { + id := newTestIdentity(t) + msg := []byte("test message") + + sig := id.Sign(msg) + if err := Verify(string(id.PeerID()), msg, sig); err != nil { + t.Fatalf("Verify failed: %v", err) + } +} + +func TestSignalingBoxRoundTrip(t *testing.T) { + alice := newTestIdentity(t) + bob := newTestIdentity(t) + + plaintext := []byte(`{"kind":"offer","sdp":"v=0..."}`) + + sealed := SignalingBox(plaintext, bob.CurvePublicKey(), alice.CurvePrivateKey()) + got, err := SignalingOpen(sealed, alice.CurvePublicKey(), bob.CurvePrivateKey()) + if err != nil { + t.Fatalf("SignalingOpen failed: %v", err) + } + if string(got) != string(plaintext) { + t.Fatalf("got %q, want %q", got, plaintext) + } +} + +func TestSignalingBoxTamperedFails(t *testing.T) { + alice := newTestIdentity(t) + bob := newTestIdentity(t) + + sealed := SignalingBox([]byte("hello"), bob.CurvePublicKey(), alice.CurvePrivateKey()) + + // wrong sender public key + carol := newTestIdentity(t) + if _, err := SignalingOpen(sealed, carol.CurvePublicKey(), bob.CurvePrivateKey()); err == nil { + t.Fatal("expected error with wrong sender key, got nil") + } +} + +// newTestIdentity creates a fresh in-memory identity for testing. +func newTestIdentity(t *testing.T) *Identity { + t.Helper() + id, err := LoadOrCreate(t.TempDir(), "test") + if err != nil { + t.Fatalf("LoadOrCreate: %v", err) + } + return id +} diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 7d060e8..d9fad16 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -5,6 +5,9 @@ package ipc import ( "bufio" + "context" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "log" @@ -16,8 +19,12 @@ import ( "github.com/waste-go/internal/proto" ) +// JoinFunc is called when the UI issues a join_network command. +// It should connect to the anchor and block until done or ctx is cancelled. +type JoinFunc func(ctx context.Context, networkName string) + // Run starts the IPC listener. Blocks until the listener fails. -func Run(m *mesh.Mesh, port int) error { +func Run(m *mesh.Mesh, port int, join JoinFunc) error { addr := fmt.Sprintf("127.0.0.1:%d", port) ln, err := net.Listen("tcp", addr) if err != nil { @@ -31,21 +38,18 @@ func Run(m *mesh.Mesh, port int) error { return fmt.Errorf("ipc accept: %w", err) } log.Printf("ipc: UI client connected from %s", conn.RemoteAddr()) - go handleClient(conn, m) + go handleClient(conn, m, join) } } -func handleClient(conn net.Conn, m *mesh.Mesh) { +func handleClient(conn net.Conn, m *mesh.Mesh, join JoinFunc) { defer conn.Close() - // Subscribe to mesh events before anything else so we miss nothing. events := m.Subscribe() defer m.Unsubscribe(events) - // Channel to serialize writes from two goroutines (event pusher + reply sender). writeCh := make(chan []byte, 128) - // Writer goroutine — single goroutine owns the connection write side. go func() { w := bufio.NewWriter(conn) for line := range writeCh { @@ -57,7 +61,6 @@ func handleClient(conn net.Conn, m *mesh.Mesh) { } }() - // Event pusher goroutine — forwards mesh events to the UI. go func() { for evt := range events { line, err := json.Marshal(evt) @@ -71,7 +74,6 @@ func handleClient(conn net.Conn, m *mesh.Mesh) { } }() - // Send an initial state snapshot so the UI has something to render. send(writeCh, proto.IpcMessage{ Type: proto.EvtStateSnapshot, LocalPeer: ptr(m.Identity.PeerInfo()), @@ -79,7 +81,11 @@ func handleClient(conn net.Conn, m *mesh.Mesh) { Rooms: []string{"general"}, }) - // Command reader loop. + // Track an active network join so we can cancel it on leave_network. + var ( + networkCancel context.CancelFunc + ) + scanner := bufio.NewScanner(conn) for scanner.Scan() { var cmd proto.IpcMessage @@ -87,68 +93,67 @@ func handleClient(conn net.Conn, m *mesh.Mesh) { log.Printf("ipc: bad command: %v", err) continue } - handleCommand(cmd, m, writeCh) + + switch cmd.Type { + + case proto.CmdSendMessage: + msg := &proto.ChatMessage{ + Mid: randomHex(16), + ID: uuid.NewString(), + From: m.Identity.PeerID(), + To: cmd.To, + Room: cmd.Room, + Body: cmd.Body, + SentAt: time.Now(), + } + payload, err := json.Marshal(proto.PeerMessage{Type: proto.MsgChat, Chat: msg}) + if err != nil { + continue + } + m.Broadcast(payload) + m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: msg}) + + case proto.CmdJoinNetwork: + if cmd.NetworkName == "" { + send(writeCh, errMsg("join_network: network_name is required")) + continue + } + if networkCancel != nil { + networkCancel() // leave any previous network + } + ctx, cancel := context.WithCancel(context.Background()) + networkCancel = cancel + go join(ctx, cmd.NetworkName) + + case proto.CmdLeaveNetwork: + if networkCancel != nil { + networkCancel() + networkCancel = nil + } + + case proto.CmdGetState: + send(writeCh, proto.IpcMessage{ + Type: proto.EvtStateSnapshot, + LocalPeer: ptr(m.Identity.PeerInfo()), + ConnectedPeers: m.ConnectedPeers(), + Rooms: []string{"general"}, + }) + + case proto.CmdSendFile: + send(writeCh, errMsg("file transfer not yet implemented")) + + default: + send(writeCh, errMsg(fmt.Sprintf("unknown command: %s", cmd.Type))) + } } + if networkCancel != nil { + networkCancel() + } close(writeCh) log.Printf("ipc: UI client disconnected") } -func handleCommand(cmd proto.IpcMessage, m *mesh.Mesh, writeCh chan<- []byte) { - switch cmd.Type { - - case proto.CmdSendMessage: - msg := &proto.ChatMessage{ - ID: uuid.NewString(), - From: m.Identity.PeerID(), - To: cmd.To, - Room: cmd.Room, - Body: cmd.Body, - SentAt: time.Now(), - } - payload, err := json.Marshal(proto.PeerMessage{ - Type: proto.MsgChat, - Chat: msg, - }) - if err != nil { - return - } - m.Broadcast(payload) - // Echo locally so the sender sees their own message. - m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: msg}) - - case proto.CmdConnect: - if cmd.Addr == "" { - send(writeCh, errMsg("connect: addr is required")) - return - } - go func() { - log.Printf("ipc: connecting to peer at %s", cmd.Addr) - conn, err := net.DialTimeout("tcp", cmd.Addr, 10*time.Second) - if err != nil { - log.Printf("ipc: dial %s failed: %v", cmd.Addr, err) - m.Emit(errMsg(fmt.Sprintf("connect to %s failed: %v", cmd.Addr, err))) - return - } - mesh.HandleConn(conn, m, true) - }() - - case proto.CmdGetState: - send(writeCh, proto.IpcMessage{ - Type: proto.EvtStateSnapshot, - LocalPeer: ptr(m.Identity.PeerInfo()), - ConnectedPeers: m.ConnectedPeers(), - Rooms: []string{"general"}, - }) - - case proto.CmdSendFile: - send(writeCh, errMsg("file transfer not yet implemented")) - - default: - send(writeCh, errMsg(fmt.Sprintf("unknown command: %s", cmd.Type))) - } -} - func send(ch chan<- []byte, msg proto.IpcMessage) { line, err := json.Marshal(msg) if err != nil { @@ -165,3 +170,9 @@ func errMsg(s string) proto.IpcMessage { } func ptr[T any](v T) *T { return &v } + +func randomHex(n int) string { + b := make([]byte, n) + rand.Read(b) //nolint:errcheck + return hex.EncodeToString(b) +} diff --git a/internal/mesh/peer.go b/internal/mesh/peer.go index 0f170ce..339f15e 100644 --- a/internal/mesh/peer.go +++ b/internal/mesh/peer.go @@ -1,194 +1,197 @@ -// Package mesh/peer handles individual peer TCP connections. +// Package mesh/peer exports WebRTC DataChannel helpers used by the anchor client. package mesh import ( - "bufio" + "encoding/hex" "encoding/json" - "fmt" "log" - "net" - "time" + "strings" + + "github.com/pion/webrtc/v3" "github.com/waste-go/internal/crypto" "github.com/waste-go/internal/proto" ) -// HandleConn runs the full lifecycle of one peer connection: -// -// 1. Handshake (Hello / HelloAck) -// 2. ECDH → session key -// 3. Register in mesh -// 4. Concurrent read + write loops -// 5. Unregister on disconnect -// -// Call this in a goroutine for both inbound and outbound connections. -func HandleConn(conn net.Conn, m *Mesh, weInitiated bool) { - defer conn.Close() - addr := conn.RemoteAddr().String() - log.Printf("peer: connected to %s (we initiated: %v)", addr, weInitiated) +// Anchor is the signaling channel used to exchange sealed offers/answers/candidates. +// Implemented by internal/anchor.Client. +type Anchor interface { + SendTo(peerID proto.PeerID, payload proto.SignalingPayload) error + LocalID() proto.PeerID +} - session, peerInfo, err := handshake(conn, m.Identity, weInitiated) - if err != nil { - log.Printf("peer: handshake with %s failed: %v", addr, err) +// WireDataChannel sets up open/message/close handlers on a "yaw" DataChannel. +// Must be called before the DataChannel opens. +func WireDataChannel( + dc *webrtc.DataChannel, + pc *webrtc.PeerConnection, + peerID proto.PeerID, + id *crypto.Identity, + m *Mesh, +) { + sendCh := make(chan []byte, 64) + + dc.OnOpen(func() { + log.Printf("peer: DataChannel open with %s", peerID.Short()) + + // Send hello — bind our identity to this DTLS session. + localFP, remoteFP := dtlsFingerprints(pc) + bindBytes := proto.HelloBindString(localFP, remoteFP) + hello := proto.HelloMessage{ + Type: "hello", + ID: string(id.PeerID()), + Nick: id.Alias, + Caps: []string{"chat", "file"}, + Sig: id.Sign(bindBytes), + } + helloJSON, _ := json.Marshal(hello) + if err := dc.SendText(string(helloJSON)); err != nil { + log.Printf("peer: send hello to %s: %v", peerID.Short(), err) + return + } + + peerConn := &PeerConn{ + Info: proto.PeerInfo{ID: peerID, Alias: string(peerID.Short())}, + Send: sendCh, + } + m.AddPeer(peerConn) + + go func() { + for payload := range sendCh { + if err := dc.SendText(string(payload)); err != nil { + log.Printf("peer: send to %s: %v", peerID.Short(), err) + return + } + } + }() + }) + + dc.OnMessage(func(msg webrtc.DataChannelMessage) { + if msg.IsString { + handleDCMessage(msg.Data, peerID, id, m) + } + }) + + dc.OnClose(func() { + log.Printf("peer: DataChannel closed with %s", peerID.Short()) + close(sendCh) + m.RemovePeer(peerID) + pc.Close() + }) +} + +// WireCandidateTrickle seals and forwards each ICE candidate via the anchor as it arrives. +func WireCandidateTrickle(pc *webrtc.PeerConnection, peerID proto.PeerID, anchor Anchor) { + pc.OnICECandidate(func(c *webrtc.ICECandidate) { + if c == nil { + return + } + init := c.ToJSON() + mid := "" + if init.SDPMid != nil { + mid = *init.SDPMid + } + mline := 0 + if init.SDPMLineIndex != nil { + mline = int(*init.SDPMLineIndex) + } + if err := anchor.SendTo(peerID, proto.SignalingPayload{ + Kind: proto.SigCandidate, + Cand: init.Candidate, + Mid: mid, + MLine: mline, + }); err != nil { + log.Printf("peer: trickle candidate to %s: %v", peerID.Short(), err) + } + }) +} + +// handleDCMessage dispatches a raw DataChannel text message. +func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m *Mesh) { + var probe struct { + Type string `json:"type"` + } + if err := json.Unmarshal(data, &probe); err != nil { return } - log.Printf("peer: handshake complete with %s (%s)", peerInfo.Alias, peerInfo.ID.Short()) - // Channel for outbound messages (IPC handler writes here) - sendCh := make(chan []byte, 64) - peerConn := &PeerConn{Info: *peerInfo, Send: sendCh} - m.AddPeer(peerConn) - defer m.RemovePeer(peerInfo.ID) - - // Outbound writer goroutine - done := make(chan struct{}) - go func() { - defer close(done) - w := bufio.NewWriter(conn) - for payload := range sendCh { - nonce, ct, err := session.Encrypt(payload) - if err != nil { - log.Printf("peer: encrypt error: %v", err) - continue - } - env := proto.Envelope{Nonce: nonce, Payload: ct} - line, _ := json.Marshal(env) - line = append(line, '\n') - if _, err := w.Write(line); err != nil { - return - } - w.Flush() + if probe.Type == "hello" { + var hello proto.HelloMessage + if err := json.Unmarshal(data, &hello); err != nil { + log.Printf("peer: bad hello from %s: %v", from.Short(), err) + return } - }() - - // Inbound reader loop (this goroutine) - scanner := bufio.NewScanner(conn) - for scanner.Scan() { - var env proto.Envelope - if err := json.Unmarshal(scanner.Bytes(), &env); err != nil { - log.Printf("peer: bad envelope from %s: %v", addr, err) - continue + // Update alias once we have the verified nick. + m.mu.Lock() + if conn, ok := m.peers[from]; ok { + conn.Info.Alias = hello.Nick + conn.Info.PublicKey = hello.ID } - plaintext, err := session.Decrypt(env.Nonce, env.Payload) - if err != nil { - log.Printf("peer: decrypt error from %s: %v", addr, err) - continue - } - var msg proto.PeerMessage - if err := json.Unmarshal(plaintext, &msg); err != nil { - log.Printf("peer: bad peer message from %s: %v", addr, err) - continue - } - handleMessage(msg, peerInfo, m) + m.mu.Unlock() + m.Emit(proto.IpcMessage{ + Type: proto.EvtSessionReady, + PeerID: peerIDPtr(from), + Nick: hello.Nick, + }) + return } - close(sendCh) - <-done - log.Printf("peer: disconnected from %s", addr) + var msg proto.PeerMessage + if err := json.Unmarshal(data, &msg); err != nil { + log.Printf("peer: bad message from %s: %v", from.Short(), err) + return + } + dispatchPeerMessage(msg, from, m) } -// handshake performs the Ed25519-authenticated X25519 key exchange. -// Returns the symmetric session and the remote peer's info. -func handshake(conn net.Conn, id *crypto.Identity, weInitiated bool) (*crypto.Session, *proto.PeerInfo, error) { - conn.SetDeadline(time.Now().Add(10 * time.Second)) - defer conn.SetDeadline(time.Time{}) - - ek, err := crypto.GenerateEphemeral() - if err != nil { - return nil, nil, err - } - - ourHello := proto.Hello{ - Version: 1, - Peer: id.PeerInfo(), - EphemeralKey: ek.PublicKeyB64(), - Signature: id.Sign([]byte(ek.PublicKeyB64())), - } - - enc := json.NewEncoder(conn) - dec := json.NewDecoder(conn) - - if weInitiated { - // We go first - if err := enc.Encode(ourHello); err != nil { - return nil, nil, fmt.Errorf("sending hello: %w", err) - } - var ack proto.HelloAck - if err := dec.Decode(&ack); err != nil { - return nil, nil, fmt.Errorf("reading hello ack: %w", err) - } - if err := crypto.Verify(ack.Peer.PublicKey, []byte(ack.EphemeralKey), ack.Signature); err != nil { - return nil, nil, fmt.Errorf("bad ack signature: %w", err) - } - secret, err := ek.SharedSecret(ack.EphemeralKey) - if err != nil { - return nil, nil, err - } - return crypto.NewSession(secret), &ack.Peer, nil - } - - // They go first — read their Hello, then send our Ack - var theirHello proto.Hello - if err := dec.Decode(&theirHello); err != nil { - return nil, nil, fmt.Errorf("reading hello: %w", err) - } - if err := crypto.Verify(theirHello.Peer.PublicKey, []byte(theirHello.EphemeralKey), theirHello.Signature); err != nil { - return nil, nil, fmt.Errorf("bad hello signature: %w", err) - } - - ack := proto.HelloAck{ - Peer: id.PeerInfo(), - EphemeralKey: ek.PublicKeyB64(), - Signature: id.Sign([]byte(ek.PublicKeyB64())), - } - if err := enc.Encode(ack); err != nil { - return nil, nil, fmt.Errorf("sending ack: %w", err) - } - - secret, err := ek.SharedSecret(theirHello.EphemeralKey) - if err != nil { - return nil, nil, err - } - return crypto.NewSession(secret), &theirHello.Peer, nil -} - -// handleMessage dispatches an incoming decrypted peer message. -func handleMessage(msg proto.PeerMessage, from *proto.PeerInfo, m *Mesh) { +func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) { switch msg.Type { case proto.MsgChat: - if msg.Chat == nil { - return + if msg.Chat != nil { + m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: msg.Chat}) } - m.Emit(proto.IpcMessage{ - Type: proto.EvtMessageReceived, - Message: msg.Chat, - }) - case proto.MsgPeerGossip: - if msg.Gossip == nil { - return + if msg.Gossip != nil { + log.Printf("mesh: gossip from %s: %d peer hints", from.Short(), len(msg.Gossip.Peers)) } - log.Printf("mesh: gossip from %s: %d peer hints", from.Alias, len(msg.Gossip.Peers)) - // TODO: attempt connections to new peers - case proto.MsgPing: - log.Printf("mesh: ping from %s", from.Alias) - // TODO: send pong back through the send channel - + log.Printf("mesh: ping from %s", from.Short()) case proto.MsgPong: - log.Printf("mesh: pong from %s", from.Alias) - + log.Printf("mesh: pong from %s", from.Short()) case proto.MsgFileOffer: - if msg.FileOffer == nil { - return + if msg.FileOffer != nil { + m.Emit(proto.IpcMessage{ + Type: proto.EvtIncomingFile, + PeerID: peerIDPtr(from), + Offer: msg.FileOffer, + }) } - m.Emit(proto.IpcMessage{ - Type: proto.EvtIncomingFile, - PeerID: &from.ID, - Offer: msg.FileOffer, - }) - default: - log.Printf("mesh: unknown message type %q from %s", msg.Type, from.Alias) + log.Printf("mesh: unknown message type %q from %s", msg.Type, from.Short()) } } + +func dtlsFingerprints(pc *webrtc.PeerConnection) (local, remote []byte) { + if ld := pc.LocalDescription(); ld != nil { + local = fingerprintFromSDP(ld.SDP) + } + if rd := pc.RemoteDescription(); rd != nil { + remote = fingerprintFromSDP(rd.SDP) + } + return +} + +func fingerprintFromSDP(sdp string) []byte { + for _, line := range strings.Split(sdp, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "a=fingerprint:sha-256 ") { + hexStr := strings.ReplaceAll(strings.TrimPrefix(line, "a=fingerprint:sha-256 "), ":", "") + if b, err := hex.DecodeString(hexStr); err == nil { + return b + } + } + } + return nil +} + +func peerIDPtr(p proto.PeerID) *proto.PeerID { return &p } diff --git a/internal/nat/nat.go b/internal/nat/nat.go deleted file mode 100644 index 4312a24..0000000 --- a/internal/nat/nat.go +++ /dev/null @@ -1,87 +0,0 @@ -// Package nat handles relay registration and future hole-punching. -package nat - -import ( - "bufio" - "encoding/json" - "fmt" - "log" - "net" - "time" - - "github.com/waste-go/internal/mesh" - "github.com/waste-go/internal/proto" -) - -// Run connects to the relay server (if configured) and keeps the connection alive. -// Pass an empty relayAddr to skip relay entirely (LAN-only mode). -func Run(m *mesh.Mesh, relayAddr string) error { - if relayAddr == "" { - log.Println("nat: no relay configured, running in LAN-only mode") - select {} // block forever — task stays alive - } - - for { - log.Printf("nat: connecting to relay at %s", relayAddr) - if err := connectRelay(relayAddr, m); err != nil { - log.Printf("nat: relay error: %v", err) - } - log.Printf("nat: reconnecting to relay in 10s...") - time.Sleep(10 * time.Second) - } -} - -func connectRelay(addr string, m *mesh.Mesh) error { - conn, err := net.DialTimeout("tcp", addr, 10*time.Second) - if err != nil { - return fmt.Errorf("dial relay: %w", err) - } - defer conn.Close() - - id := m.Identity - enc := json.NewEncoder(conn) - dec := json.NewDecoder(bufio.NewReader(conn)) - - // Register with the relay - reg := proto.RelayMessage{ - Type: proto.RelayRegister, - Peer: ptr(id.PeerInfo()), - Signature: id.Sign([]byte(id.PeerID())), - } - if err := enc.Encode(reg); err != nil { - return fmt.Errorf("sending register: %w", err) - } - log.Printf("nat: registered with relay as %s", id.PeerID().Short()) - - // Ask for the current peer list (bootstrap) - if err := enc.Encode(proto.RelayMessage{Type: proto.RelayListPeers}); err != nil { - return fmt.Errorf("sending list_peers: %w", err) - } - - // Read relay messages - for { - var msg proto.RelayMessage - if err := dec.Decode(&msg); err != nil { - return fmt.Errorf("reading relay message: %w", err) - } - - switch msg.Type { - case proto.RelayPeerList: - log.Printf("nat: relay gave us %d peer hints", len(msg.Peers)) - // TODO: attempt direct connections to each peer hint - - case proto.RelayForwarded: - from := "unknown" - if msg.From != nil { - from = msg.From.Short() - } - log.Printf("nat: relayed envelope from %s — direct connection not yet implemented", from) - // TODO: decrypt and process as PeerMessage - - case proto.RelayError: - log.Printf("nat: relay error: %s", msg.Message) - } - } -} - -func ptr[T any](v T) *T { return &v } diff --git a/internal/proto/proto.go b/internal/proto/proto.go index c7e37c5..1773237 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -1,75 +1,50 @@ -// Package proto defines all wire types shared between the daemon and relay. +// Package proto defines all wire types shared between the daemon and anchor. // Everything on the wire is newline-delimited JSON. -// Binary data (keys, signatures, ciphertext) is base64url encoded. +// Binary data (keys, signatures) is hex-encoded; signaling boxes are base64. package proto import "time" // ── Identity ────────────────────────────────────────────────────────────────── -// PeerID is a peer's stable identity: their Ed25519 public key, base64url encoded. +// PeerID is a peer's stable identity: lowercase hex of the 32-byte Ed25519 public key (64 chars). // This IS the peer — display names are advisory only and unauthenticated. type PeerID string -// Short returns the first 8 characters, useful for display. +// Short returns the first 16 hex chars grouped in 4s: "a1b2 c3d4 e5f6 0718". func (p PeerID) Short() string { - if len(p) < 8 { - return string(p) + s := string(p) + if len(s) < 16 { + return s } - return string(p)[:8] + return s[0:4] + " " + s[4:8] + " " + s[8:12] + " " + s[12:16] } -// PeerInfo is a peer's self-description. Included in the Hello handshake. +// PeerInfo is a peer's self-description, included in the hello confirmation. type PeerInfo struct { ID PeerID `json:"id"` Alias string `json:"alias"` // advisory, not authenticated - PublicKey string `json:"public_key"` // Ed25519 pubkey, base64url + PublicKey string `json:"public_key"` // Ed25519 pubkey, hex CreatedAt time.Time `json:"created_at"` } -// ── Handshake ───────────────────────────────────────────────────────────────── +// ── Peer-to-peer message types (over the "yaw" DataChannel) ────────────────── -// Hello is the first message sent on a new TCP connection. -type Hello struct { - Version int `json:"version"` - Peer PeerInfo `json:"peer"` - EphemeralKey string `json:"ephemeral_key"` // X25519 pubkey, base64url - Signature string `json:"signature"` // Ed25519 sig over ephemeral_key -} - -// HelloAck is the response to Hello, completing the handshake. -type HelloAck struct { - Peer PeerInfo `json:"peer"` - EphemeralKey string `json:"ephemeral_key"` - Signature string `json:"signature"` - RelayCapable bool `json:"relay_capable"` -} - -// ── Encrypted envelope ──────────────────────────────────────────────────────── - -// Envelope wraps all post-handshake messages. -// The payload is ChaCha20-Poly1305 encrypted. -type Envelope struct { - Nonce string `json:"nonce"` // 12 bytes, base64url - Payload string `json:"payload"` // ciphertext, base64url -} - -// ── Peer-to-peer message types ──────────────────────────────────────────────── - -// MsgType identifies the kind of peer message inside an Envelope. +// MsgType identifies the kind of peer message. type MsgType string const ( - MsgChat MsgType = "chat" - MsgPeerGossip MsgType = "peer_gossip" - MsgFileOffer MsgType = "file_offer" - MsgFileResp MsgType = "file_response" - MsgFileChunk MsgType = "file_chunk" - MsgPing MsgType = "ping" - MsgPong MsgType = "pong" + MsgChat MsgType = "chat" + MsgPeerGossip MsgType = "peer_gossip" + MsgFileOffer MsgType = "file_offer" + MsgFileResp MsgType = "file_response" + MsgFileDone MsgType = "file_done" + MsgPing MsgType = "ping" + MsgPong MsgType = "pong" ) -// PeerMessage is the top-level container decoded from inside an Envelope. +// PeerMessage is the top-level container sent over the "yaw" DataChannel. +// File chunks go over a separate binary DataChannel labeled "f:". type PeerMessage struct { Type MsgType `json:"type"` @@ -78,13 +53,14 @@ type PeerMessage struct { Gossip *PeerGossip `json:"gossip,omitempty"` FileOffer *FileOffer `json:"file_offer,omitempty"` FileResp *FileResponse `json:"file_response,omitempty"` - FileChunk *FileChunk `json:"file_chunk,omitempty"` + FileDone *FileDone `json:"file_done,omitempty"` Seq *uint64 `json:"seq,omitempty"` // for ping/pong } // ChatMessage is a message to a room or a DM. type ChatMessage struct { - ID string `json:"id"` + Mid string `json:"mid"` // random 16-byte hex, for deduplication (YAW/2 §8) + ID string `json:"id"` // internal uuid, kept for local use From PeerID `json:"from"` To *PeerID `json:"to,omitempty"` // nil = broadcast to room Room string `json:"room"` @@ -100,64 +76,102 @@ type PeerGossip struct { // GossipEntry is one peer hint shared via gossip. type GossipEntry struct { Peer PeerInfo `json:"peer"` - AddrHint string `json:"addr_hint"` // IP:port, may be behind NAT + AddrHint string `json:"addr_hint"` // IP:port hint, may be behind NAT LastSeen time.Time `json:"last_seen"` } // FileOffer initiates a file transfer. type FileOffer struct { - TransferID string `json:"transfer_id"` - Filename string `json:"filename"` - SizeBytes int64 `json:"size_bytes"` - SHA256 string `json:"sha256"` + Mid string `json:"mid"` // dedup id + Xid string `json:"xid"` // transfer id, used as DataChannel label "f:" + Filename string `json:"filename"` + SizeBytes int64 `json:"size_bytes"` + SHA256 string `json:"sha256"` // hex } // FileResponse accepts or declines a FileOffer. type FileResponse struct { - TransferID string `json:"transfer_id"` - Accepted bool `json:"accepted"` + Mid string `json:"mid"` + Xid string `json:"xid"` + Accepted bool `json:"accepted"` } -// FileChunk is one piece of a file transfer. -type FileChunk struct { - TransferID string `json:"transfer_id"` - Seq uint32 `json:"seq"` - Data string `json:"data"` // base64url - IsLast bool `json:"is_last"` +// FileDone signals that all chunks have been sent. Receiver verifies SHA256. +type FileDone struct { + Mid string `json:"mid"` + Xid string `json:"xid"` + SHA256 string `json:"sha256"` // hex } -// ── Relay protocol ──────────────────────────────────────────────────────────── +// ── DataChannel hello (YAW/2 §6) ───────────────────────────────────────────── -// RelayMsgType identifies relay wire messages. -type RelayMsgType string +// HelloMessage is the first message sent on the "yaw" DataChannel. +// The signature binds this identity to the specific DTLS session. +type HelloMessage struct { + Type string `json:"type"` // always "hello" + ID string `json:"id"` // hex pubkey + Nick string `json:"nick"` // alias + Caps []string `json:"caps"` // capability list, e.g. ["chat","file"] + Sig string `json:"sig"` // hex ed25519 sig over HelloBindString +} + +// HelloBindString returns the bytes the hello signature covers: +// "yaw/2 bind" || localDTLSFingerprint(32 bytes) || remoteDTLSFingerprint(32 bytes) +func HelloBindString(localFP, remoteFP []byte) []byte { + buf := []byte("yaw/2 bind") + buf = append(buf, localFP...) + buf = append(buf, remoteFP...) + return buf +} + +// ── Signaling payload (sealed inside nacl/box, exchanged via anchor) ────────── + +// SignalingKind identifies the kind of sealed signaling payload. +type SignalingKind string const ( - RelayRegister RelayMsgType = "register" - RelayForward RelayMsgType = "forward" - RelayListPeers RelayMsgType = "list_peers" - RelayForwarded RelayMsgType = "forwarded" - RelayPeerList RelayMsgType = "peer_list" - RelayError RelayMsgType = "error" + SigOffer SignalingKind = "offer" + SigAnswer SignalingKind = "answer" + SigCandidate SignalingKind = "candidate" + SigBye SignalingKind = "bye" ) -// RelayMessage is used for both client→relay and relay→client. -type RelayMessage struct { - Type RelayMsgType `json:"type"` +// SignalingPayload is the JSON plaintext sealed inside a crypto_box (YAW/2 §5). +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 +} - // register - Peer *PeerInfo `json:"peer,omitempty"` - Signature string `json:"signature,omitempty"` +// ── Anchor WebSocket wire types (YAW/2 §5) ──────────────────────────────────── - // forward / forwarded - To *PeerID `json:"to,omitempty"` - From *PeerID `json:"from,omitempty"` - Envelope *Envelope `json:"envelope,omitempty"` +// AnchorMsgType identifies anchor WebSocket messages. +type AnchorMsgType string - // peer_list - Peers []GossipEntry `json:"peers,omitempty"` +const ( + AnchorChallenge AnchorMsgType = "challenge" + AnchorJoin AnchorMsgType = "join" + AnchorJoined AnchorMsgType = "joined" + AnchorPeerJoin AnchorMsgType = "peer-join" + AnchorPeerLeave AnchorMsgType = "peer-leave" + AnchorTo AnchorMsgType = "to" + AnchorFrom AnchorMsgType = "from" + AnchorNoPeer AnchorMsgType = "no-peer" +) - // error - Message string `json:"message,omitempty"` +// AnchorMessage covers all WebSocket frames to/from the anchor. +type AnchorMessage struct { + Type AnchorMsgType `json:"type"` + Nonce string `json:"nonce,omitempty"` // challenge nonce, hex + ID string `json:"id,omitempty"` // peer hex id + Net string `json:"net,omitempty"` // hashed network name + Sig string `json:"sig,omitempty"` // ed25519 sig over (nonce||net), hex + Peers []string `json:"peers,omitempty"` // joined: list of peer hex ids in network + To string `json:"to,omitempty"` // target peer hex id + From string `json:"from,omitempty"` // sender peer hex id + Box string `json:"box,omitempty"` // base64 nacl/box sealed payload } // ── IPC protocol (daemon ↔ local UI) ───────────────────────────────────────── @@ -167,15 +181,17 @@ type IpcMsgType string const ( // Commands (UI → daemon) - CmdSendMessage IpcMsgType = "send_message" - CmdConnect IpcMsgType = "connect" - CmdGetState IpcMsgType = "get_state" - CmdSendFile IpcMsgType = "send_file" + CmdSendMessage IpcMsgType = "send_message" + CmdJoinNetwork IpcMsgType = "join_network" // fields: network_name (plaintext) + CmdLeaveNetwork IpcMsgType = "leave_network" + CmdGetState IpcMsgType = "get_state" + CmdSendFile IpcMsgType = "send_file" // Events (daemon → UI) EvtMessageReceived IpcMsgType = "message_received" EvtPeerConnected IpcMsgType = "peer_connected" EvtPeerDisconnected IpcMsgType = "peer_disconnected" + EvtSessionReady IpcMsgType = "session_ready" // DataChannel open + hello verified EvtIncomingFile IpcMsgType = "incoming_file" EvtFileProgress IpcMsgType = "file_progress" EvtStateSnapshot IpcMsgType = "state_snapshot" @@ -191,8 +207,8 @@ type IpcMessage struct { To *PeerID `json:"to,omitempty"` Body string `json:"body,omitempty"` - // connect - Addr string `json:"addr,omitempty"` + // join_network / leave_network + NetworkName string `json:"network_name,omitempty"` // send_file Path string `json:"path,omitempty"` @@ -200,6 +216,7 @@ type IpcMessage struct { // events Peer *PeerInfo `json:"peer,omitempty"` PeerID *PeerID `json:"peer_id,omitempty"` + Nick string `json:"nick,omitempty"` Message *ChatMessage `json:"message,omitempty"` Offer *FileOffer `json:"offer,omitempty"` TransferID string `json:"transfer_id,omitempty"`