fix: protocol compliance — DTLS binding, sha256 integrity, derived signaling ID
Go (transport.go, crypto.go): - DeriveForNetwork: HKDF per-room Ed25519 identity to avoid anchor peer-ID collision when daemon holds multiple simultaneous pair-room connections (§ waste-go pattern) - sendHello: sign prefix||local_fp||remote_fp per yaw/2 §6 DTLS fingerprint binding - onControl hello: verify fingerprint binding + sig before setting verified=true - FileOffer.SHA256: carry file hash in offer message per yaw/2 §9 - SendFile: sha256 before offer, sends file-done with hash after streaming - wireFileRecv: pion Detach API + sha256 during recv + wait for file-done + verify - pendingDones map: goroutine-safe channel for file-done routing - signaling keepalive: ping with 10 s timeout, 3-min read deadline on anchor loop PWA (flit.ts): - dtlsFP/sha256hex helpers - Split PeerConn.signalingId (routing) from .peerId (cryptoId, for crypto/hello) - _sendHello: include DTLS fingerprints in signature per §6 - _onControl hello: verify fingerprint binding - sendFile: async, sha256 before offer - _stream: send file-done after DC close - _wire: set dataComplete=true on DC close, defer emit until file-done arrives - _onControl file-done: set expectedSHA256, conditionally call _finalizeRecv - _finalizeRecv: concatenate bufs, verify sha256, create URL, emit fileRecv Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -155,9 +155,10 @@ func (s *signaling) sendTo(to, box string) {
|
||||
// ── Session ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type FileOffer struct {
|
||||
XID string
|
||||
Name string
|
||||
Size int64
|
||||
XID string
|
||||
Name string
|
||||
Size int64
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
// Session is a 1:1 ephemeral pairing: join a room, connect to exactly one
|
||||
@@ -180,6 +181,7 @@ type Session struct {
|
||||
sig *signaling
|
||||
pendingRecvs map[string]*recvState
|
||||
pendingAccepts map[string]chan int64
|
||||
pendingDones map[string]chan string // xid → channel receiving expected sha256 from file-done
|
||||
|
||||
OnConnected func(verified bool)
|
||||
OnDisconnected func()
|
||||
@@ -565,9 +567,25 @@ func (s *Session) wireDC(dc *webrtc.DataChannel) {
|
||||
}
|
||||
|
||||
func (s *Session) sendHello() {
|
||||
s.mu.Lock()
|
||||
pc := s.pc
|
||||
s.mu.Unlock()
|
||||
if pc == nil {
|
||||
return
|
||||
}
|
||||
lfp, err1 := dtlsFP(pc.LocalDescription().SDP)
|
||||
rfp, err2 := dtlsFP(pc.RemoteDescription().SDP)
|
||||
if err1 != nil || err2 != nil {
|
||||
log.Printf("flit: sendHello: fingerprint error: %v / %v", err1, err2)
|
||||
return
|
||||
}
|
||||
// §6: sender signs prefix || local_fp || remote_fp
|
||||
bindMsg := append([]byte(bindPrefix), lfp...)
|
||||
bindMsg = append(bindMsg, rfp...)
|
||||
s.controlSend(map[string]string{
|
||||
"type": "hello", "id": s.identity.PeerID(),
|
||||
"sig": s.identity.Sign([]byte(bindPrefix)),
|
||||
"type": "hello",
|
||||
"id": s.identity.PeerID(),
|
||||
"sig": s.identity.Sign(bindMsg),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -578,16 +596,41 @@ func (s *Session) onControl(data []byte) {
|
||||
}
|
||||
switch m["type"] {
|
||||
case "hello":
|
||||
helloID, _ := m["id"].(string)
|
||||
sigHex, _ := m["sig"].(string)
|
||||
verified := false
|
||||
s.mu.Lock()
|
||||
s.verified = m["id"] == s.peerID
|
||||
pc := s.pc
|
||||
s.mu.Unlock()
|
||||
if pc != nil && pc.RemoteDescription() != nil && pc.LocalDescription() != nil {
|
||||
// §6: verifier reconstructs prefix || remote_fp || local_fp
|
||||
// (sender's local = our remote; sender's remote = our local)
|
||||
rfp, err1 := dtlsFP(pc.RemoteDescription().SDP)
|
||||
lfp, err2 := dtlsFP(pc.LocalDescription().SDP)
|
||||
if err1 == nil && err2 == nil {
|
||||
bindMsg := append([]byte(bindPrefix), rfp...)
|
||||
bindMsg = append(bindMsg, lfp...)
|
||||
verified = helloID == s.peerID && crypto.Verify(helloID, bindMsg, sigHex) == nil
|
||||
} else {
|
||||
log.Printf("flit: hello verify: fingerprint error: %v / %v", err1, err2)
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.verified = verified
|
||||
s.mu.Unlock()
|
||||
if s.OnConnected != nil {
|
||||
s.OnConnected(s.verified)
|
||||
s.OnConnected(verified)
|
||||
}
|
||||
case "file-offer":
|
||||
if s.OnFileOffer != nil {
|
||||
size, _ := m["size"].(float64)
|
||||
s.OnFileOffer(FileOffer{XID: m["xid"].(string), Name: m["name"].(string), Size: int64(size)})
|
||||
sha256, _ := m["sha256"].(string)
|
||||
s.OnFileOffer(FileOffer{
|
||||
XID: m["xid"].(string),
|
||||
Name: m["name"].(string),
|
||||
Size: int64(size),
|
||||
SHA256: sha256,
|
||||
})
|
||||
}
|
||||
case "file-accept":
|
||||
xid, _ := m["xid"].(string)
|
||||
@@ -602,6 +645,16 @@ func (s *Session) onControl(data []byte) {
|
||||
if ch != nil {
|
||||
ch <- offset
|
||||
}
|
||||
case "file-done":
|
||||
xid, _ := m["xid"].(string)
|
||||
sha256hex, _ := m["sha256"].(string)
|
||||
s.mu.Lock()
|
||||
ch := s.pendingDones[xid]
|
||||
delete(s.pendingDones, xid)
|
||||
s.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- sha256hex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,6 +685,16 @@ func (s *Session) SendFile(path string) error {
|
||||
xid := fmt.Sprintf("push-%d", time.Now().UnixNano())
|
||||
name := st.Name()
|
||||
|
||||
// Compute sha256 over the whole file before offering (§9).
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return fmt.Errorf("hashing file: %w", err)
|
||||
}
|
||||
sha256hex := hex.EncodeToString(h.Sum(nil))
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("seek after hash: %w", err)
|
||||
}
|
||||
|
||||
acceptCh := make(chan int64, 1)
|
||||
s.mu.Lock()
|
||||
if s.pendingAccepts == nil {
|
||||
@@ -640,7 +703,13 @@ func (s *Session) SendFile(path string) error {
|
||||
s.pendingAccepts[xid] = acceptCh
|
||||
s.mu.Unlock()
|
||||
|
||||
s.controlSend(map[string]string{"type": "file-offer", "name": name, "size": fmt.Sprint(st.Size()), "xid": xid})
|
||||
s.controlSend(map[string]string{
|
||||
"type": "file-offer",
|
||||
"name": name,
|
||||
"size": fmt.Sprint(st.Size()),
|
||||
"xid": xid,
|
||||
"sha256": sha256hex,
|
||||
})
|
||||
|
||||
s.mu.Lock()
|
||||
pc := s.pc
|
||||
@@ -689,7 +758,12 @@ func (s *Session) SendFile(path string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return dc.Close()
|
||||
if err := dc.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
// §9: send file-done with the pre-computed hash so the receiver can verify.
|
||||
s.controlSend(map[string]string{"type": "file-done", "xid": xid, "sha256": sha256hex})
|
||||
return nil
|
||||
}
|
||||
|
||||
// AcceptOffer accepts an incoming file-offer and writes the transfer to
|
||||
@@ -768,6 +842,17 @@ func (s *Session) wireFileRecv(dc *webrtc.DataChannel) {
|
||||
return
|
||||
}
|
||||
|
||||
// Hash the already-received bytes first when resuming, so our
|
||||
// running sha256 covers the whole file from byte 0.
|
||||
h := sha256.New()
|
||||
if resuming {
|
||||
partPath := finalPath + ".part"
|
||||
if pf, perr := os.Open(partPath); perr == nil {
|
||||
_, _ = io.Copy(h, pf)
|
||||
pf.Close()
|
||||
}
|
||||
}
|
||||
|
||||
buf := make([]byte, chunkSize)
|
||||
for {
|
||||
n, readErr := raw.Read(buf)
|
||||
@@ -776,6 +861,7 @@ func (s *Session) wireFileRecv(dc *webrtc.DataChannel) {
|
||||
log.Printf("flit: write %s: %v", rs.offer.Name, werr)
|
||||
break
|
||||
}
|
||||
h.Write(buf[:n])
|
||||
rs.have += int64(n)
|
||||
if s.OnFileProgress != nil {
|
||||
s.OnFileProgress(xid, rs.have, rs.offer.Size)
|
||||
@@ -786,6 +872,7 @@ func (s *Session) wireFileRecv(dc *webrtc.DataChannel) {
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
actualSHA256 := hex.EncodeToString(h.Sum(nil))
|
||||
log.Printf("flit: recv done: %s wrote=%d of %d", rs.offer.Name, rs.have, rs.offer.Size)
|
||||
|
||||
if resuming {
|
||||
@@ -795,6 +882,27 @@ func (s *Session) wireFileRecv(dc *webrtc.DataChannel) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// §9: wait for file-done and verify sha256 before reporting success.
|
||||
doneCh := make(chan string, 1)
|
||||
s.mu.Lock()
|
||||
if s.pendingDones == nil {
|
||||
s.pendingDones = make(map[string]chan string)
|
||||
}
|
||||
s.pendingDones[xid] = doneCh
|
||||
s.mu.Unlock()
|
||||
var expectedSHA256 string
|
||||
select {
|
||||
case expectedSHA256 = <-doneCh:
|
||||
case <-time.After(15 * time.Second):
|
||||
log.Printf("flit: file-done timeout for %s", rs.offer.Name)
|
||||
return
|
||||
}
|
||||
if actualSHA256 != expectedSHA256 {
|
||||
log.Printf("flit: sha256 mismatch for %s: got %s want %s", rs.offer.Name, actualSHA256, expectedSHA256)
|
||||
return
|
||||
}
|
||||
|
||||
if s.OnFileDone != nil {
|
||||
s.OnFileDone(rs.offer.Name, finalPath)
|
||||
}
|
||||
@@ -808,6 +916,24 @@ func (s *Session) wireFileRecv(dc *webrtc.DataChannel) {
|
||||
}
|
||||
}
|
||||
|
||||
// dtlsFP extracts and hex-decodes the sha-256 DTLS fingerprint from an SDP blob.
|
||||
// The spec (§6) requires this for the hello binding signature.
|
||||
func dtlsFP(sdp string) ([]byte, error) {
|
||||
const marker = "a=fingerprint:sha-256 "
|
||||
for _, line := range strings.Split(sdp, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, marker) {
|
||||
continue
|
||||
}
|
||||
fp, err := hex.DecodeString(strings.ReplaceAll(strings.TrimPrefix(line, marker), ":", ""))
|
||||
if err != nil || len(fp) != 32 {
|
||||
return nil, fmt.Errorf("bad DTLS fingerprint in SDP")
|
||||
}
|
||||
return fp, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no sha-256 DTLS fingerprint in SDP")
|
||||
}
|
||||
|
||||
func mustHex(s string) []byte {
|
||||
b, _ := hex.DecodeString(s)
|
||||
return b
|
||||
|
||||
Reference in New Issue
Block a user