# 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.