Migrate to YAW/2 protocol: WebRTC transport, anchor signaling, nacl/box crypto

Replace the original raw-TCP peer connections and Blowfish/RSA-era design with
the YAW/2 protocol stack:

Transport
- internal/mesh/peer.go: raw TCP + manual ECDH replaced with pion/webrtc
  DataChannels (ICE, DTLS, SCTP). Peers negotiate via sealed offer/answer/
  candidate exchange rather than direct dialling.
- internal/nat: deleted — ICE subsumes everything this package was going to do.

Signaling
- cmd/anchor: new WebSocket signaling server (replaces cmd/relay). Verifies
  Ed25519 challenge/join signatures, routes opaque nacl/box blobs, broadcasts
  peer-join/peer-leave events. Never sees plaintext signaling content.
- internal/anchor: new anchor client. Manages PeerConnection lifecycle,
  decides offerer by peer-id comparison, seals/opens signaling payloads with
  nacl/box, implements mesh.Anchor.

Crypto (internal/crypto)
- PeerID encoding: base64url → lowercase hex (64 chars, YAW/2 §2).
- Sign/Verify: hex signatures throughout.
- Added CurvePublicKey/CurvePrivateKey: X25519 keys derived from Ed25519
  identity via Montgomery conversion (filippo.io/edwards25519), matching
  libsodium's crypto_sign_ed25519_*_to_curve25519.
- Added SignalingBox/SignalingOpen: nacl/box (XSalsa20-Poly1305) seal/open
  for signaling payloads.

Wire types (internal/proto)
- ChatMessage gains Mid (random 16-byte hex) for mesh deduplication (§8).
- FileChunk removed from PeerMessage — chunks go on a separate binary
  DataChannel labelled "f:<xid>"; FileDone added.
- New types: SignalingPayload, AnchorMessage, HelloMessage, HelloBindString.
- PeerID.Short() now returns first 16 hex chars grouped in 4s.
- IPC: CmdConnect removed; CmdJoinNetwork/CmdLeaveNetwork added;
  EvtSessionReady added (fires when DataChannel opens and hello is received).

IPC (internal/ipc)
- join_network/leave_network replace connect (direct TCP dial).
- Network joins are context-scoped: leave_network or client disconnect
  cancels the anchor connection cleanly.

README updated to reflect new project layout, getting-started commands,
IPC protocol, and roadmap state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-21 17:48:14 +02:00
parent de2d1cada0
commit 3e058bee9b
14 changed files with 1716 additions and 671 deletions

246
cmd/anchor/main.go Normal file
View File

@@ -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

View File

@@ -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 <url>"})
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
}

View File

@@ -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 &registry{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] = &registeredPeer{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)
}
}