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

348
internal/anchor/client.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:<xid>".
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:<xid>"
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"`