feat: implement YAW/2.1 forward-secret signaling

Upgrades the signaling layer from static X25519 (2.0) to per-session
ephemeral X25519 (2.1). Recorded signaling traffic cannot be decrypted
even if long-term Ed25519 keys later leak, because esk is zeroed on
session close.

Protocol:
- Each peer generates a fresh X25519 keypair (esk/epk) per session.
- Peers exchange signed `ekey` messages sealed under static keys before
  the offer/answer. Offer/answer/candidate payloads are then sealed with
  ephemeral keys (crypto_box(·, peer_epk, my_esk)).
- ekey sig binds both peer IDs and the epk to prevent replay to third parties.
- Offerer waits up to 2 s for the peer's ekey; if none arrives it falls back
  to YAW/2.0 static-key sealing and logs "2.0 fallback offer".
- 2.0 peers silently ignore the unknown `ekey` kind — full interop preserved.

Implementation:
- crypto.go: add EphemeralKey.PublicRaw/PrivateRaw/Wipe helpers.
- proto.go: add SigEkey kind; EPK/V/EkeySig fields on SignalingPayload.
- anchor/client.go: replace flat pcs map with peerSession struct tracking
  ephemeral keys, peerEPK, and fs flag; openBoxAuto tries ephemeral then
  static; sealAndSend chooses seal based on session state.
- test-network.sh: pipe daemon stderr through tee to daemon.log; add
  YAW/2.1 FS verification section.
- test-tui.sh: same daemon.log capture.
- README.md: document 2.1 forward secrecy, file transfer IPC, updated roadmap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-22 14:45:15 +02:00
parent 13b30ca0cb
commit 274ff423f6
6 changed files with 375 additions and 64 deletions

View File

