Compare commits

...

4 Commits

Author SHA1 Message Date
Fredrik Johansson
3a105e2c9a 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>
2026-07-02 18:44:48 +02:00
Fredrik Johansson
a4bee36f45 Fix multi-room anchor collision via per-room derived identities
The anchor keys its clients map by peer ID. With the daemon joining one
room per trusted peer, both sessions authenticated with the same real
Ed25519 ID — the second register() overwrote the first, orphaning the
earlier room's WS connection.

Fix mirrors waste-go/internal/netmgr: derive a deterministic per-room
Ed25519 keypair from HKDF(master_private_key, room_hash). Same inputs
always produce the same derived ID; different rooms produce different
IDs. No anchor changes required.

cli/internal/crypto: add DeriveForNetwork (HKDF-SHA256, same KDF as
waste-go) so the daemon can derive stable per-room signaling identities.

cli/internal/transport: Join derives a per-room signaling identity
before calling dialSignaling. Real identity is still used for hello
verification and seal/open crypto inside the DataChannel.

pwa/src/transport/flit.ts: split PeerConn.peerId into signalingId
(anchor routing) and cryptoId (seal/open, hello). In pair-room mode
the daemon's signaling ID differs from its real ID, so connectTo and
_onFrom now use trustedPeerId as the cryptoId regardless of what the
anchor reports as the sender. offerByOrder comparison uses real IDs
(trustedPeerId ?? signalingId) so both sides agree. hello verification
now also checks the Ed25519 signature, not just the claimed ID.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 18:26:19 +02:00
Fredrik Johansson
0fe01e146c Fix file transfer, signaling reliability, and DC callback races
- wireFileRecv: replace OnMessage/OnClose channel approach with pion
  Detach API so SCTP buffers data regardless of callback timing — fixes
  0-byte files caused by pion dispatching data goroutines before OnMessage
  was registered
- yaw DC: same Detach API treatment for the control channel
- connectTo: use DetachDataChannels SettingEngine, replace stale PCs
  (Connecting/Failed/Disconnected state) instead of returning early;
  isCurrent guard in OnConnectionStateChange prevents stale-PC close
  from firing OnDisconnected
- offerByOrder: gate maybeOffer on peer ID order so only one side
  creates the yaw DC and SDP offer — fixes signaling glare causing
  triple connected (verified) logs
- pendingRecvs: promote single pendingRecv slot to map[string]*recvState
  so concurrent file offers don't overwrite each other
- peer-left: stop calling resetPeer() so active file DCs aren't closed
  before data arrives
- signaling ping: wrap conn.Ping with 10s timeout context so a dead TCP
  connection is detected within 30s rather than hanging forever
- signaling read: 3-minute read deadline forces reconnect if anchor
  silently evicts the peer from the room (idle timeout)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 17:38:48 +02:00
Fredrik Johansson
488431eaec Add per-peer auto-accept, disconnect, copy snip, and add-by-ID
- Auto-accept toggle per trusted peer (keyring.autoAccept); only
  activates when peer is cryptographically verified — unverified
  connections always prompt regardless of setting
- Disconnect button on connected screen returns to idle with full
  state reset
- Copy snip: copies own peer ID as a flit-peer:<base64> string for
  sharing out-of-band (no QR needed for initial pairing)
- Add peer by ID: paste a raw hex peer ID or flit-peer: snip in
  Invite new tab to add directly to keyring and connect without QR
- Own peer ID always visible at bottom of idle screen
- Restructured known-device row for mobile: secondary actions
  (Auto/Rename/Forget) on top line, Connect full-width below

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 17:07:11 +02:00
10 changed files with 884 additions and 118 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@ cli/flit
# Host-specific deploy scripts (contain server addresses / SSH targets)
deploy-pwa.sh
deploy-daemon.sh
serve-pwa.sh
# Runtime config — contains anchor URL; copy from example and fill in

154
MULTI_ROOM_PROBLEM.md Normal file
View File

