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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user