Compare commits
4 Commits
0b06500b9a
...
3a105e2c9a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a105e2c9a | ||
|
|
a4bee36f45 | ||
|
|
0fe01e146c | ||
|
|
488431eaec |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
154
MULTI_ROOM_PROBLEM.md
Normal 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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
124
pwa/src/App.tsx
124
pwa/src/App.tsx
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user