@@ -0,0 +1,154 @@
# The Multi-Room Peer ID Collision Problem
## What's happening
The flit daemon runs one goroutine per trusted peer, and each goroutine opens its own
WebSocket connection to the anchor and joins a separate pair room. Both connections
authenticate with the daemon's real Ed25519 identity — the same peer ID.
The anchor stores connected clients in a Go map keyed by peer ID:
```go
// waste-go/cmd/anchor/main.go
type anchor struct {
clients map[string]*client // keyed by hex peer id
}
func (a *anchor) register(c *client) {
a.mu.Lock()
a.clients[c.id] = c // ← overwrites any existing entry for this id
a.mu.Unlock()
}
```
When the Samsung goroutine connects first, `clients["c639a691..."]` points to the Samsung
room WebSocket. When the Laptop goroutine connects a millisecond later, `clients["c639a691..."]`
is **overwritten** to point to the Laptop room WebSocket. The Samsung entry is gone.
The Samsung room WebSocket is still open. The daemon's read goroutine is still blocked on it.
But the anchor no longer routes any messages to it — it's orphaned. When Samsung joins,
`networkPeerIDs("e1d686b9...", samsung_id)` finds no entry for the daemon in that net hash,
returns an empty list, logs `peers=0`, and never sends the daemon a `peer-join`.
## Why it's not a bug in the anchor
The anchor was designed for human-scale use: one device, one room at a time. A single peer
ID being in two rooms simultaneously isn't a use case it was built for. The `map[string]*client`
design is correct for that model.
---
## Options
### Option A — Ephemeral signaling identity per session (recommended)
Generate a fresh Ed25519 keypair for each `dialSignaling` call. The anchor sees a unique,
throwaway ID per room. The real identity is only used inside the WebRTC DataChannel, in
the `hello` message where it matters for security.
**Anchor changes:** none.
**Protocol changes:** none at the DataChannel level (`hello` is unchanged).
**PWA change required:** the pair-room `trustedPeerID` filter currently rejects any
peer whose *signaling* ID doesn't match the expected peer ID. Since the daemon now
uses an ephemeral signaling ID, the PWA must not filter by signaling ID in pair-room
mode — instead, connect to the first peer in the room and rely on `hello` for identity
verification. This is safe:
- The pair room name is `hash("flit-pair:" + sorted(idA, idB))` — private to the two
parties; not publicly guessable unless you know both real peer IDs.
- `hello` binds the peer's real Ed25519 identity to the DTLS session, so an impostor
can't pass verification even if they discover the room name.
The trade-off: a peer in the wrong room (or a race-condition join) triggers a failed
hello instead of being silently ignored at the signaling level.
**Summary of code changes:**
- `dialSignaling`: accept a `*crypto.Identity` parameter (currently does); callers
pass a freshly-generated ephemeral identity instead of the real one.
- `Session.Join`: generate ephemeral identity before calling `dialSignaling`.
- `Session.PeerID()`: unchanged — returns the real identity's ID.
- `offerByOrder` comparison: compare real peer IDs (unchanged).
- PWA `transport/flit.ts`: in pair-room mode, remove signaling-level ID filter;
connect to any peer present in the room and let `hello` sort out identity.
---
### Option B — Daemon-only room (single WS)
The daemon joins one room derived solely from its own real ID:
```
room = hash("flit-daemon:" + daemon_real_id)
```
All trusted peers know this room (it's computable from the daemon's public ID). The
daemon filters incoming `peer-join` events against its trusted list. One WebSocket
connection, no collision.
**Anchor changes:** none.
**Protocol changes:** yes — pair-room name derivation changes for daemon mode. The PWA
needs a "connect to daemon" flow that uses `hash("flit-daemon:" + daemon_id)` instead
of `pairRoomName(myId, daemonId)`. Existing QR/pair-room flow is unaffected for
non-daemon peers.
**Trade-off:** all trusted peers share one room — each peer can see the others' peer IDs
(via `peer-join` events from the anchor). For most home setups this is fine; for higher
privacy requirements it's undesirable.
---
### Option C — Per-peer sub-identity stored in config
For each trusted peer, the daemon config stores a unique Ed25519 keypair used only for
signaling into that peer's pair room. The real identity is still used for `hello`.
```toml
[[peers]]
id = "ba3e38bf..." # peer's real ID
label = "Samsung"
signal_key = "aabb1122..." # daemon's per-peer signing key (hex Ed25519 private key)
```
The PWA must be told the daemon's per-peer signing key (its public half) to compute
the pair room and recognise the peer-join. This means re-pairing every time a new
`signal_key` is generated.
**Anchor changes:** none. **Protocol changes:** minor. **Pairing complexity:** high —
effectively adds a second identity per pair.
---
---
### Option D — Per-network derived identity (how waste-go solves this)
`waste-go/internal/netmgr` already has this problem and solves it cleanly:
```go
// waste-go/internal/crypto/crypto.go
func DeriveForNetwork(master *Identity, networkHash string) (*Identity, error) {
r := hkdf.New(sha256.New, master.privateKey[:32], []byte(networkHash), []byte("yaw2-net-identity"))
var seed [32]byte
io.ReadFull(r, seed[:])
priv := ed25519.NewKeyFromSeed(seed[:])
return &Identity{privateKey: priv, ...}, nil
}
```
`HKDF(master_private_key, network_hash)` produces a **deterministic, per-room keypair**.
Same master key + same room hash → same derived ID, always. Different rooms → different IDs.
No collision in the anchor's `clients` map.
**Anchor changes:** none.
**Protocol changes:** none — `hello` still uses the real identity.
**PWA changes:** none — the daemon's derived ID is stable per room, so the pair room
derivation and `trustedPeerID` filter are unaffected (they operate on the *other* peer's ID).
**Security:** same as before. The derived key signs the anchor challenge legitimately.
`hello` inside the DataChannel still uses and verifies the real Ed25519 identity.
This is the recommended fix. Flit's `crypto` package needs `DeriveForNetwork` added
(HKDF, same as waste-go), then `Session.Join` passes the derived identity to
`dialSignaling` instead of the master identity.

View File

@@ -115,6 +115,7 @@ func runDaemon(ctx context.Context) {
// reconnecting with exponential backoff whenever the connection drops.
func runPeerLoop(ctx context.Context, tc transport.Config, myID string, peer peerConfig, downloadDir string) {
room := transport.PairRoomName(myID, peer.ID)
log.Printf("[%s] room: %s", peer.Label, room)
backoff := 2 * time.Second
for {

View File

@@ -1,5 +1,6 @@
// flit — headless CLI for ephemeral, E2E-encrypted file transfer.
//
// flit id print this device's peer ID (hex Ed25519 pubkey)
// flit send <path> generate a one-shot room, print QR + invite, wait for a peer, send
// flit recv <invite|code> join a room from an invite string, accept the offered file
// flit daemon run as a persistent receiver for trusted peers (reads ~/.flit/daemon.toml)
@@ -100,6 +101,8 @@ func main() {
defer stop()
switch os.Args[1] {
case "id":
printID()
case "send":
if len(os.Args) < 3 {
fmt.Println("usage: flit send <path>")
@@ -115,11 +118,20 @@ func main() {
case "daemon":
runDaemon(ctx)
default:
fmt.Println("usage: flit send <path> | flit recv <invite> | flit daemon")
fmt.Println("usage: flit id | flit send <path> | flit recv <invite> | flit daemon")
os.Exit(1)
}
}
func printID() {
sess, err := transport.NewSession(dataDir(), transport.Config{})
if err != nil {
fmt.Fprintln(os.Stderr, "flit:", err)
os.Exit(1)
}
fmt.Println(sess.PeerID())
}
func send(ctx context.Context, path string) {
if _, err := os.Stat(path); err != nil {
fmt.Fprintln(os.Stderr, "flit:", err)

View File

@@ -8,17 +8,20 @@ package crypto
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"filippo.io/edwards25519"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/hkdf"
"golang.org/x/crypto/nacl/box"
)
@@ -94,6 +97,21 @@ func Verify(publicKeyHex string, data []byte, sigHex string) error {
return nil
}
// DeriveForNetwork returns a deterministic in-memory Identity derived from the
// master key and the given network hash (hex). Same inputs always produce the
// same keypair, so the peer ID is stable across restarts. Different network
// hashes produce different peer IDs, preventing the anchor's peer-ID map from
// colliding when the same device joins multiple rooms simultaneously.
func DeriveForNetwork(master *Identity, networkHash string) (*Identity, error) {
r := hkdf.New(sha256.New, master.privateKey[:32], []byte(networkHash), []byte("yaw2-net-identity"))
var seed [32]byte
if _, err := io.ReadFull(r, seed[:]); err != nil {
return nil, fmt.Errorf("deriving network identity: %w", err)
}
priv := ed25519.NewKeyFromSeed(seed[:])
return &Identity{privateKey: priv, PublicKey: priv.Public().(ed25519.PublicKey)}, nil
}
// CurvePublicKey returns the X25519 public key derived from this Ed25519 identity.
func (id *Identity) CurvePublicKey() *[32]byte {
edPoint, _ := new(edwards25519.Point).SetBytes(id.PublicKey)

View File

@@ -15,6 +15,7 @@ import (
"io"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -105,6 +106,26 @@ func dialSignaling(ctx context.Context, url string, id *crypto.Identity, netHash
}
}
}()
// Keepalive: ping every 20s so proxies/NAT don't silently drop the connection.
go func() {
t := time.NewTicker(20 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
err := conn.Ping(pingCtx)
cancel()
if err != nil {
// Close so the read loop errors and OnDisconnected fires.
_ = conn.Close(websocket.StatusGoingAway, "ping failed")
return
}
}
}
}()
for {
var msg anchorMsg
@@ -134,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
@@ -145,18 +167,21 @@ type Session struct {
identity *crypto.Identity
cfg Config
mu sync.Mutex
pc *webrtc.PeerConnection
dc *webrtc.DataChannel
peerID string
verified bool
esk, epk *[32]byte
peerEPK *[32]byte
ekeySent bool
offered bool
mu sync.Mutex
pc *webrtc.PeerConnection
dc *webrtc.DataChannel
peerID string
verified bool
esk, epk *[32]byte
peerEPK *[32]byte
ekeySent bool
offered bool
offerByOrder bool // true if this side should create the yaw DC and SDP offer
sig *signaling
pendingRecv *recvState
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()
@@ -179,7 +204,13 @@ func (s *Session) PeerID() string { return s.identity.PeerID() }
// one peer (or the pinned trustedPeerID) to complete the handshake.
func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID string) error {
hash := NetHash(roomName)
sig, present, err := dialSignaling(ctx, s.cfg.SignalURL, s.identity, hash)
// Use a per-room derived identity for signaling so that joining multiple
// rooms simultaneously doesn't collide in the anchor's peer-ID map.
signingID, err := crypto.DeriveForNetwork(s.identity, hash)
if err != nil {
return fmt.Errorf("derive signaling identity: %w", err)
}
sig, present, err := dialSignaling(ctx, s.cfg.SignalURL, signingID, hash)
if err != nil {
return err
}
@@ -197,8 +228,18 @@ func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID strin
go func() {
for {
// 3-minute read deadline: if the anchor silently evicts us from
// the room (idle timeout) without closing the WS, we'll never
// receive a peer-join. Force a reconnect so we re-enter the room.
readCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
var msg anchorMsg
if err := wsjson.Read(ctx, sig.conn, &msg); err != nil {
err := wsjson.Read(readCtx, sig.conn, &msg)
cancel()
if err != nil {
_ = sig.conn.Close(websocket.StatusGoingAway, "idle timeout")
if s.OnDisconnected != nil {
s.OnDisconnected()
}
return
}
switch msg.Type {
@@ -206,7 +247,16 @@ func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID strin
if trustedPeerID != "" && msg.ID != trustedPeerID {
continue
}
log.Printf("flit: peer-join: %s", msg.ID[:8])
_ = s.connectTo(ctx, msg.ID)
case "peer-left":
if trustedPeerID != "" && msg.ID != trustedPeerID {
continue
}
// Log but don't reset: active file transfers use the WebRTC
// path which is independent of signaling. The stale-PC case
// is handled in connectTo when peer-join arrives next.
log.Printf("flit: peer-left: %s", msg.ID[:8])
case "from":
if trustedPeerID != "" && msg.From != trustedPeerID {
continue
@@ -220,45 +270,77 @@ func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID strin
func (s *Session) connectTo(ctx context.Context, peerID string) error {
s.mu.Lock()
var oldPC *webrtc.PeerConnection
if s.pc != nil {
s.mu.Unlock()
return nil
state := s.pc.ConnectionState()
if state == webrtc.PeerConnectionStateNew ||
state == webrtc.PeerConnectionStateConnected {
s.mu.Unlock()
return nil // healthy or brand-new connection already exists
}
// Stale PC (peer left while we were connecting, or ICE failed) — replace it.
log.Printf("flit: connectTo %s: replacing stale PC (state=%s)", peerID[:8], state)
oldPC = s.pc
s.pc = nil
}
s.peerID = peerID
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers(s.cfg)})
var se webrtc.SettingEngine
se.DetachDataChannels()
api := webrtc.NewAPI(webrtc.WithSettingEngine(se))
pc, err := api.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers(s.cfg)})
if err != nil {
s.mu.Unlock()
if oldPC != nil {
_ = oldPC.Close()
}
return err
}
s.pc = pc
kp, err := crypto.GenerateEphemeral()
if err != nil {
s.pc = nil
s.mu.Unlock()
if oldPC != nil {
_ = oldPC.Close()
}
return err
}
s.esk, s.epk = kp.PrivateRaw(), kp.PublicRaw()
s.ekeySent = false
s.offered = false
s.peerEPK = nil
s.offerByOrder = s.identity.PeerID() < peerID
s.mu.Unlock()
// Close the stale PC after s.pc has been updated. The Closed state callback
// will see isCurrent=false and skip OnDisconnected, so runPeerLoop keeps running.
if oldPC != nil {
_ = oldPC.Close()
}
pc.OnDataChannel(func(dc *webrtc.DataChannel) { s.wireDC(dc) })
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
if state == webrtc.PeerConnectionStateFailed ||
state == webrtc.PeerConnectionStateDisconnected ||
state == webrtc.PeerConnectionStateClosed {
s.mu.Lock()
s.pc = nil
s.ekeySent = false
s.offered = false
s.peerEPK = nil
isCurrent := s.pc == pc
if isCurrent {
s.pc = nil
s.ekeySent = false
s.offered = false
s.peerEPK = nil
}
s.mu.Unlock()
if s.OnDisconnected != nil {
log.Printf("flit: PC state=%s isCurrent=%v", state, isCurrent)
if isCurrent && s.OnDisconnected != nil {
s.OnDisconnected()
}
}
})
s.sendEkey()
offerByOrder := s.identity.PeerID() < peerID
if offerByOrder {
if s.offerByOrder {
go func() {
time.Sleep(ekeyTimeout)
s.maybeOffer()
@@ -267,6 +349,23 @@ func (s *Session) connectTo(ctx context.Context, peerID string) error {
return nil
}
// resetPeer tears down the current WebRTC connection without triggering
// OnDisconnected, leaving the signaling connection open so that the next
// peer-join for this peer can reconnect immediately.
func (s *Session) resetPeer() {
s.mu.Lock()
pc := s.pc
s.pc = nil
s.ekeySent = false
s.offered = false
s.peerEPK = nil
s.mu.Unlock()
if pc != nil {
log.Printf("flit: resetPeer: closing stale PC (peer left signaling)")
_ = pc.Close()
}
}
func (s *Session) sendEkey() {
s.mu.Lock()
if s.ekeySent {
@@ -290,7 +389,7 @@ func (s *Session) sendEkey() {
func (s *Session) maybeOffer() {
s.mu.Lock()
if s.offered || s.pc == nil {
if !s.offerByOrder || s.offered || s.pc == nil {
s.mu.Unlock()
return
}
@@ -434,8 +533,32 @@ func (s *Session) wireDC(dc *webrtc.DataChannel) {
s.mu.Lock()
s.dc = dc
s.mu.Unlock()
dc.OnOpen(func() { s.sendHello() })
dc.OnMessage(func(msg webrtc.DataChannelMessage) { s.onControl(msg.Data) })
startYaw := func() {
raw, err := dc.Detach()
if err != nil {
log.Printf("flit: yaw detach: %v", err)
return
}
s.sendHello()
go func() {
buf := make([]byte, 32768)
for {
n, err := raw.Read(buf)
if n > 0 {
s.onControl(append([]byte(nil), buf[:n]...))
}
if err != nil {
return
}
}
}()
}
if dc.ReadyState() == webrtc.DataChannelStateOpen {
startYaw()
} else {
dc.OnOpen(func() { startYaw() })
}
return
}
if strings.HasPrefix(dc.Label(), "f:") {
@@ -444,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),
})
}
@@ -457,16 +596,64 @@ 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)
var offset int64
if v, ok := m["offset"].(string); ok {
offset, _ = strconv.ParseInt(v, 10, 64)
}
s.mu.Lock()
ch := s.pendingAccepts[xid]
delete(s.pendingAccepts, xid)
s.mu.Unlock()
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
}
}
}
@@ -482,7 +669,9 @@ func (s *Session) controlSend(obj map[string]string) {
_ = dc.SendText(string(data))
}
// SendFile offers and streams a file to the connected peer.
// SendFile offers and streams a file to the connected peer. If the receiver
// has a partial .part file it will reply with a non-zero offset and the send
// resumes from that byte position.
func (s *Session) SendFile(path string) error {
f, err := os.Open(path)
if err != nil {
@@ -496,7 +685,31 @@ func (s *Session) SendFile(path string) error {
xid := fmt.Sprintf("push-%d", time.Now().UnixNano())
name := st.Name()
s.controlSend(map[string]string{"type": "file-offer", "name": name, "size": fmt.Sprint(st.Size()), "xid": xid})
// 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 {
s.pendingAccepts = make(map[string]chan int64)
}
s.pendingAccepts[xid] = acceptCh
s.mu.Unlock()
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
@@ -507,10 +720,24 @@ func (s *Session) SendFile(path string) error {
}
opened := make(chan struct{})
dc.OnOpen(func() { close(opened) })
var offset int64
select {
case offset = <-acceptCh:
case <-time.After(30 * time.Second):
return fmt.Errorf("timed out waiting for file-accept")
}
select {
case <-opened:
case <-time.After(15 * time.Second):
return fmt.Errorf("timed out waiting for file-accept")
return fmt.Errorf("timed out waiting for data channel open")
}
if offset > 0 {
if _, err := f.Seek(offset, io.SeekStart); err != nil {
return fmt.Errorf("seek to resume offset: %w", err)
}
log.Printf("flit: resuming %s from byte %d", name, offset)
}
buf := make([]byte, chunkSize)
@@ -531,14 +758,30 @@ 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 downloadDir.
// AcceptOffer accepts an incoming file-offer and writes the transfer to
// downloadDir. If a .part file from a previous partial transfer exists for this
// filename and is smaller than the declared size, its byte count is sent back
// as the resume offset so the sender can skip ahead.
func (s *Session) AcceptOffer(offer FileOffer, downloadDir string) {
s.controlSend(map[string]string{"type": "file-accept", "xid": offer.XID})
partPath := filepath.Join(downloadDir, offer.Name+".part")
var offset int64
if info, err := os.Stat(partPath); err == nil && info.Size() < offer.Size {
offset = info.Size()
}
s.controlSend(map[string]string{"type": "file-accept", "xid": offer.XID, "offset": fmt.Sprint(offset)})
s.mu.Lock()
s.pendingRecv = &recvState{offer: offer, dir: downloadDir}
if s.pendingRecvs == nil {
s.pendingRecvs = make(map[string]*recvState)
}
s.pendingRecvs[offer.XID] = &recvState{offer: offer, dir: downloadDir, have: offset}
s.mu.Unlock()
}
@@ -556,35 +799,139 @@ type recvState struct {
func (s *Session) wireFileRecv(dc *webrtc.DataChannel) {
xid := strings.TrimPrefix(dc.Label(), "f:")
s.mu.Lock()
rs := s.pendingRecv
s.pendingRecv = nil
rs := s.pendingRecvs[xid]
delete(s.pendingRecvs, xid)
s.mu.Unlock()
if rs == nil || rs.offer.XID != xid {
if rs == nil {
return
}
path := rs.dir + "/" + rs.offer.Name
f, err := os.Create(path)
if err != nil {
log.Printf("flit: create %s: %v", path, err)
return
}
rs.f = f
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
if _, err := f.Write(msg.Data); err != nil {
log.Printf("flit: wireFileRecv: %s size=%d state=%s", rs.offer.Name, rs.offer.Size, dc.ReadyState())
// Use pion's Detach API so data is buffered in pion's SCTP stream and
// read by our goroutine — no OnMessage/OnClose callback race.
startRecv := func() {
raw, err := dc.Detach()
if err != nil {
log.Printf("flit: file DC detach %s: %v", rs.offer.Name, err)
return
}
rs.have += int64(len(msg.Data))
if s.OnFileProgress != nil {
s.OnFileProgress(xid, rs.have, rs.offer.Size)
log.Printf("flit: file DC open: %s", rs.offer.Name)
go func() {
finalPath := filepath.Join(rs.dir, rs.offer.Name)
resuming := rs.have > 0
var f *os.File
if resuming {
partPath := finalPath + ".part"
f, err = os.OpenFile(partPath, os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
log.Printf("flit: reopen %s: %v — restarting", partPath, err)
resuming = false
rs.have = 0
f, err = os.Create(finalPath)
} else {
log.Printf("flit: resuming %s from byte %d", rs.offer.Name, rs.have)
}
} else {
f, err = os.Create(finalPath)
}
if err != nil {
log.Printf("flit: open output: %v", err)
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)
if n > 0 {
if _, werr := f.Write(buf[:n]); werr != nil {
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)
}
}
if readErr != nil {
break
}
}
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 {
partPath := finalPath + ".part"
if rerr := os.Rename(partPath, finalPath); rerr != nil {
log.Printf("flit: rename %s: %v", partPath, rerr)
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)
}
}()
}
if dc.ReadyState() == webrtc.DataChannelStateOpen {
startRecv()
} else {
dc.OnOpen(func() { startRecv() })
}
}
// 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
}
})
dc.OnClose(func() {
f.Close()
if s.OnFileDone != nil {
s.OnFileDone(rs.offer.Name, path)
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 {

View File

@@ -106,6 +106,45 @@
background: rgba(255,107,107,0.08);
}
.btn-accent {
color: var(--accent);
border-color: var(--accent-border);
background: var(--accent-dim);
}
.btn-accent:hover {
background: rgba(0,232,122,0.2);
border-color: var(--accent);
}
.my-id-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 8px;
margin-top: 8px;
}
.my-id-label {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
flex-shrink: 0;
}
.my-id-row .peer-id {
flex: 1;
}
.my-id-row .btn {
flex-shrink: 0;
}
.btn-link {
background: none;
border: none;
@@ -200,9 +239,8 @@
.known-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 8px;
@@ -212,6 +250,14 @@
.known-row:hover { border-color: var(--accent-border); }
.known-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.known-info {
display: flex;
flex-direction: column;
@@ -238,12 +284,16 @@
color: var(--muted);
}
.known-actions {
.known-secondary {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.known-connect {
width: 100%;
}
/* ── QR / hosting card ───────────────────────────────────────────────────────── */
.qr-card {

View File

@@ -3,7 +3,7 @@ import QRCode from 'qrcode'
import { FlitSession, type FileOfferEvent, type FileProgressEvent, type FileRecvEvent, type FileSendProgressEvent, type FileSentEvent, type FlitConfig } from './transport/flit'
import { createInvite, decodeInvite, pairRoomName } from './pairing/ephemeral'
import { QrScanner } from './pairing/QrScanner'
import { addTrusted, listTrusted, removeTrusted, type TrustedPeer } from './pairing/keyring'
import { addTrusted, listTrusted, removeTrusted, setAutoAccept, shouldAutoAccept, type TrustedPeer } from './pairing/keyring'
import { consumeSharedFiles, registerServiceWorker } from './share-target'
import './App.css'
@@ -39,7 +39,11 @@ function App() {
const [sendProgress, setSendProgress] = useState<FileSendProgressEvent | null>(null)
const [sentFiles, setSentFiles] = useState<FileSentEvent[]>([])
const [scanning, setScanning] = useState(false)
const [myId, setMyId] = useState<string | null>(null)
const [addIdInput, setAddIdInput] = useState('')
const [copied, setCopied] = useState<'snip' | 'id' | null>(null)
const sessionRef = useRef<FlitSession | null>(null)
const verifiedRef = useRef(false)
useEffect(() => {
registerServiceWorker()
@@ -48,6 +52,10 @@ function App() {
if (files.length) void sendFiles(files)
})
}
// Load own identity eagerly so we can show/copy our peer ID
try {
FlitSession.init(cfg()).then(s => setMyId(s.identity.id)).catch(() => {})
} catch { /* config not ready yet */ }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@@ -59,9 +67,16 @@ function App() {
function peerEvents() {
return {
connected: (v: boolean, pid: string) => { setVerified(v); setPeerId(pid); setPhase('connected') },
connected: (v: boolean, pid: string) => { setVerified(v); verifiedRef.current = v; setPeerId(pid); setPhase('connected') },
status: (s: RTCPeerConnectionState) => setConnState(s),
fileOffer: (e: FileOfferEvent) => { setOffers((o) => [...o, e]); setPeerId(e.peer) },
fileOffer: (e: FileOfferEvent) => {
setPeerId(e.peer)
if (verifiedRef.current && shouldAutoAccept(e.peer)) {
sessionRef.current?.acceptOffer(e.xid, e.name, e.size)
} else {
setOffers((o) => [...o, e])
}
},
fileProgress: (e: FileProgressEvent) => setProgress(e),
fileRecv: (e: FileRecvEvent) => {
setReceived((r) => [...r, e])
@@ -162,6 +177,58 @@ function App() {
setTrusted(listTrusted())
}
function toggleAutoAccept(peer: TrustedPeer) {
setAutoAccept(peer.id, !peer.autoAccept)
setTrusted(listTrusted())
}
function disconnect() {
sessionRef.current?.close()
sessionRef.current = null
setPhase('idle')
setVerified(false)
verifiedRef.current = false
setPeerId('')
setOffers([])
setProgress(null)
setSendProgress(null)
setConnState(null)
}
function addPeerById() {
const raw = addIdInput.trim()
if (!raw) return
// Accept bare hex ID or flit-peer:<base64> snip
let id = raw
let suggestedLabel = ''
if (raw.startsWith('flit-peer:')) {
try {
const b64 = raw.slice('flit-peer:'.length).replace(/-/g, '+').replace(/_/g, '/')
const parsed = JSON.parse(atob(b64)) as { id: string; label?: string }
id = parsed.id
suggestedLabel = parsed.label ?? ''
} catch { setError('Invalid flit-peer snip'); return }
}
if (!/^[0-9a-f]{64}$/i.test(id)) { setError('Not a valid peer ID (64 hex chars)'); return }
const label = window.prompt('Label for this device', suggestedLabel || id.slice(0, 8))
if (label === null) return
addTrusted(id, label.trim() || id.slice(0, 8))
setTrusted(listTrusted())
setAddIdInput('')
setMode('known')
}
function copyMyId(mode: 'snip' | 'id') {
if (!myId) return
const text = mode === 'snip'
? 'flit-peer:' + btoa(JSON.stringify({ id: myId })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
: myId
navigator.clipboard.writeText(text).then(() => {
setCopied(mode)
setTimeout(() => setCopied(null), 2000)
})
}
return (
<main className="app">
<h1>flit<span className="dot">.</span></h1>
@@ -190,15 +257,22 @@ function App() {
: <ul className="known-list">
{trusted.map(p => (
<li key={p.id} className="known-row">
<div className="known-info">
<span className="known-label">{p.label}</span>
<span className="peer-id">{p.id.slice(0, 16)}</span>
</div>
<div className="known-actions">
<button className="btn btn-primary btn-sm" onClick={() => connectToKnown(p)}>Connect</button>
<button className="btn btn-sm" onClick={() => renameKnown(p)}>Rename</button>
<button className="btn btn-sm btn-danger" onClick={() => forgetKnown(p)}>Forget</button>
<div className="known-top">
<div className="known-info">
<span className="known-label">{p.label}</span>
<span className="peer-id">{p.id.slice(0, 16)}</span>
</div>
<div className="known-secondary">
<button
className={`btn btn-sm ${p.autoAccept ? 'btn-accent' : ''}`}
title="Auto-accept files from this device when verified"
onClick={() => toggleAutoAccept(p)}
>{p.autoAccept ? 'Auto ✓' : 'Auto'}</button>
<button className="btn btn-sm" onClick={() => renameKnown(p)}>Rename</button>
<button className="btn btn-sm btn-danger" onClick={() => forgetKnown(p)}>Forget</button>
</div>
</div>
<button className="btn btn-primary known-connect" onClick={() => connectToKnown(p)}>Connect</button>
</li>
))}
</ul>
@@ -218,6 +292,29 @@ function App() {
/>
<button className="btn" onClick={() => joinFromInvite(inviteInput)}>Join</button>
</div>
<div className="divider">or add known peer</div>
<div className="join-row">
<input
className="text-input"
placeholder="peer ID or flit-peer: snip"
value={addIdInput}
onChange={(e) => setAddIdInput(e.target.value)}
/>
<button className="btn" onClick={addPeerById}>Add</button>
</div>
</div>
)}
{myId && (
<div className="my-id-row">
<span className="my-id-label">your ID</span>
<span className="peer-id">{myId.slice(0, 16)}</span>
<button className="btn btn-sm" onClick={() => copyMyId('id')}>
{copied === 'id' ? 'Copied ✓' : 'Copy ID'}
</button>
<button className="btn btn-sm" onClick={() => copyMyId('snip')}>
{copied === 'snip' ? 'Copied ✓' : 'Copy snip'}
</button>
</div>
)}
</div>
@@ -251,7 +348,10 @@ function App() {
</span>
{peerId && <span className="peer-id">{peerId.slice(0, 16)}</span>}
</div>
<button className="btn" onClick={trustCurrentPeer}>Remember this device</button>
<div className="btn-row">
<button className="btn" onClick={trustCurrentPeer}>Remember this device</button>
<button className="btn btn-danger" onClick={disconnect}>Disconnect</button>
</div>
<label className="dropzone">
Tap to choose file(s) to send

View File

@@ -5,6 +5,7 @@ export interface TrustedPeer {
id: string // hex Ed25519 pubkey
label: string
addedAt: number
autoAccept?: boolean
}
const KEY = 'flit_keyring'
@@ -19,6 +20,11 @@ export function addTrusted(id: string, label: string): void {
localStorage.setItem(KEY, JSON.stringify(peers))
}
export function setAutoAccept(id: string, value: boolean): void {
const peers = listTrusted().map(p => p.id === id ? { ...p, autoAccept: value } : p)
localStorage.setItem(KEY, JSON.stringify(peers))
}
export function removeTrusted(id: string): void {
localStorage.setItem(KEY, JSON.stringify(listTrusted().filter(p => p.id !== id)))
}
@@ -26,3 +32,7 @@ export function removeTrusted(id: string): void {
export function isTrusted(id: string): boolean {
return listTrusted().some(p => p.id === id)
}
export function shouldAutoAccept(id: string): boolean {
return listTrusted().some(p => p.id === id && p.autoAccept === true)
}

View File

@@ -47,6 +47,18 @@ async function iceServers(cfg: FlitConfig): Promise<RTCIceServer[]> {
const enc = (s: string) => new TextEncoder().encode(s)
// dtlsFP extracts and hex-decodes the sha-256 DTLS fingerprint from an SDP string.
function dtlsFP(sdp: string): Uint8Array {
const m = sdp.match(/a=fingerprint:sha-256 ([\dA-Fa-f:]+)/i)
if (!m) throw new Error('no sha-256 DTLS fingerprint in SDP')
return sodium.from_hex(m[1].replace(/:/g, ''))
}
async function sha256hex(buf: ArrayBuffer): Promise<string> {
const digest = await globalThis.crypto.subtle.digest('SHA-256', buf)
return toHex(new Uint8Array(digest))
}
function concat(...arrs: Uint8Array[]): Uint8Array {
const n = arrs.reduce((a, b) => a + b.length, 0)
const out = new Uint8Array(n); let o = 0
@@ -250,25 +262,31 @@ export class PeerConn {
private _offerPending = false
private _offered = false
private _recv: Record<string, { name: string; size: number; buf: ArrayBuffer[]; have: number; cancelled?: boolean }> = {}
private _recv: Record<string, { name: string; size: number; buf: ArrayBuffer[]; have: number; cancelled?: boolean; dataComplete?: boolean; expectedSHA256?: string }> = {}
private _recvChannels: Record<string, RTCDataChannel> = {}
private _pushQueue: Map<string, File> = new Map()
private _pushQueue: Map<string, { file: File; sha256: string; buf: ArrayBuffer }> = new Map()
private identity: Identity
private sig: Signaling
// signalingId: the peer's anchor routing ID (may be a per-room derived key)
// cryptoId / peerId: the peer's real Ed25519 identity (used for seal/open and hello)
private _signalingId: string
peerId: string
private events: Partial<PeerEvents>
constructor(
identity: Identity,
sig: Signaling,
peerId: string,
signalingId: string,
cryptoId: string,
events: Partial<PeerEvents>,
ice: RTCIceServer[],
) {
this.identity = identity
this.sig = sig
this.peerId = peerId
this._signalingId = signalingId
this.peerId = cryptoId
this.events = events
this.pc = new RTCPeerConnection({ iceServers: ice })
const kp = sodium.crypto_box_keypair()
@@ -303,7 +321,7 @@ export class PeerConn {
this._ekeySent = true
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.identity.id), sodium.from_hex(this.peerId), this._epk)
const msg = { kind: 'ekey', v: 'yaw/2.1', epk: sodium.to_hex(this._epk), sig: sodium.to_hex(this.identity.sign(signed)) }
this.sig.sendTo(this.peerId, this._seal(msg, false))
this.sig.sendTo(this._signalingId, this._seal(msg, false))
}
private async _onEkey(obj: Record<string, unknown>) {
@@ -333,7 +351,7 @@ export class PeerConn {
this._wire(this.dc)
await this.pc.setLocalDescription(await this.pc.createOffer())
await gatherComplete(this.pc)
this.sig.sendTo(this.peerId, this._seal({ kind: 'offer', sdp: this.pc.localDescription!.sdp }, true))
this.sig.sendTo(this._signalingId, this._seal({ kind: 'offer', sdp: this.pc.localDescription!.sdp }, true))
}
async onBox(box: string) {
@@ -349,7 +367,7 @@ export class PeerConn {
await this.pc.setRemoteDescription({ type: 'offer', sdp: obj['sdp'] as string })
await this.pc.setLocalDescription(await this.pc.createAnswer())
await gatherComplete(this.pc)
this.sig.sendTo(this.peerId, this._seal({ kind: 'answer', sdp: this.pc.localDescription!.sdp }, usedEph))
this.sig.sendTo(this._signalingId, this._seal({ kind: 'answer', sdp: this.pc.localDescription!.sdp }, usedEph))
} else if (obj['kind'] === 'answer') {
await this.pc.setRemoteDescription({ type: 'answer', sdp: obj['sdp'] as string })
}
@@ -376,17 +394,24 @@ export class PeerConn {
}
channel.onclose = () => {
if (rx.cancelled) { delete this._recv[xid]; return }
const blob = new Blob(rx.buf)
const url = URL.createObjectURL(blob)
this.events.fileRecv?.({ peer: this.peerId, name: rx.name, size: rx.size, url })
delete this._recv[xid]
rx.dataComplete = true
// If file-done already arrived before DC closed, finalize now.
if (rx.expectedSHA256 !== undefined) void this._finalizeRecv(xid)
}
this._recvChannels[xid] = channel
}
}
private _sendHello() {
this._dc({ type: 'hello', id: this.identity.id, sig: sodium.to_hex(this.identity.sign(enc(BIND_PREFIX))) })
try {
const lfp = dtlsFP(this.pc.localDescription!.sdp)
const rfp = dtlsFP(this.pc.remoteDescription!.sdp)
// §6: sender signs prefix || local_fp || remote_fp
const bindMsg = concat(enc(BIND_PREFIX), lfp, rfp)
this._dc({ type: 'hello', id: this.identity.id, sig: sodium.to_hex(this.identity.sign(bindMsg)) })
} catch (e) {
console.error('flit: sendHello fingerprint error', e)
}
}
private _onControl(data: string) {
@@ -394,13 +419,26 @@ export class PeerConn {
try { m = JSON.parse(data) } catch { return }
if (m['type'] === 'hello') {
this.verified = m['id'] === this.peerId && this.peerAuthed
const helloId = m['id'] as string
const helloSig = sodium.from_hex(m['sig'] as string)
try {
// §6: verifier reconstructs prefix || remote_fp || local_fp
// (sender's local = our remote; sender's remote = our local)
const rfp = dtlsFP(this.pc.remoteDescription!.sdp)
const lfp = dtlsFP(this.pc.localDescription!.sdp)
const bindMsg = concat(enc(BIND_PREFIX), rfp, lfp)
const sigOk = Identity.verify(helloId, bindMsg, helloSig)
this.verified = helloId === this.peerId && sigOk && this.peerAuthed
} catch {
this.verified = false
}
this.events.connected?.(this.verified, this.peerId)
} else if (m['type'] === 'file-offer') {
this.events.fileOffer?.({ peer: this.peerId, xid: m['xid'] as string, name: m['name'] as string, size: m['size'] as number })
} else if (m['type'] === 'file-accept') {
const xid = m['xid'] as string
if (this._pushQueue.has(xid)) void this._stream(xid)
const offset = typeof m['offset'] === 'string' ? parseInt(m['offset'] as string, 10) || 0 : 0
if (this._pushQueue.has(xid)) void this._stream(xid, offset)
} else if (m['type'] === 'file-cancel') {
const xid = m['xid'] as string
if (this._recv[xid]) {
@@ -409,9 +447,35 @@ export class PeerConn {
delete this._recvChannels[xid]
this.events.fileCancelled?.(xid)
}
} else if (m['type'] === 'file-done') {
const xid = m['xid'] as string
const expectedSHA256 = m['sha256'] as string
const rx = this._recv[xid]
if (rx) {
rx.expectedSHA256 = expectedSHA256
// If DC already closed and all data is in, finalize now.
if (rx.dataComplete) void this._finalizeRecv(xid)
}
}
}
private async _finalizeRecv(xid: string) {
const rx = this._recv[xid]
if (!rx) return
delete this._recv[xid]
delete this._recvChannels[xid]
const combined = new Uint8Array(rx.buf.reduce((acc, b) => acc + b.byteLength, 0))
let offset = 0
for (const b of rx.buf) { combined.set(new Uint8Array(b), offset); offset += b.byteLength }
const actualSHA256 = await sha256hex(combined.buffer)
if (actualSHA256 !== rx.expectedSHA256) {
console.error(`flit: sha256 mismatch for ${rx.name}: got ${actualSHA256} want ${rx.expectedSHA256}`)
return
}
const url = URL.createObjectURL(new Blob([combined]))
this.events.fileRecv?.({ peer: this.peerId, name: rx.name, size: rx.size, url })
}
acceptOffer(xid: string, name: string, size: number) {
this._recv[xid] = { name, size, buf: [], have: 0 }
this._dc({ type: 'file-accept', xid })
@@ -421,32 +485,34 @@ export class PeerConn {
this._dc({ type: 'file-cancel', xid })
}
sendFile(file: File) {
async sendFile(file: File) {
const xid = `push-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
this._pushQueue.set(xid, file)
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid })
const buf = await file.arrayBuffer()
const hash = await sha256hex(buf)
this._pushQueue.set(xid, { file, sha256: hash, buf })
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid, sha256: hash })
}
private async _stream(xid: string) {
const file = this._pushQueue.get(xid)
if (!file) return
private async _stream(xid: string, resumeOffset = 0) {
const entry = this._pushQueue.get(xid)
if (!entry) return
this._pushQueue.delete(xid)
const { file, sha256: hash, buf: fullBuf } = entry
const dc = this.pc.createDataChannel(`f:${xid}`)
dc.binaryType = 'arraybuffer'
await new Promise<void>(res => { dc.onopen = () => res() })
const buf = await file.arrayBuffer()
let offset = 0
while (offset < buf.byteLength) {
const buf = resumeOffset > 0 ? fullBuf.slice(resumeOffset) : fullBuf
let localOffset = 0
while (localOffset < buf.byteLength) {
while (dc.bufferedAmount > 1024 * 1024) await new Promise(r => setTimeout(r, 10))
dc.send(buf.slice(offset, offset + CHUNK))
offset += CHUNK
this.events.fileSendProgress?.({ peer: this.peerId, xid, name: file.name, sent: offset, total: buf.byteLength })
// Yield to the event loop every chunk so the browser can paint
// progress — without this, small files finish in one synchronous
// tick and the UI never shows intermediate state.
dc.send(buf.slice(localOffset, localOffset + CHUNK))
localOffset += CHUNK
this.events.fileSendProgress?.({ peer: this.peerId, xid, name: file.name, sent: resumeOffset + localOffset, total: file.size })
await new Promise(r => setTimeout(r, 0))
}
dc.close()
// §9: send file-done with sha256 so receiver can verify integrity.
this._dc({ type: 'file-done', xid, sha256: hash })
this.events.fileSent?.({ peer: this.peerId, xid, name: file.name })
}
@@ -488,27 +554,34 @@ export class FlitSession {
this.sig = sig
const ice = await iceServers(this.cfg)
const connectTo = async (pid: string) => {
if (pid === this.identity.id) return
if (trustedPeerId && pid !== trustedPeerId) return
const connectTo = async (signalingId: string) => {
if (signalingId === this.identity.id) return
if (this.peer) return
this.peer = new PeerConn(this.identity, sig, pid, events, ice)
if (this.identity.id < pid) await this.peer.startOffer()
// cryptoId is the real Ed25519 identity used for seal/open and hello.
// In ephemeral rooms trustedPeerId is unset so signalingId == cryptoId.
// In pair rooms the daemon uses a derived signaling ID; trustedPeerId is
// the real ID we verify hello against.
const cryptoId = trustedPeerId ?? signalingId
this.peer = new PeerConn(this.identity, sig, signalingId, cryptoId, events, ice)
// Compare real IDs so both sides agree on who offers first.
if (this.identity.id < cryptoId) await this.peer.startOffer()
}
const present = await sig.connect(
(from, box) => { void this._onFrom(from, box, events, ice, trustedPeerId) },
(pid) => { void connectTo(pid) },
(signalingId) => { void connectTo(signalingId) },
() => { /* peer-leave: nothing to clean up for a single 1:1 session */ },
(peers) => { peers.forEach(pid => void connectTo(pid)) },
(peers) => { peers.forEach(signalingId => void connectTo(signalingId)) },
)
for (const pid of present) await connectTo(pid)
for (const signalingId of present) await connectTo(signalingId)
}
private async _onFrom(from: string, box: string, events: Partial<PeerEvents>, ice: RTCIceServer[], trustedPeerId?: string) {
if (from === this.identity.id) return
if (trustedPeerId && from !== trustedPeerId) return
if (!this.peer) this.peer = new PeerConn(this.identity, this.sig!, from, events, ice)
if (!this.peer) {
const cryptoId = trustedPeerId ?? from
this.peer = new PeerConn(this.identity, this.sig!, from, cryptoId, events, ice)
}
await this.peer.onBox(box)
}