@@ -1,4 +1,4 @@
// Package anchor implements the YAW/2 anchor client.
// Package anchor implements the YAW/2 anchor client (with YAW/2.1 forward-secret signaling).
// 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.
@@ -25,6 +25,38 @@ import (
"github.com/waste-go/internal/proto"
)
const ekeyTimeout = 2 * time.Second
// peerSession holds per-peer state for one (potential or live) connection.
type peerSession struct {
pc *webrtc.PeerConnection
ekey *crypto.EphemeralKey // our ephemeral keypair for this session
peerEPK *[32]byte // peer's ephemeral pubkey (nil until ekey received)
fs bool // forward-secret session (both sides exchanged ekey)
ekeyRx chan struct{} // closed when peerEPK is set
}
func newSession(pc *webrtc.PeerConnection) (*peerSession, error) {
ek, err := crypto.GenerateEphemeral()
if err != nil {
return nil, err
}
return &peerSession{
pc: pc,
ekey: ek,
ekeyRx: make(chan struct{}),
}, nil
}
func (s *peerSession) close() {
if s.ekey != nil {
s.ekey.Wipe()
}
if s.pc != nil {
s.pc.Close()
}
}
// 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) {
@@ -62,11 +94,11 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
}()
var (
mu sync.RWMutex
pcs = make(map[proto.PeerID]*webrtc.PeerConnection)
mu sync.RWMutex
sessions = make(map[proto.PeerID]*peerSession)
)
sender := &sender{id: id, sendCh: sendCh}
s := &sender{id: id, sendCh: sendCh}
for {
var msg proto.AnchorMessage
@@ -81,7 +113,6 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
if err != nil {
return fmt.Errorf("bad challenge nonce: %w", err)
}
// §5.1: sig covers nonce_raw || net_ascii (64-char hex string as UTF-8)
sig := id.Sign(append(nonceBytes, []byte(netHash)...))
sendCh <- proto.AnchorMessage{
Type: proto.AnchorJoin,
@@ -96,13 +127,13 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
pid := proto.PeerID(peerHex)
if strings.Compare(string(id.PeerID()), peerHex) > 0 {
go func(pid proto.PeerID) {
pc, err := offer(pid, id, m, sender)
sess, err := startOffer(ctx, pid, id, m, s)
if err != nil {
log.Printf("anchor: offer to %s: %v", pid.Short(), err)
return
}
mu.Lock()
pcs[pid] = pc
sessions[pid] = sess
mu.Unlock()
}(pid)
}
@@ -113,13 +144,13 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
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)
sess, err := startOffer(ctx, pid, id, m, s)
if err != nil {
log.Printf("anchor: offer to %s: %v", pid.Short(), err)
return
}
mu.Lock()
pcs[pid] = pc
sessions[pid] = sess
mu.Unlock()
}(pid)
}
@@ -127,21 +158,61 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
case proto.AnchorPeerLeave:
pid := proto.PeerID(msg.ID)
mu.Lock()
if pc, ok := pcs[pid]; ok {
pc.Close()
delete(pcs, pid)
if sess, ok := sessions[pid]; ok {
sess.close()
delete(sessions, 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)
mu.RLock()
sess := sessions[fromID]
mu.RUnlock()
// Determine which key to try for opening the box.
// If we already have the peer's ephemeral key, try ephemeral first.
payload, usedEph, err := openBoxAuto(msg.Box, fromID, id, sess)
if err != nil {
log.Printf("anchor: open box from %s: %v", fromID.Short(), err)
continue
}
dispatchSignaling(ctx, payload, fromID, id, m, sender, pcs, &mu)
if payload.Kind == proto.SigEkey {
// Process ekey under static keys (we opened it correctly above).
mu.Lock()
if sess == nil {
// Answerer: we haven't created a session yet, do it now.
pc, err := newPC()
if err != nil {
mu.Unlock()
log.Printf("anchor: new PC for answerer: %v", err)
continue
}
sess, err = newSession(pc)
if err != nil {
pc.Close()
mu.Unlock()
log.Printf("anchor: ephemeral key gen: %v", err)
continue
}
sessions[fromID] = sess
// Send our ekey back immediately.
go sendEkey(fromID, sess, id, s)
}
if err := receiveEkey(payload, fromID, id, sess); err != nil {
mu.Unlock()
log.Printf("anchor: bad ekey from %s: %v", fromID.Short(), err)
continue
}
mu.Unlock()
_ = usedEph
continue
}
dispatchSignaling(ctx, payload, usedEph, fromID, id, m, s, sessions, &mu)
case proto.AnchorNoPeer:
log.Printf("anchor: no such peer: %s", proto.PeerID(msg.ID).Short())
@@ -149,41 +220,118 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
}
}
// ── ekey helpers ─────────────────────────────────────────────────────────────
// sendEkey seals and transmits our ephemeral public key to peerID.
func sendEkey(peerID proto.PeerID, sess *peerSession, id *crypto.Identity, s *sender) {
epkHex := hex.EncodeToString(sess.ekey.PublicRaw()[:])
sig := signEkey(id, peerID, sess.ekey.PublicRaw())
payload := proto.SignalingPayload{
Kind: proto.SigEkey,
V: "yaw/2.1",
EPK: epkHex,
EkeySig: sig,
}
// ekey is always sealed under static keys (§5.4 (a)).
if err := s.SendTo(peerID, payload); err != nil {
log.Printf("anchor: send ekey to %s: %v", peerID.Short(), err)
}
}
// receiveEkey validates and stores the peer's ephemeral public key.
func receiveEkey(payload proto.SignalingPayload, from proto.PeerID, id *crypto.Identity, sess *peerSession) error {
epkBytes, err := hex.DecodeString(payload.EPK)
if err != nil || len(epkBytes) != 32 {
return fmt.Errorf("bad epk hex")
}
// Verify sig: "yaw/2.1 ekey" || from_id_raw(32) || our_id_raw(32) || epk_raw(32)
fromRaw, err := hex.DecodeString(string(from))
if err != nil {
return fmt.Errorf("bad from id")
}
ourRaw, err := hex.DecodeString(string(id.PeerID()))
if err != nil {
return fmt.Errorf("bad own id")
}
msg := append([]byte("yaw/2.1 ekey"), fromRaw...)
msg = append(msg, ourRaw...)
msg = append(msg, epkBytes...)
if err := crypto.Verify(string(from), msg, payload.EkeySig); err != nil {
return fmt.Errorf("ekey sig: %w", err)
}
var epk [32]byte
copy(epk[:], epkBytes)
sess.peerEPK = &epk
sess.fs = true
close(sess.ekeyRx) // signal waiters
return nil
}
// signEkey produces the Ed25519 signature for our ekey message.
// Input: "yaw/2.1 ekey" || our_id_raw(32) || peer_id_raw(32) || epk_raw(32)
func signEkey(id *crypto.Identity, peerID proto.PeerID, epk *[32]byte) string {
ourRaw, _ := hex.DecodeString(string(id.PeerID()))
peerRaw, _ := hex.DecodeString(string(peerID))
msg := append([]byte("yaw/2.1 ekey"), ourRaw...)
msg = append(msg, peerRaw...)
msg = append(msg, epk[:]...)
return id.Sign(msg)
}
// ── signaling dispatch ────────────────────────────────────────────────────────
func dispatchSignaling(
ctx context.Context,
payload proto.SignalingPayload,
usedEph bool,
fromID proto.PeerID,
id *crypto.Identity,
m *mesh.Mesh,
s *sender,
pcs map[proto.PeerID]*webrtc.PeerConnection,
sessions map[proto.PeerID]*peerSession,
mu *sync.RWMutex,
) {
switch payload.Kind {
case proto.SigOffer:
go func() {
pc, err := answer(payload, fromID, id, m, s)
mu.Lock()
sess := sessions[fromID]
mu.Unlock()
pc, err := answerOffer(ctx, payload, fromID, id, m, s, sess)
if err != nil {
log.Printf("anchor: answer to %s: %v", fromID.Short(), err)
return
}
mu.Lock()
pcs[fromID] = pc
if existing, ok := sessions[fromID]; ok && existing != sess {
// Session was already replaced; close the new PC.
pc.Close()
} else {
if sess == nil {
// No session yet (2.0 peer — no ekey): create a minimal one.
sess2, _ := newSession(pc)
if sess2 != nil {
sess2.fs = false
sessions[fromID] = sess2
}
} else {
sess.pc = pc
}
}
mu.Unlock()
}()
case proto.SigAnswer:
mu.RLock()
pc, ok := pcs[fromID]
sess, ok := sessions[fromID]
mu.RUnlock()
if !ok {
if !ok || sess.pc == nil {
log.Printf("anchor: answer from %s but no PeerConnection", fromID.Short())
return
}
if err := pc.SetRemoteDescription(webrtc.SessionDescription{
if err := sess.pc.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeAnswer,
SDP: payload.SDP,
}); err != nil {
@@ -192,12 +340,12 @@ func dispatchSignaling(
case proto.SigCandidate:
mu.RLock()
pc, ok := pcs[fromID]
sess, ok := sessions[fromID]
mu.RUnlock()
if !ok {
if !ok || sess.pc == nil {
return
}
if err := pc.AddICECandidate(webrtc.ICECandidateInit{
if err := sess.pc.AddICECandidate(webrtc.ICECandidateInit{
Candidate: payload.Cand,
SDPMid: strPtr(payload.Mid),
SDPMLineIndex: uint16Ptr(uint16(payload.MLine)),
@@ -207,9 +355,9 @@ func dispatchSignaling(
case proto.SigBye:
mu.Lock()
if pc, ok := pcs[fromID]; ok {
pc.Close()
delete(pcs, fromID)
if sess, ok := sessions[fromID]; ok {
sess.close()
delete(sessions, fromID)
}
mu.Unlock()
}
@@ -217,20 +365,28 @@ func dispatchSignaling(
// ── offer / answer helpers ────────────────────────────────────────────────────
func offer(peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*webrtc.PeerConnection, error) {
// startOffer creates a session, sends our ekey, waits up to ekeyTimeout for
// the peer's ekey, then sends the offer (ephemeral or static).
func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*peerSession, error) {
pc, err := newPC()
if err != nil {
return nil, err
}
dc, err := pc.CreateDataChannel("yaw", &webrtc.DataChannelInit{Ordered: boolPtr(true)})
sess, err := newSession(pc)
if err != nil {
pc.Close()
return nil, err
}
dc, err := pc.CreateDataChannel("yaw", &webrtc.DataChannelInit{Ordered: boolPtr(true)})
if err != nil {
sess.close()
return nil, err
}
mesh.WireDataChannel(dc, pc, peerID, id, m)
mesh.WireCandidateTrickle(pc, peerID, s)
// Handle file DataChannels opened by the remote peer (they are the file sender).
// Handle file DataChannels opened by the remote peer.
pc.OnDataChannel(func(dc *webrtc.DataChannel) {
if strings.HasPrefix(dc.Label(), "f:") {
xid := strings.TrimPrefix(dc.Label(), "f:")
@@ -238,23 +394,46 @@ func offer(peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*
}
})
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})
// Send our ekey immediately.
sendEkey(peerID, sess, id, s)
// Build the offer in a goroutine so we don't block the read loop.
go func() {
// Wait for peer's ekey or fall back after timeout.
select {
case <-sess.ekeyRx:
log.Printf("anchor: 2.1 FS offer to %s", peerID.Short())
case <-time.After(ekeyTimeout):
log.Printf("anchor: 2.0 fallback offer to %s (no ekey received)", peerID.Short())
case <-ctx.Done():
return
}
sdpOffer, err := pc.CreateOffer(nil)
if err != nil {
log.Printf("anchor: create offer to %s: %v", peerID.Short(), err)
return
}
if err := pc.SetLocalDescription(sdpOffer); err != nil {
log.Printf("anchor: set local offer to %s: %v", peerID.Short(), err)
return
}
payload := proto.SignalingPayload{Kind: proto.SigOffer, SDP: sdpOffer.SDP}
if err := s.sealAndSend(peerID, payload, sess); err != nil {
log.Printf("anchor: send offer to %s: %v", peerID.Short(), err)
}
}()
return sess, nil
}
func answer(payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*webrtc.PeerConnection, error) {
// answerOffer processes an incoming offer and returns the PeerConnection.
func answerOffer(ctx context.Context, payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender, sess *peerSession) (*webrtc.PeerConnection, error) {
pc, err := newPC()
if err != nil {
return nil, err
}
pc.OnDataChannel(func(dc *webrtc.DataChannel) {
switch {
case dc.Label() == "yaw":
@@ -282,7 +461,13 @@ func answer(payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Iden
pc.Close()
return nil, err
}
return pc, s.SendTo(fromID, proto.SignalingPayload{Kind: proto.SigAnswer, SDP: sdpAnswer.SDP})
answerPayload := proto.SignalingPayload{Kind: proto.SigAnswer, SDP: sdpAnswer.SDP}
if err := s.sealAndSend(fromID, answerPayload, sess); err != nil {
pc.Close()
return nil, err
}
return pc, nil
}
// ── sender implements mesh.Anchor ────────────────────────────────────────────
@@ -292,6 +477,7 @@ type sender struct {
sendCh chan proto.AnchorMessage
}
// SendTo seals with STATIC keys (used for ekey and 2.0 fallback).
func (s *sender) SendTo(peerID proto.PeerID, payload proto.SignalingPayload) error {
plaintext, err := json.Marshal(payload)
if err != nil {
@@ -302,8 +488,33 @@ func (s *sender) SendTo(peerID proto.PeerID, payload proto.SignalingPayload) err
return fmt.Errorf("derive curve key for %s: %w", peerID.Short(), err)
}
sealed := crypto.SignalingBox(plaintext, recipientCurve, s.id.CurvePrivateKey())
return s.enqueue(peerID, sealed)
}
// sealAndSend seals with ephemeral keys if available, static otherwise.
func (s *sender) sealAndSend(peerID proto.PeerID, payload proto.SignalingPayload, sess *peerSession) error {
plaintext, err := json.Marshal(payload)
if err != nil {
return err
}
var sealed string
if sess != nil && sess.peerEPK != nil {
// Ephemeral seal (2.1 FS).
sealed = crypto.SignalingBox(plaintext, sess.peerEPK, sess.ekey.PrivateRaw())
} else {
// Static seal (2.0 compatible).
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())
}
return s.enqueue(peerID, sealed)
}
func (s *sender) enqueue(peerID proto.PeerID, box string) error {
select {
case s.sendCh <- proto.AnchorMessage{Type: proto.AnchorTo, To: string(peerID), Box: sealed}:
case s.sendCh <- proto.AnchorMessage{Type: proto.AnchorTo, To: string(peerID), Box: box}:
return nil
default:
return fmt.Errorf("send queue full")
@@ -312,23 +523,37 @@ func (s *sender) SendTo(peerID proto.PeerID, payload proto.SignalingPayload) err
func (s *sender) LocalID() proto.PeerID { return s.id.PeerID() }
// ── helpers ───────────────────────────────────────────────────────────────────
// ── box opening ───────────────────────────────────────────────────────────────
func openBox(b64box string, fromID proto.PeerID, localID *crypto.Identity) (proto.SignalingPayload, error) {
// openBoxAuto opens an incoming box, trying ephemeral keys first (if available),
// then falling back to static. Returns the payload and whether ephemeral was used.
func openBoxAuto(b64box string, fromID proto.PeerID, localID *crypto.Identity, sess *peerSession) (proto.SignalingPayload, bool, error) {
senderCurve, err := curveFromPeerID(fromID)
if err != nil {
return proto.SignalingPayload{}, err
return proto.SignalingPayload{}, false, err
}
// Try ephemeral first if we have the peer's epk.
if sess != nil && sess.peerEPK != nil {
if pt, err := crypto.SignalingOpen(b64box, sess.peerEPK, sess.ekey.PrivateRaw()); err == nil {
var p proto.SignalingPayload
if err := json.Unmarshal(pt, &p); err == nil {
return p, true, nil
}
}
}
// Fall back to static keys.
plaintext, err := crypto.SignalingOpen(b64box, senderCurve, localID.CurvePrivateKey())
if err != nil {
return proto.SignalingPayload{}, err
return proto.SignalingPayload{}, false, fmt.Errorf("open box: %w", err)
}
var p proto.SignalingPayload
return p, json.Unmarshal(plaintext, &p)
return p, false, 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().
// ── helpers ───────────────────────────────────────────────────────────────────
func curveFromPeerID(id proto.PeerID) (*[32]byte, error) {
pubBytes, err := hex.DecodeString(string(id))
if err != nil || len(pubBytes) != 32 {
@@ -355,6 +580,6 @@ func newPC() (*webrtc.PeerConnection, error) {
})
}
func boolPtr(b bool) *bool { return &b }
func strPtr(s string) *string { return &s }
func uint16Ptr(v uint16) *uint16 { return &v }
func boolPtr(b bool) *bool { return &b }
func strPtr(s string) *string { return &s }
func uint16Ptr(v uint16) *uint16 { return &v }

View File

@@ -231,6 +231,21 @@ func (ek *EphemeralKey) PublicKeyB64() string {
return b64.EncodeToString(ek.public[:])
}
// PublicRaw returns a pointer to the raw 32-byte X25519 public key.
// The returned pointer is valid for the lifetime of the EphemeralKey.
func (ek *EphemeralKey) PublicRaw() *[32]byte { return &ek.public }
// PrivateRaw returns a pointer to the raw 32-byte X25519 private key.
// Use only when you need to pass it directly to SignalingBox.
func (ek *EphemeralKey) PrivateRaw() *[32]byte { return &ek.private }
// Wipe zeroes the private key. Call when the session ends.
func (ek *EphemeralKey) Wipe() {
for i := range ek.private {
ek.private[i] = 0
}
}
// SharedSecret performs ECDH with the other party's public key.
// Returns a 32-byte shared secret suitable for use as an AEAD key.
func (ek *EphemeralKey) SharedSecret(theirPublicB64 string) ([32]byte, error) {

View File

@@ -159,15 +159,21 @@ const (
SigAnswer SignalingKind = "answer"
SigCandidate SignalingKind = "candidate"
SigBye SignalingKind = "bye"
SigEkey SignalingKind = "ekey" // YAW/2.1: ephemeral key exchange
)
// SignalingPayload is the JSON plaintext sealed inside a crypto_box (YAW/2 §5).
// SignalingPayload is the JSON plaintext sealed inside a crypto_box (YAW/2 §5 / §5.4).
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
// YAW/2.1 ekey fields (sealed under static keys)
V string `json:"v,omitempty"` // "yaw/2.1"
EPK string `json:"epk,omitempty"` // hex-encoded ephemeral X25519 pubkey (32 bytes)
EkeySig string `json:"ekey_sig,omitempty"` // hex Ed25519 sig over ekey bind bytes
}
// ── Anchor WebSocket wire types (YAW/2 §5) ────────────────────────────────────