diff --git a/README.md b/README.md index db240d9..cbd1693 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,9 @@ Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`. {"type":"send_message","room":"general","body":"hi"} {"type":"send_message","room":"dm:","body":"hey","to":""} {"type":"get_state"} +{"type":"get_file_list"} // own share dir +{"type":"get_file_list","peer_id":"<64-hex>"} // remote peer's share dir +{"type":"send_file","peer_id":"<64-hex>","path":"notes.txt"} // offer a file from share dir {"type":"generate_invite"} ``` @@ -114,7 +117,13 @@ Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`. {"type":"peer_disconnected","peer_id":"<64-hex>"} // Incoming message — mid is a 32-hex dedup token, to is set for DMs -{"type":"message_received","message":{"mid":"<32-hex>","from":"<64-hex>","to":"<64-hex>","room":"general","body":"hi","sent_at":"..."}} +{"type":"message_received","message":{"mid":"<32-hex>","from":"<64-hex>","room":"general","text":"hi","ts":1700000000000}} + +// File events +{"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"<32-hex>","name":"notes.txt","size":1024,"sha256":"<64-hex>"}} +{"type":"file_progress","transfer_id":"<32-hex>","bytes_received":65536,"total_bytes":1048576} +{"type":"file_complete","transfer_id":"<32-hex>","path":"/data-dir/downloads-/notes.txt"} +{"type":"file_list","peer_id":"<64-hex>","files":[{"name":"notes.txt","size_bytes":1024}]} // Invite generation response {"type":"invite_generated","invite":"waste:"} @@ -129,12 +138,25 @@ Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`. |---|---|---| | Identity | Ed25519 | Fast, small keys, standard | | Peer ID | Hex-encoded Ed25519 pubkey | 64 lowercase hex chars (YAW/2 §2) | -| Signaling encryption | XSalsa20-Poly1305 (`nacl/box`) | X25519 keys derived from Ed25519 identity (YAW/2 §3) | +| Signaling encryption (2.0) | XSalsa20-Poly1305 (`nacl/box`) | X25519 keys derived from Ed25519 identity (YAW/2 §3) | +| **Signaling encryption (2.1)** | **XSalsa20-Poly1305, ephemeral X25519** | **Per-session keypair; `esk` wiped on close → forward secrecy** | | Transport | WebRTC DataChannels (DTLS+SCTP) | pion/webrtc — ICE, hole punching included | | Hashing | SHA-256 | File integrity, network name hashing | Replaces WASTE's original Blowfish/PCBC (broken cipher mode) + RSA. +### Forward-secret signaling (YAW/2.1) + +By default waste-go speaks **YAW/2.1**: before sending an offer each peer generates a fresh +X25519 keypair (`esk`/`epk`), broadcasts its `epk` in a signed `ekey` message, then seals +`offer`/`answer`/`candidate` payloads with the *ephemeral* keys. `esk` is zeroed when the +session ends. Recorded signaling traffic cannot be decrypted even if the long-term Ed25519 +keys later leak. + +A 2.0 peer ignores the `ekey` message (unknown type → silently dropped) and the offerer +falls back to static-key sealing after a 2 s timeout, so **2.1 ↔ 2.0 sessions work** — the +session just isn't forward-secret. The log line `anchor: 2.0 fallback offer to …` flags this. + > Peer IDs are 64-char lowercase hex (Ed25519 public key). Existing `identity.json` files > on disk are unaffected — only the over-the-wire representation changed from base64url. @@ -202,12 +224,20 @@ A self-contained test script boots anchor + three peers, joins them to a named n Data lands at `/tmp/waste-test` (wiped on each run). Inspect after a run: ```bash -sqlite3 /tmp/waste-test/alice/messages.db +# DB name includes the network ID (first 8 hex chars of sha256("yaw2-net:"+name)) +sqlite3 /tmp/waste-test/alice/messages-.db .headers on -SELECT room, from_peer, body, sent_at FROM messages; +SELECT room, from_peer, text, sent_at FROM messages; SELECT peer_id, alias, last_seen FROM peers; ``` +There is also a TUI integration test that boots the same three-peer network and +launches the Bubble Tea UI as alice: + +```bash +./test-tui.sh +``` + ## Roadmap - [x] **Crypto layer** — hex peer IDs, `nacl/box` signaling, Ed25519→X25519 key derivation @@ -218,5 +248,6 @@ SELECT peer_id, alias, last_seen FROM peers; - [x] **IPC updates** — `join_network`/`leave_network`; `session_ready` event; DMs via `to` field - [x] **Message persistence** — SQLite (`internal/store`); messages and peer alias cache - [x] **TUI** — Bubble Tea terminal UI (`cmd/tui`); three-pane layout with room switching and DMs -- [ ] **File transfer** — chunked binary DataChannel (`f:`) +- [x] **File transfer** — chunked binary DataChannel (`f:`); SHA-256 verified; backpressure; auto-accept +- [x] **Forward-secret signaling (YAW/2.1)** — ephemeral X25519 per session; `esk` wiped on close; 2.0 fallback - [ ] **Native UI** — web frontend with native packaging (Tauri-style) diff --git a/internal/anchor/client.go b/internal/anchor/client.go index 64b84dd..a55b7a6 100644 --- a/internal/anchor/client.go +++ b/internal/anchor/client.go @@ -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 } diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index d3fbc9f..b6f5c76 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -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) { diff --git a/internal/proto/proto.go b/internal/proto/proto.go index d21dd64..e2a6f8f 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -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) ──────────────────────────────────── diff --git a/test-network.sh b/test-network.sh index 975974f..b96751b 100755 --- a/test-network.sh +++ b/test-network.sh @@ -236,7 +236,7 @@ log "$ALICE_COLOR" "alice" "starting daemon (ipc :${ALICE_IPC})" -ipc-port "$ALICE_IPC" \ -anchor "$ANCHOR_URL" \ -share-dir "$DATA_ROOT/alice/share" \ - 2> >(while IFS= read -r l; do echo -e "${ALICE_COLOR}${DIM}[alice] ${l}${RESET}"; done) & + 2> >(tee "$DATA_ROOT/alice/daemon.log" | while IFS= read -r l; do echo -e "${ALICE_COLOR}${DIM}[alice] ${l}${RESET}"; done) & PIDS+=($!) log "$BOB_COLOR" "bob" "starting daemon (ipc :${BOB_IPC})" @@ -246,7 +246,7 @@ log "$BOB_COLOR" "bob" "starting daemon (ipc :${BOB_IPC})" -ipc-port "$BOB_IPC" \ -anchor "$ANCHOR_URL" \ -share-dir "$DATA_ROOT/bob/share" \ - 2> >(while IFS= read -r l; do echo -e "${BOB_COLOR}${DIM}[bob] ${l}${RESET}"; done) & + 2> >(tee "$DATA_ROOT/bob/daemon.log" | while IFS= read -r l; do echo -e "${BOB_COLOR}${DIM}[bob] ${l}${RESET}"; done) & PIDS+=($!) log "$CHARLIE_COLOR" "charlie" "starting daemon (ipc :${CHARLIE_IPC})" @@ -256,7 +256,7 @@ log "$CHARLIE_COLOR" "charlie" "starting daemon (ipc :${CHARLIE_IPC})" -ipc-port "$CHARLIE_IPC" \ -anchor "$ANCHOR_URL" \ -share-dir "$DATA_ROOT/charlie/share" \ - 2> >(while IFS= read -r l; do echo -e "${CHARLIE_COLOR}${DIM}[charlie]${l}${RESET}"; done) & + 2> >(tee "$DATA_ROOT/charlie/daemon.log" | while IFS= read -r l; do echo -e "${CHARLIE_COLOR}${DIM}[charlie]${l}${RESET}"; done) & PIDS+=($!) wait_port "$ALICE_IPC" "alice" @@ -297,6 +297,37 @@ subscribe "$CHARLIE_COLOR" "charlie" "$CHARLIE_IPC" echo -e "${DIM}waiting for ICE / DataChannel setup (up to 10s)…${RESET}" sleep 6 +# ── YAW/2.1 forward-secrecy check ──────────────────────────────────────────── +# The daemon logs "2.1 FS offer" when forward-secret signaling was negotiated, +# or "2.0 fallback offer" if the peer didn't respond to the ekey in time. +# We verify by counting FS vs fallback lines in the daemon log files. +echo "" +echo -e "${DIM}────────────────────────────────────────────────────────${RESET}" +echo -e "YAW/2.1 forward-secret signaling check" +echo -e "${DIM}────────────────────────────────────────────────────────${RESET}" + +fs_ok=0 +fs_fail=0 +for peer_data in "$DATA_ROOT/alice" "$DATA_ROOT/bob" "$DATA_ROOT/charlie"; do + logfile="$peer_data/daemon.log" + if [ -f "$logfile" ]; then + ok=$(grep -c "2\.1 FS offer" "$logfile" 2>/dev/null || true) + fail=$(grep -c "2\.0 fallback" "$logfile" 2>/dev/null || true) + fs_ok=$(( fs_ok + ok )) + fs_fail=$(( fs_fail + fail )) + fi +done + +# Also check stderr captured above (it was piped to terminal; count from the +# variable output buffer if possible — or just note the result from logs). +# Simpler: re-check via a short daemon log we write below. +# For now just print what we know from the test run output. +if [ "$fs_fail" -eq 0 ]; then + echo -e " ${GREEN}✓ all sessions negotiated YAW/2.1 (forward-secret)${RESET}" +else + echo -e " ${YELLOW}⚠ ${fs_fail} session(s) fell back to YAW/2.0 (non-FS)${RESET}" +fi + # ── group chat ──────────────────────────────────────────────────────────────── echo "" echo -e "${DIM}────────────────────────────────────────────────────────${RESET}" diff --git a/test-tui.sh b/test-tui.sh index c58c5d5..fc685e8 100755 --- a/test-tui.sh +++ b/test-tui.sh @@ -100,17 +100,20 @@ ANCHOR_URL="ws://127.0.0.1:${ANCHOR_PORT}/ws" # Daemons "$DATA_ROOT/bin/waste-daemon" -alias alice -data-dir "$DATA_ROOT/alice" \ -ipc-port "$ALICE_IPC" -anchor "$ANCHOR_URL" \ - -share-dir "$DATA_ROOT/alice/share" 2>/dev/null & + -share-dir "$DATA_ROOT/alice/share" \ + 2>"$DATA_ROOT/alice/daemon.log" & PIDS+=($!) "$DATA_ROOT/bin/waste-daemon" -alias bob -data-dir "$DATA_ROOT/bob" \ -ipc-port "$BOB_IPC" -anchor "$ANCHOR_URL" \ - -share-dir "$DATA_ROOT/bob/share" 2>/dev/null & + -share-dir "$DATA_ROOT/bob/share" \ + 2>"$DATA_ROOT/bob/daemon.log" & PIDS+=($!) "$DATA_ROOT/bin/waste-daemon" -alias charlie -data-dir "$DATA_ROOT/charlie" \ -ipc-port "$CHARLIE_IPC" -anchor "$ANCHOR_URL" \ - -share-dir "$DATA_ROOT/charlie/share" 2>/dev/null & + -share-dir "$DATA_ROOT/charlie/share" \ + 2>"$DATA_ROOT/charlie/daemon.log" & PIDS+=($!) wait_port "$ALICE_IPC"