Compare commits

..

12 Commits

Author SHA1 Message Date
explewd
2b77c3e102 Fix verified status to not depend on DTLS fingerprint hash matching
hello's fingerprint-bound signature was the actual gate for `verified`
on both the CLI and PWA, and dtlsFP() only ever parses sha-256
fingerprints. Different WebRTC stacks report the DTLS cert hash under
different algorithms (WebKit: sha-512, pion/Chromium: sha-256), so a
legitimate cross-stack peer could never produce a verifiable hello —
degrading to permanently "unverified", or on the Go side, never
sending hello at all. Matches an interop gotcha waste-go's yaw2 docs
recently called out explicitly.

Real authentication already happens over the sealed signaling channel
during the ekey exchange (crypto.Verify), same as waste-go. Added
peerAuthed to the Go session (mirroring the PWA's existing field),
set once a sealed box from the peer opens successfully, and gate
`verified` on peerAuthed + hello.id matching instead. The fingerprint
signature is still sent/checked when both sides have a parseable
fingerprint, but only logged on mismatch — never blocking.

No behavior change for today's pion<->Chromium pairings (both sha-256).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:44:11 +02:00
explewd
932a4bd7bb Add flit watch: one-way folder sync to a trusted peer
Watches local directories and auto-pushes new/changed files to a
known daemon peer via fsnotify, with startup reconciliation, a
stable_after debounce, and per-file JSON state so failed sends retry
on the next connection. Multiple watch entries targeting the same
peer share a single connection/room (watchPeerGroup), resolving the
room-name-collision question flagged in PROPOSAL-watch.md. Push-only,
never propagates deletes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:50:18 +02:00
explewd
0ffe9fc29d Add proposal: flit watch, one-way folder sync
Design doc for a new sending-side mode: watch a local directory,
push new/changed files to one already-known peer automatically, no
manual `flit send` per file. Deliberately scoped as one-way
(source -> destination, push only) rather than two-way sync, to avoid
drifting into conflict-resolution/delete-propagation territory that
belongs to actual sync tools, not flit.

Grounded in the real code: confirms Session.SendFile is synchronous/
blocking (its return value alone is a sufficient completion signal,
no new ack plumbing needed), and that daemon.go's runPeerLoop already
has the exact persistent-connection/backoff shape this needs — just
aimed at receiving, not sending. Notably, the receiving side needs
zero new code: an existing `flit daemon` configured with the sender
as a trusted peer already does the right thing on receipt.

One open question flagged as needing resolution before implementation
(not deferred): whether multiple watch entries targeting the same
peer would collide on the same deterministic pairwise room name.

Not implemented — design stage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:30:38 +02:00
explewd
06e7ce83e9 Add MIT license 2026-07-09 19:54:38 +02:00
Fredrik Johansson
c8205c703b pwa: sync package-lock.json with package.json
npm ci was failing with "Missing: @emnapi/runtime@1.11.1 from lock
file" — a stale lockfile entry for rolldown's optional wasm32-wasi
binding's transitive deps, unrelated to any feature work. Blocked
deploy-pwa.sh, which uses npm ci for a clean install.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 15:33:19 +02:00
Fredrik Johansson
bd7879ce7b Show online/offline status for known devices
Uses the waste-go anchor's new EXT-009 presence_query to check each
known device's reachability without a full connect attempt. Each
pairing already has its own deterministic anchor room, so this is one
short-lived query per device (auto-refreshed every 15s while the
Known Devices tab is open), not a persistent connection held per
pairing while idle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 15:06:48 +02:00
Fredrik Johansson
6a3ad787d5 fix: register file-done channel before sending file-accept
For small files, file-done arrives before wireFileRecv's goroutine
reaches the pendingDones registration — the message was discarded and
the goroutine timed out. Fix by registering doneCh in AcceptOffer
(before sending file-accept), carrying it through recvState, and
reading it in wireFileRecv without re-registering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 18:57:06 +02:00
Fredrik Johansson
153426367d fix: send file-done before closing file DC to avoid control channel race
Closing the file DataChannel can trigger SCTP/ICE cleanup before the
file-done control message is flushed, causing a 15 s timeout on the
receiver. Send file-done first on both the Go and TS sender paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 18:50:12 +02:00
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
19 changed files with 1878 additions and 130 deletions

1
.gitignore vendored
View File

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

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 explewd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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.

219
PROPOSAL-watch.md Normal file
View File

@@ -0,0 +1,219 @@
# Proposal: `flit watch` — one-way folder sync over flit
**Status:** proposed, not built. Self-contained — written so a fresh agent
with no prior conversation context can pick this up and implement it
without anything explained first.
## Context (read this first)
Full background on flit is in [README.md](./README.md) and
[PROTOCOL.md](./PROTOCOL.md) — read both before touching code. The
relevant existing pieces for this proposal, all in `cli/`:
- `cmd/flit/main.go``send(ctx, path)`: creates a `transport.Session`,
generates a one-shot room + QR invite, and on `OnConnected` calls
`sess.SendFile(path)`. **`SendFile` is synchronous and blocking** — it
returns only once the transfer is fully complete (or errors). Its
return value alone is a sufficient "did this actually get there"
signal; no separate completion callback exists or is needed.
- `cmd/flit/daemon.go` — the pattern this proposal reuses almost
wholesale, just aimed the other direction:
- `daemonConfig`/`peerConfig` — TOML config (`~/.flit/daemon.toml`),
a list of trusted peers by `id`/`label`.
- `runPeerLoop(ctx, tc, myID, peer, downloadDir)` — maintains a
**persistent connection to one named peer**, reconnecting with
exponential backoff (2s → 30s cap) on disconnect. Sets
`OnFileOffer` to auto-accept anything the peer sends and save it to
`downloadDir`.
- This is receive-only today. **`flit watch` needs the send-side
sibling of this exact loop** — same persistent-connection/backoff
shape, but triggered by local filesystem events instead of incoming
offers.
- `internal/transport/transport.go` — `Session.Join(ctx, roomName,
trustedPeerID)`, `transport.PairRoomName(a, b)` (deterministic
pairwise room from two peer IDs — this is what makes "known device,
no fresh QR" work at all), `Session.SendFile(path)`,
`Session.PeerID()`.
## Motivation
At one point the idea on the table was two-way directory sync — watch
a folder, keep it mirrored to a known host, in both directions. That
was deliberately walked back to **one-way sync**: source → destination
only, push only, never reconciling anything back. That sidesteps the
entire class of problem full bidirectional sync brings with it
(conflict resolution, delete propagation, catch-up ordering). The
moment this proposal starts growing bidirectional-anything, that's a
sign it's drifting toward re-implementing a general sync tool — flit's
own case study already draws that line explicitly ("Syncthing syncs
continuously; too heavy for ad hoc one-offs" — flit is deliberately
*not* trying to be that). This proposal should stay on the "one
watched folder pushes new files to one named peer, and that's the
whole feature" side of that line.
The actual want: drop a file into a watched folder, it gets encrypted
and pushed to a specific already-known device automatically — no
manual `flit send`, no QR, no re-running anything by hand per file.
## Design
### 1. The receiving side needs no changes
Worth stating plainly since it simplifies scope: an already-running
`flit daemon`, configured with the sender as a trusted peer, already
does exactly the right thing on receipt — auto-accept, save to
`download_dir`. This proposal is entirely about the sending side.
### 2. `flit watch` — a new subcommand, new config file
`~/.flit/watch.toml`, same shape as `daemon.toml` deliberately, for
familiarity:
```toml
signal_url = "wss://your-anchor.example.com/ws"
turn_url = ""
turn_secret = ""
[[watch]]
dir = "/home/user/backups/documents"
peer_id = "<hex Ed25519 pubkey of the destination device>"
peer_label = "home-server"
pattern = "*.pdf,*.docx" # optional glob include-list; default "*" if omitted
stable_after = "10s" # optional quiet period before considering a file ready
```
Multiple `[[watch]]` blocks are allowed — different folders can go to
different peers, or the same peer, independently.
### 3. Local state = the send queue, no separate queue structure needed
One state file per watch entry:
`~/.flit/watch-state/<sha256(dir + peer_id)>.json` —
`{ "<relative-path>": { "size": N, "mtime": N, "sha256": "...", "sent_at": N|null } }`.
**The invariant that makes this simple**: `sent_at` is only ever set
*after* `SendFile` returns without error. A file with `sent_at: null`,
or whose current size/mtime/hash no longer matches what's recorded, is
"needs sending" — that's the entire queue. No separate retry logic:
if `SendFile` fails partway (peer disconnects mid-transfer), the state
was never advanced, so the file is exactly as eligible for retry on
the next connection as it was before the attempt.
### 4. Startup: reconcile state against reality, don't trust only live events
On start, before subscribing to filesystem events, walk `dir`
(respecting `pattern`) and diff against the state file. This catches
anything that changed while the watcher wasn't running — a live-only
`fsnotify` subscription would silently miss files dropped in during
downtime.
### 5. The watch-and-send loop — `runPeerLoop`'s sibling
```go
// runWatchLoop mirrors daemon.go's runPeerLoop: a persistent connection
// to one named peer, reconnecting with the same backoff. Differs only in
// what drives sends — a local file-ready queue instead of OnFileOffer.
func runWatchLoop(ctx context.Context, tc transport.Config, myID string, w watchConfig, state *watchState) {
room := transport.PairRoomName(myID, w.PeerID)
backoff := 2 * time.Second
for {
if ctx.Err() != nil {
return
}
sess, err := transport.NewSession(dataDir(), tc)
// ... same connect/reconnect shape as runPeerLoop ...
sess.OnConnected = func(verified bool) {
backoff = 2 * time.Second
drainQueue(sess, w, state) // send everything currently queued, in order
}
// fsnotify events (and the startup reconciliation) push into the
// same queue; if already connected when a new file becomes ready,
// send it immediately rather than waiting for the next OnConnected.
...
}
}
```
`drainQueue` sends queued files one at a time via `sess.SendFile(path)`
— sequential, not concurrent, since flit's session model is one peer
connection at a time and concurrent sends would need to be queued
DataChannel-side anyway (see "Multi-file send" in the flit case study
for the existing precedent of handling several files across one
session, just not built for a filesystem-driven trigger).
### 6. Debounce — don't send a file mid-write
`stable_after` is a quiet-period debounce: after the *last* fsnotify
write event for a given file, wait that long before considering it
ready. Without this, a large file being actively written would get
offered (and likely fail hash verification on the receiving end, or
worse, silently transfer a truncated version) the moment its first
bytes land. This is a real requirement, not a nice-to-have — flag it
as such if a fresh implementation is tempted to skip it for a first
cut.
### 7. Never touches deletes
The watcher only reacts to create/write events. A file deleted from
the source directory is never deleted from the destination, and never
should be — that's the entire point of one-way sync rather than a
mirror. Don't wire up `fsnotify`'s remove/rename events to anything
that deletes on the far end.
## Security considerations
- **This sends files with no per-file human confirmation** — a
deliberate, meaningful departure from interactive `flit send`
(always a conscious action) and even from `daemon`'s auto-accept
(still gated behind that peer being explicitly marked trusted with
`autoAccept` in the keyring). The safety knob here is entirely
`watch.toml` itself: which directories are named, and each entry's
`pattern`. Recommend a **`flit watch --dry-run`** mode that walks the
configured directories and prints what *would* be sent, without
connecting to anything — a way to sanity-check a new watch config
before it goes live and silently starts pushing files.
- **`pattern` should default to something narrower than `*` in
practice**, even though `*` is the technical default if omitted —
worth calling out in whatever docs ship with this, since an
unconstrained watch on a broad directory (e.g. a whole home folder)
turns "sync my documents" into "sync everything that ever lands in
this tree," which is a much bigger promise than most `[[watch]]`
entries are meant to make.
- **The destination is exactly as trusted as any other flit peer** —
this doesn't introduce a new trust primitive, it reuses the existing
Ed25519 identity + pairwise-room model as-is. A `peer_id` here is
the same kind of already-known, already-paired device as anything in
the PWA's "Known devices" list or `daemon.toml`.
- **No encryption-at-rest concern beyond what already exists** — files
are E2E encrypted in transit exactly like every other flit transfer;
this proposal doesn't change flit's crypto at all, only adds a new
trigger for when `SendFile` gets called.
## Open questions
- **Large initial backlogs** — if `dir` already has thousands of files
on first run, the startup reconciliation queues all of them at once.
Sequential sending (per §5) means this could take a long time before
catching up to "live." Acceptable for a first cut; a
`--max-startup-queue` or similar throttle could be added later if
this turns out to matter in practice — not designing it ahead of an
actual backlog being a problem.
- **Multiple watchers targeting the same peer simultaneously** — each
`[[watch]]` entry in §5's design opens its own `runWatchLoop`, meaning
its own separate connection/room to the same peer if two entries
share a `peer_id`. `transport.PairRoomName` is deterministic per
peer pair, so two simultaneous sessions to the same peer would
collide on the same room name — this needs either a single shared
connection per unique `peer_id` (multiple watch entries feeding one
`runWatchLoop`/queue) or confirmation that flit's transport already
tolerates multiple simultaneous sessions to the same room gracefully.
**Needs resolving before implementation, not deferred** — this is a
correctness question about the connection model, not a nice-to-have.
- **State file corruption/loss** — if `~/.flit/watch-state/*.json` is
lost (disk issue, manual deletion), the next startup reconciliation
will treat every file as unsent and re-push the entire directory.
Harmless for correctness (the destination just re-receives what it
already has) but potentially slow/wasteful for a large directory.
Worth a passing mention in user-facing docs, not worth engineering
around for a first version.

View File

@@ -20,6 +20,7 @@ It is deliberately not a replacement for [Zipline](https://github.com/diced/zipl
- Auto-downloads on the receiver side — no "click to save" step. - Auto-downloads on the receiver side — no "click to save" step.
- In-app QR scanner (Chrome/Android) — scan the sender's QR without leaving flit. - In-app QR scanner (Chrome/Android) — scan the sender's QR without leaving flit.
- Android share sheet integration — "Share → flit" works from Photos, Files, any app. - Android share sheet integration — "Share → flit" works from Photos, Files, any app.
- One-way folder sync (`flit watch`) — drop a file into a watched folder, it gets pushed to a trusted peer automatically, no manual send.
--- ---
@@ -52,9 +53,9 @@ Each device generates an **Ed25519 keypair** on first run. The hex-encoded publi
Pairing uses [yaw/2.1](PROTOCOL.md) forward-secret signaling, trimmed for flit's 1:1 ephemeral use case: Pairing uses [yaw/2.1](PROTOCOL.md) forward-secret signaling, trimmed for flit's 1:1 ephemeral use case:
1. Connecting peers exchange **ephemeral X25519 keys** (`ekey`) signed with their Ed25519 identity keys — providing forward secrecy for the signaling channel. 1. Connecting peers exchange **ephemeral X25519 keys** (`ekey`) signed with their Ed25519 identity keys over the sealed signaling channel — this is what actually authenticates "the device on the other end is who the QR said it was."
2. Once the WebRTC data channel opens, each side sends a **`hello`** message containing their identity and a signature over a known prefix. This binds the WebRTC DTLS fingerprint to the Ed25519 identity, confirming "the device on the other end is who the QR said it was." 2. Once the WebRTC data channel opens, each side sends a **`hello`** message confirming its identity, plus a best-effort signature binding it to the session's DTLS fingerprint when both sides report that fingerprint under a hash algorithm flit can parse. `verified` status rests on step 1 (the authenticated sealed exchange) and `hello.id` matching it — not on the fingerprint signature, which different WebRTC stacks may report under different hash algorithms (WebKit: sha-512, Chromium/pion: sha-256) and so can't be relied on to match even between two legitimate peers.
3. File data flows over the WebRTC data channel — encrypted by DTLS, with the identity-confirmed binding from step 2. 3. File data flows over the WebRTC data channel — encrypted by DTLS, with the identity already confirmed by step 1.
The anchor never sees plaintext message content — it only routes sealed (encrypted) blobs by peer id and hashed room name. The anchor never sees plaintext message content — it only routes sealed (encrypted) blobs by peer id and hashed room name.
@@ -101,6 +102,7 @@ For headless machines (homelab boxes, servers) that can't run a browser.
flit send path/to/file.tar.gz # print QR + invite, wait for peer, send flit send path/to/file.tar.gz # print QR + invite, wait for peer, send
flit recv "flit:eyJhbmNob3..." # join from invite string, accept file flit recv "flit:eyJhbmNob3..." # join from invite string, accept file
flit daemon # persistent receiver for trusted peers flit daemon # persistent receiver for trusted peers
flit watch [--dry-run] # watch folders, auto-push new files to trusted peers
``` ```
**Built with:** Go, pion/webrtc, libsodium (via nhooyr.io/websocket + internal crypto). **Built with:** Go, pion/webrtc, libsodium (via nhooyr.io/websocket + internal crypto).
@@ -132,6 +134,38 @@ Copy `cli/daemon.toml.example` to `~/.flit/daemon.toml` to get started. The daem
**Bootstrapping:** to get a peer's `id`, pair once via QR (`flit send` / PWA "Invite new") and copy the hex ID from the "peer connected" log line, or read it from the PWA's Known devices list after tapping "Remember this device." **Bootstrapping:** to get a peer's `id`, pair once via QR (`flit send` / PWA "Invite new") and copy the hex ID from the "peer connected" log line, or read it from the PWA's Known devices list after tapping "Remember this device."
### Watch mode
`flit watch` is the send-side sibling of `flit daemon`: it watches one or more local folders and automatically pushes new or changed files to a specific trusted peer — no manual `flit send`, no QR, no per-file interaction. It's **one-way sync only** (source → destination, push only) — deliberately not a general bidirectional sync tool, and it never propagates deletes.
The receiving side needs no changes: an already-running `flit daemon`, configured with the watcher's device as a trusted peer, already does the right thing on receipt.
Config lives at `~/.flit/watch.toml`:
```toml
signal_url = "wss://your-anchor.example.com/ws"
turn_url = "" # optional
turn_secret = "" # optional
[[watch]]
dir = "/home/user/backups/documents"
peer_id = "<hex Ed25519 pubkey of the destination device>"
peer_label = "home-server"
pattern = "*.pdf,*.docx" # optional glob include-list; default "*" if omitted
stable_after = "10s" # optional quiet period before considering a file ready
```
Multiple `[[watch]]` blocks are allowed — different folders can go to different peers, or the same peer (entries sharing a `peer_id` share a single connection).
Copy `cli/watch.toml.example` to `~/.flit/watch.toml` to get started. Notes on behavior:
- **Startup reconciliation** — on start, before subscribing to filesystem events, `flit watch` walks each `dir` and diffs it against local state, so anything that changed while the watcher wasn't running gets picked up (not just live events going forward).
- **Debounce (`stable_after`)** — a file is only considered ready to send after this quiet period has passed since its last write, so a large file being actively written isn't offered mid-write.
- **State tracking** — one JSON state file per `[[watch]]` entry lives in `~/.flit/watch-state/`, recording what's been sent. If a send fails partway (peer disconnects mid-transfer), the file is retried on the next successful connection — no separate retry logic needed. Losing this state is harmless (worst case: the whole directory gets re-sent) but not something to engineer around.
- **`flit watch --dry-run`** walks the configured directories and prints what *would* be sent, without connecting to anything — use it to sanity-check a new `watch.toml` before it goes live.
- **`pattern` should be as narrow as your use case allows.** `*` is the default if omitted, but an unconstrained watch on a broad directory turns "sync my documents" into "sync everything that ever lands in this tree." There's no per-file confirmation in watch mode — the directory and pattern in `watch.toml` are the only safety knob.
- Reconnects with the same exponential backoff (2s → 30s cap) as daemon mode, and reuses the same trusted-peer identity model — a `peer_id` here is the same kind of already-known device as anything in the PWA's Known devices list or `daemon.toml`.
--- ---
## Self-hosting ## Self-hosting

View File

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

View File

@@ -1,8 +1,10 @@
// flit — headless CLI for ephemeral, E2E-encrypted file transfer. // 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 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 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) // flit daemon run as a persistent receiver for trusted peers (reads ~/.flit/daemon.toml)
// flit watch [--dry-run] watch folders and auto-push new files to trusted peers (reads ~/.flit/watch.toml)
package main package main
import ( import (
@@ -92,7 +94,7 @@ func cfg() transport.Config {
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
fmt.Println("usage: flit send <path> | flit recv <invite> | flit daemon") fmt.Println("usage: flit id | flit send <path> | flit recv <invite> | flit daemon | flit watch [--dry-run]")
os.Exit(1) os.Exit(1)
} }
@@ -100,6 +102,8 @@ func main() {
defer stop() defer stop()
switch os.Args[1] { switch os.Args[1] {
case "id":
printID()
case "send": case "send":
if len(os.Args) < 3 { if len(os.Args) < 3 {
fmt.Println("usage: flit send <path>") fmt.Println("usage: flit send <path>")
@@ -114,12 +118,24 @@ func main() {
recv(ctx, os.Args[2]) recv(ctx, os.Args[2])
case "daemon": case "daemon":
runDaemon(ctx) runDaemon(ctx)
case "watch":
dryRun := len(os.Args) > 2 && os.Args[2] == "--dry-run"
runWatch(ctx, dryRun)
default: 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 | flit watch [--dry-run]")
os.Exit(1) 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) { func send(ctx context.Context, path string) {
if _, err := os.Stat(path); err != nil { if _, err := os.Stat(path); err != nil {
fmt.Fprintln(os.Stderr, "flit:", err) fmt.Fprintln(os.Stderr, "flit:", err)

532
cli/cmd/flit/watch.go Normal file
View File

@@ -0,0 +1,532 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/BurntSushi/toml"
"github.com/fsnotify/fsnotify"
"flit/internal/transport"
)
// watchFileConfig is read from ~/.flit/watch.toml.
type watchFileConfig struct {
SignalURL string `toml:"signal_url"`
TurnURL string `toml:"turn_url"`
TurnSecret string `toml:"turn_secret"`
Watches []watchEntryConfig `toml:"watch"`
}
type watchEntryConfig struct {
Dir string `toml:"dir"`
PeerID string `toml:"peer_id"`
PeerLabel string `toml:"peer_label"`
Pattern string `toml:"pattern"`
StableAfter string `toml:"stable_after"`
}
func watchConfigPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".flit", "watch.toml")
}
func watchStateDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".flit", "watch-state")
}
func loadWatchConfig() (*watchFileConfig, error) {
path := watchConfigPath()
var cfg watchFileConfig
if _, err := toml.DecodeFile(path, &cfg); err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
if cfg.SignalURL == "" {
return nil, fmt.Errorf("%s: signal_url is required", path)
}
if len(cfg.Watches) == 0 {
return nil, fmt.Errorf("%s: no [[watch]] entries configured", path)
}
for i := range cfg.Watches {
w := &cfg.Watches[i]
if w.Dir == "" {
return nil, fmt.Errorf("%s: watch entry %d missing dir", path, i)
}
w.Dir = expandHome(w.Dir)
if w.PeerID == "" {
return nil, fmt.Errorf("%s: watch entry for %s missing peer_id", path, w.Dir)
}
if w.PeerLabel == "" {
w.PeerLabel = w.PeerID[:min16(w.PeerID)]
}
if w.Pattern == "" {
w.Pattern = "*"
}
if w.StableAfter == "" {
w.StableAfter = "10s"
}
}
return &cfg, nil
}
// fileState is the per-file record kept in a watch entry's state file.
// SentAt is nil until SendFile has returned successfully for exactly this
// size/mtime — that's the entire "needs sending" queue (see PROPOSAL-watch.md §3).
type fileState struct {
Size int64 `json:"size"`
Mtime int64 `json:"mtime"`
SHA256 string `json:"sha256,omitempty"`
SentAt *int64 `json:"sent_at"`
}
// watchEntry is one [[watch]] block: a directory watched for one peer.
type watchEntry struct {
cfg watchEntryConfig
stableAfter time.Duration
statePath string
mu sync.Mutex
state map[string]fileState
}
func newWatchEntry(cfg watchEntryConfig) (*watchEntry, error) {
d, err := time.ParseDuration(cfg.StableAfter)
if err != nil {
return nil, fmt.Errorf("dir %s: invalid stable_after %q: %w", cfg.Dir, cfg.StableAfter, err)
}
sum := sha256.Sum256([]byte(cfg.Dir + cfg.PeerID))
e := &watchEntry{
cfg: cfg,
stableAfter: d,
statePath: filepath.Join(watchStateDir(), hex.EncodeToString(sum[:])+".json"),
state: map[string]fileState{},
}
return e, nil
}
func (e *watchEntry) load() error {
data, err := os.ReadFile(e.statePath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
e.mu.Lock()
defer e.mu.Unlock()
return json.Unmarshal(data, &e.state)
}
func (e *watchEntry) save() error {
e.mu.Lock()
data, err := json.MarshalIndent(e.state, "", " ")
e.mu.Unlock()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(e.statePath), 0700); err != nil {
return err
}
return os.WriteFile(e.statePath, data, 0600)
}
// matches checks a file's base name against the entry's comma-separated
// glob pattern list.
func (e *watchEntry) matches(name string) bool {
for _, pat := range strings.Split(e.cfg.Pattern, ",") {
pat = strings.TrimSpace(pat)
if pat == "" {
continue
}
if ok, _ := filepath.Match(pat, name); ok {
return true
}
}
return false
}
// noteObserved records the current size/mtime for relPath and, if they
// differ from what was last successfully sent (or nothing was ever sent),
// marks the file ready to send by clearing sent_at.
func (e *watchEntry) noteObserved(relPath string, size, mtime int64) (readyNow bool) {
e.mu.Lock()
defer e.mu.Unlock()
cur, ok := e.state[relPath]
if ok && cur.SentAt != nil && cur.Size == size && cur.Mtime == mtime {
return false // already sent this exact version
}
e.state[relPath] = fileState{Size: size, Mtime: mtime, SHA256: cur.SHA256, SentAt: nil}
return true
}
func (e *watchEntry) markSent(relPath string, size, mtime int64, sha string) {
e.mu.Lock()
defer e.mu.Unlock()
now := time.Now().Unix()
e.state[relPath] = fileState{Size: size, Mtime: mtime, SHA256: sha, SentAt: &now}
}
// pendingSorted returns relative paths currently marked as needing a send,
// in a deterministic order.
func (e *watchEntry) pendingSorted() []string {
e.mu.Lock()
defer e.mu.Unlock()
var out []string
for rel, st := range e.state {
if st.SentAt == nil {
out = append(out, rel)
}
}
sort.Strings(out)
return out
}
// reconcile walks the watched directory and updates in-memory state for
// anything that's new or changed since it was last recorded (PROPOSAL-watch.md §4).
// It does not touch the filesystem beyond reading; call save() afterward to persist.
func (e *watchEntry) reconcile() error {
entries, err := os.ReadDir(e.cfg.Dir)
if err != nil {
return err
}
for _, de := range entries {
if de.IsDir() || !e.matches(de.Name()) {
continue
}
info, err := de.Info()
if err != nil {
continue
}
e.noteObserved(de.Name(), info.Size(), info.ModTime().Unix())
}
return nil
}
// watchPeerGroup shares one persistent connection across every [[watch]]
// entry targeting the same peer_id (PROPOSAL-watch.md open question #2).
type watchPeerGroup struct {
peerID string
label string
entries []*watchEntry
mu sync.Mutex
sess *transport.Session
sendMu sync.Mutex
}
func (g *watchPeerGroup) currentSession() *transport.Session {
g.mu.Lock()
defer g.mu.Unlock()
return g.sess
}
// sendOne sends one file and, on success, advances that file's state past
// "needs sending". On failure the state is left untouched, so the file
// remains eligible for retry on the next drain (PROPOSAL-watch.md §3).
func (g *watchPeerGroup) sendOne(sess *transport.Session, e *watchEntry, rel string) error {
g.sendMu.Lock()
defer g.sendMu.Unlock()
full := filepath.Join(e.cfg.Dir, rel)
info, err := os.Stat(full)
if err != nil {
return err
}
log.Printf("[%s] → %s", g.label, rel)
if err := sess.SendFile(full); err != nil {
return err
}
e.markSent(rel, info.Size(), info.ModTime().Unix(), "")
if err := e.save(); err != nil {
log.Printf("[%s] warning: could not persist state for %s: %v", g.label, e.cfg.Dir, err)
}
log.Printf("[%s] ✓ sent %s", g.label, rel)
return nil
}
// drainAll sends every currently-pending file across every entry in the
// group, sequentially (PROPOSAL-watch.md §5). Stops early on the first
// send error, since that almost always means the connection just dropped;
// remaining files stay queued for the next successful connect.
func (g *watchPeerGroup) drainAll() {
sess := g.currentSession()
if sess == nil {
return
}
for _, e := range g.entries {
for _, rel := range e.pendingSorted() {
if g.currentSession() != sess {
return // disconnected mid-drain
}
if err := g.sendOne(sess, e, rel); err != nil {
log.Printf("[%s] send failed for %s: %v", g.label, rel, err)
return
}
}
}
}
// attemptSendNow sends rel immediately if the group is currently connected.
// If not connected, the file simply stays marked pending — the next
// successful connect's drainAll will pick it up.
func (g *watchPeerGroup) attemptSendNow(e *watchEntry, rel string) {
sess := g.currentSession()
if sess == nil {
return
}
if err := g.sendOne(sess, e, rel); err != nil {
log.Printf("[%s] send failed for %s: %v", g.label, rel, err)
}
}
// runWatchGroup mirrors daemon.go's runPeerLoop: a persistent connection to
// one trusted peer, reconnecting with the same backoff. It differs only in
// what drives sends — pending state across the group's entries, rather than
// incoming OnFileOffer.
func runWatchGroup(ctx context.Context, tc transport.Config, myID string, g *watchPeerGroup) {
room := transport.PairRoomName(myID, g.peerID)
log.Printf("[%s] room: %s", g.label, room)
backoff := 2 * time.Second
for {
if ctx.Err() != nil {
return
}
log.Printf("[%s] connecting…", g.label)
disconnected := make(chan struct{})
sess, err := transport.NewSession(dataDir(), tc)
if err != nil {
log.Printf("[%s] session error: %v", g.label, err)
} else {
sess.OnConnected = func(verified bool) {
status := "unverified"
if verified {
status = "verified"
}
log.Printf("[%s] connected (%s)", g.label, status)
backoff = 2 * time.Second
g.mu.Lock()
g.sess = sess
g.mu.Unlock()
go g.drainAll()
}
sess.OnDisconnected = func() {
g.mu.Lock()
if g.sess == sess {
g.sess = nil
}
g.mu.Unlock()
select {
case <-disconnected:
default:
close(disconnected)
}
}
if err := sess.Join(ctx, room, g.peerID); err != nil {
log.Printf("[%s] join error: %v", g.label, err)
} else {
select {
case <-ctx.Done():
return
case <-disconnected:
log.Printf("[%s] disconnected", g.label)
}
}
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < 30*time.Second {
backoff *= 2
}
}
}
// watchDirLive subscribes to filesystem events for one entry and, after a
// stable_after quiet period with no further writes to a given file, marks
// it ready and (if connected) sends it immediately. Deletes/renames are
// deliberately ignored (PROPOSAL-watch.md §7) — this is push-only, one-way.
func watchDirLive(ctx context.Context, e *watchEntry, g *watchPeerGroup) error {
w, err := fsnotify.NewWatcher()
if err != nil {
return err
}
defer w.Close()
if err := w.Add(e.cfg.Dir); err != nil {
return err
}
var mu sync.Mutex
timers := map[string]*time.Timer{}
fire := func(name string) {
full := filepath.Join(e.cfg.Dir, name)
info, err := os.Stat(full)
if err != nil {
return // removed again before it went stable
}
if e.noteObserved(name, info.Size(), info.ModTime().Unix()) {
_ = e.save()
g.attemptSendNow(e, name)
}
}
for {
select {
case <-ctx.Done():
mu.Lock()
for _, t := range timers {
t.Stop()
}
mu.Unlock()
return nil
case ev, ok := <-w.Events:
if !ok {
return nil
}
if ev.Op&(fsnotify.Write|fsnotify.Create) == 0 {
continue
}
name := filepath.Base(ev.Name)
if !e.matches(name) {
continue
}
mu.Lock()
if t, ok := timers[name]; ok {
t.Stop()
}
timers[name] = time.AfterFunc(e.stableAfter, func() { fire(name) })
mu.Unlock()
case err, ok := <-w.Errors:
if !ok {
return nil
}
log.Printf("watch %s: fsnotify error: %v", e.cfg.Dir, err)
}
}
}
func runWatch(ctx context.Context, dryRun bool) {
cfg, err := loadWatchConfig()
if err != nil {
fmt.Fprintln(os.Stderr, "flit watch:", err)
fmt.Fprintf(os.Stderr, "create %s — see watch.toml.example\n", watchConfigPath())
os.Exit(1)
}
entries := make([]*watchEntry, 0, len(cfg.Watches))
for _, wc := range cfg.Watches {
if _, err := os.Stat(wc.Dir); err != nil {
fmt.Fprintf(os.Stderr, "flit watch: %s: %v\n", wc.Dir, err)
os.Exit(1)
}
e, err := newWatchEntry(wc)
if err != nil {
fmt.Fprintln(os.Stderr, "flit watch:", err)
os.Exit(1)
}
entries = append(entries, e)
}
if dryRun {
for _, e := range entries {
if err := e.load(); err != nil {
fmt.Fprintf(os.Stderr, "flit watch: %s: reading state: %v\n", e.cfg.Dir, err)
os.Exit(1)
}
if err := e.reconcile(); err != nil {
fmt.Fprintf(os.Stderr, "flit watch: %s: %v\n", e.cfg.Dir, err)
os.Exit(1)
}
pending := e.pendingSorted()
fmt.Printf("%s → %s (%d file(s) would be sent):\n", e.cfg.Dir, e.cfg.PeerLabel, len(pending))
for _, rel := range pending {
fmt.Printf(" %s\n", rel)
}
}
return
}
for _, e := range entries {
if err := e.load(); err != nil {
fmt.Fprintf(os.Stderr, "flit watch: %s: reading state: %v\n", e.cfg.Dir, err)
os.Exit(1)
}
}
tc := transport.Config{SignalURL: cfg.SignalURL, TurnURL: cfg.TurnURL, TurnSecret: cfg.TurnSecret}
probe, err := transport.NewSession(dataDir(), tc)
if err != nil {
fmt.Fprintln(os.Stderr, "flit watch: identity error:", err)
os.Exit(1)
}
myID := probe.PeerID()
groups := map[string]*watchPeerGroup{}
var order []string
for _, e := range entries {
g, ok := groups[e.cfg.PeerID]
if !ok {
g = &watchPeerGroup{peerID: e.cfg.PeerID, label: e.cfg.PeerLabel}
groups[e.cfg.PeerID] = g
order = append(order, e.cfg.PeerID)
}
g.entries = append(g.entries, e)
}
log.Printf("flit watch starting — id %s…", myID[:16])
log.Printf(" anchor : %s", cfg.SignalURL)
log.Printf(" dirs : %d, peers : %d", len(entries), len(groups))
var wg sync.WaitGroup
for _, e := range entries {
if err := e.reconcile(); err != nil {
fmt.Fprintf(os.Stderr, "flit watch: %s: %v\n", e.cfg.Dir, err)
os.Exit(1)
}
if err := e.save(); err != nil {
log.Printf("warning: could not persist state for %s: %v", e.cfg.Dir, err)
}
}
for _, peerID := range order {
g := groups[peerID]
wg.Add(1)
go func(g *watchPeerGroup) {
defer wg.Done()
runWatchGroup(ctx, tc, myID, g)
}(g)
for _, e := range g.entries {
wg.Add(1)
go func(e *watchEntry, g *watchPeerGroup) {
defer wg.Done()
if err := watchDirLive(ctx, e, g); err != nil {
log.Printf("watch %s: %v", e.cfg.Dir, err)
}
}(e, g)
}
}
wg.Wait()
log.Println("flit watch stopped")
}

View File

@@ -13,6 +13,7 @@ require (
require ( require (
github.com/BurntSushi/toml v1.6.0 // indirect github.com/BurntSushi/toml v1.6.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/google/uuid v1.3.1 // indirect github.com/google/uuid v1.3.1 // indirect
github.com/pion/datachannel v1.5.8 // indirect github.com/pion/datachannel v1.5.8 // indirect
github.com/pion/dtls/v2 v2.2.12 // indirect github.com/pion/dtls/v2 v2.2.12 // indirect

View File

@@ -5,6 +5,8 @@ github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=

View File

@@ -8,17 +8,20 @@ package crypto
import ( import (
"crypto/ed25519" "crypto/ed25519"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/sha512" "crypto/sha512"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"filippo.io/edwards25519" "filippo.io/edwards25519"
"golang.org/x/crypto/curve25519" "golang.org/x/crypto/curve25519"
"golang.org/x/crypto/hkdf"
"golang.org/x/crypto/nacl/box" "golang.org/x/crypto/nacl/box"
) )
@@ -94,6 +97,21 @@ func Verify(publicKeyHex string, data []byte, sigHex string) error {
return nil 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. // CurvePublicKey returns the X25519 public key derived from this Ed25519 identity.
func (id *Identity) CurvePublicKey() *[32]byte { func (id *Identity) CurvePublicKey() *[32]byte {
edPoint, _ := new(edwards25519.Point).SetBytes(id.PublicKey) edPoint, _ := new(edwards25519.Point).SetBytes(id.PublicKey)

View File

@@ -15,6 +15,7 @@ import (
"io" "io"
"log" "log"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync" "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 { for {
var msg anchorMsg var msg anchorMsg
@@ -134,9 +155,10 @@ func (s *signaling) sendTo(to, box string) {
// ── Session ─────────────────────────────────────────────────────────────────── // ── Session ───────────────────────────────────────────────────────────────────
type FileOffer struct { type FileOffer struct {
XID string XID string
Name string Name string
Size int64 Size int64
SHA256 string
} }
// Session is a 1:1 ephemeral pairing: join a room, connect to exactly one // Session is a 1:1 ephemeral pairing: join a room, connect to exactly one
@@ -145,18 +167,22 @@ type Session struct {
identity *crypto.Identity identity *crypto.Identity
cfg Config cfg Config
mu sync.Mutex mu sync.Mutex
pc *webrtc.PeerConnection pc *webrtc.PeerConnection
dc *webrtc.DataChannel dc *webrtc.DataChannel
peerID string peerID string
verified bool verified bool
esk, epk *[32]byte peerAuthed bool // set once a sealed box from peerID has been opened successfully
peerEPK *[32]byte esk, epk *[32]byte
ekeySent bool peerEPK *[32]byte
offered bool ekeySent bool
offered bool
offerByOrder bool // true if this side should create the yaw DC and SDP offer
sig *signaling sig *signaling
pendingRecv *recvState 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) OnConnected func(verified bool)
OnDisconnected func() OnDisconnected func()
@@ -179,7 +205,13 @@ func (s *Session) PeerID() string { return s.identity.PeerID() }
// one peer (or the pinned trustedPeerID) to complete the handshake. // one peer (or the pinned trustedPeerID) to complete the handshake.
func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID string) error { func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID string) error {
hash := NetHash(roomName) 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 { if err != nil {
return err return err
} }
@@ -197,8 +229,18 @@ func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID strin
go func() { go func() {
for { 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 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 return
} }
switch msg.Type { switch msg.Type {
@@ -206,7 +248,16 @@ func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID strin
if trustedPeerID != "" && msg.ID != trustedPeerID { if trustedPeerID != "" && msg.ID != trustedPeerID {
continue continue
} }
log.Printf("flit: peer-join: %s", msg.ID[:8])
_ = s.connectTo(ctx, msg.ID) _ = 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": case "from":
if trustedPeerID != "" && msg.From != trustedPeerID { if trustedPeerID != "" && msg.From != trustedPeerID {
continue continue
@@ -220,45 +271,77 @@ func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID strin
func (s *Session) connectTo(ctx context.Context, peerID string) error { func (s *Session) connectTo(ctx context.Context, peerID string) error {
s.mu.Lock() s.mu.Lock()
var oldPC *webrtc.PeerConnection
if s.pc != nil { if s.pc != nil {
s.mu.Unlock() state := s.pc.ConnectionState()
return nil 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 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 { if err != nil {
s.mu.Unlock() s.mu.Unlock()
if oldPC != nil {
_ = oldPC.Close()
}
return err return err
} }
s.pc = pc s.pc = pc
kp, err := crypto.GenerateEphemeral() kp, err := crypto.GenerateEphemeral()
if err != nil { if err != nil {
s.pc = nil
s.mu.Unlock() s.mu.Unlock()
if oldPC != nil {
_ = oldPC.Close()
}
return err return err
} }
s.esk, s.epk = kp.PrivateRaw(), kp.PublicRaw() 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() 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.OnDataChannel(func(dc *webrtc.DataChannel) { s.wireDC(dc) })
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
if state == webrtc.PeerConnectionStateFailed || if state == webrtc.PeerConnectionStateFailed ||
state == webrtc.PeerConnectionStateDisconnected || state == webrtc.PeerConnectionStateDisconnected ||
state == webrtc.PeerConnectionStateClosed { state == webrtc.PeerConnectionStateClosed {
s.mu.Lock() s.mu.Lock()
s.pc = nil isCurrent := s.pc == pc
s.ekeySent = false if isCurrent {
s.offered = false s.pc = nil
s.peerEPK = nil s.ekeySent = false
s.offered = false
s.peerEPK = nil
}
s.mu.Unlock() 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.OnDisconnected()
} }
} }
}) })
s.sendEkey() s.sendEkey()
offerByOrder := s.identity.PeerID() < peerID if s.offerByOrder {
if offerByOrder {
go func() { go func() {
time.Sleep(ekeyTimeout) time.Sleep(ekeyTimeout)
s.maybeOffer() s.maybeOffer()
@@ -267,6 +350,23 @@ func (s *Session) connectTo(ctx context.Context, peerID string) error {
return nil 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() { func (s *Session) sendEkey() {
s.mu.Lock() s.mu.Lock()
if s.ekeySent { if s.ekeySent {
@@ -290,7 +390,7 @@ func (s *Session) sendEkey() {
func (s *Session) maybeOffer() { func (s *Session) maybeOffer() {
s.mu.Lock() s.mu.Lock()
if s.offered || s.pc == nil { if !s.offerByOrder || s.offered || s.pc == nil {
s.mu.Unlock() s.mu.Unlock()
return return
} }
@@ -324,6 +424,9 @@ func (s *Session) onBox(from, box string) {
if plain == nil { if plain == nil {
return return
} }
s.mu.Lock()
s.peerAuthed = true
s.mu.Unlock()
var obj map[string]any var obj map[string]any
if err := json.Unmarshal(plain, &obj); err != nil { if err := json.Unmarshal(plain, &obj); err != nil {
return return
@@ -434,8 +537,32 @@ func (s *Session) wireDC(dc *webrtc.DataChannel) {
s.mu.Lock() s.mu.Lock()
s.dc = dc s.dc = dc
s.mu.Unlock() 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 return
} }
if strings.HasPrefix(dc.Label(), "f:") { if strings.HasPrefix(dc.Label(), "f:") {
@@ -444,10 +571,31 @@ func (s *Session) wireDC(dc *webrtc.DataChannel) {
} }
func (s *Session) sendHello() { func (s *Session) sendHello() {
s.controlSend(map[string]string{ s.mu.Lock()
"type": "hello", "id": s.identity.PeerID(), pc := s.pc
"sig": s.identity.Sign([]byte(bindPrefix)), s.mu.Unlock()
}) if pc == nil {
return
}
msg := map[string]string{
"type": "hello",
"id": s.identity.PeerID(),
}
// §6: sign prefix || local_fp || remote_fp when both are extractable.
// This is best-effort, not required — different WebRTC stacks report the
// DTLS certificate hash under different algorithms (WebKit: sha-512,
// pion/Chromium: sha-256), so a legitimate cross-stack peer may have no
// fingerprint we know how to parse. Trust rests on the sealed signaling
// exchange that already authenticated s.peerID (see onBox/peerAuthed)
// plus hello.id matching it, not on this signature — see onControl.
if lfp, err1 := dtlsFP(pc.LocalDescription().SDP); err1 == nil {
if rfp, err2 := dtlsFP(pc.RemoteDescription().SDP); err2 == nil {
bindMsg := append([]byte(bindPrefix), lfp...)
bindMsg = append(bindMsg, rfp...)
msg["sig"] = s.identity.Sign(bindMsg)
}
}
s.controlSend(msg)
} }
func (s *Session) onControl(data []byte) { func (s *Session) onControl(data []byte) {
@@ -457,16 +605,71 @@ func (s *Session) onControl(data []byte) {
} }
switch m["type"] { switch m["type"] {
case "hello": case "hello":
helloID, _ := m["id"].(string)
sigHex, _ := m["sig"].(string)
s.mu.Lock() s.mu.Lock()
s.verified = m["id"] == s.peerID pc := s.pc
peerAuthed := s.peerAuthed
s.mu.Unlock()
// Trust rests on the sealed signaling exchange that already
// authenticated s.peerID (peerAuthed, set in onBox) plus hello.id
// matching it — not on the fingerprint-bound signature below,
// which is only checkable when both sides report their DTLS cert
// hash under an algorithm we can parse. See sendHello for why
// that can't be a hard requirement.
verified := helloID == s.peerID && peerAuthed
if sigHex != "" && 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...)
if err := crypto.Verify(helloID, bindMsg, sigHex); err != nil {
log.Printf("flit: hello: fingerprint-bind signature did not verify for %s (informational only): %v", helloID, err)
}
}
}
s.mu.Lock()
s.verified = verified
s.mu.Unlock() s.mu.Unlock()
if s.OnConnected != nil { if s.OnConnected != nil {
s.OnConnected(s.verified) s.OnConnected(verified)
} }
case "file-offer": case "file-offer":
if s.OnFileOffer != nil { if s.OnFileOffer != nil {
size, _ := m["size"].(float64) 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 +685,9 @@ func (s *Session) controlSend(obj map[string]string) {
_ = dc.SendText(string(data)) _ = 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 { func (s *Session) SendFile(path string) error {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
@@ -496,7 +701,31 @@ func (s *Session) SendFile(path string) error {
xid := fmt.Sprintf("push-%d", time.Now().UnixNano()) xid := fmt.Sprintf("push-%d", time.Now().UnixNano())
name := st.Name() 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() s.mu.Lock()
pc := s.pc pc := s.pc
@@ -507,10 +736,24 @@ func (s *Session) SendFile(path string) error {
} }
opened := make(chan struct{}) opened := make(chan struct{})
dc.OnOpen(func() { close(opened) }) 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 { select {
case <-opened: case <-opened:
case <-time.After(15 * time.Second): 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) buf := make([]byte, chunkSize)
@@ -531,15 +774,39 @@ func (s *Session) SendFile(path string) error {
return err return err
} }
} }
return dc.Close() // §9: send file-done before closing the file DC so the control channel
// is still reachable when the message is delivered.
s.controlSend(map[string]string{"type": "file-done", "xid": xid, "sha256": sha256hex})
if err := dc.Close(); err != nil {
return err
}
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) { 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()
}
// Register doneCh before sending file-accept so file-done can never arrive
// before the channel exists (small files finish before wireFileRecv runs).
doneCh := make(chan string, 1)
s.mu.Lock() s.mu.Lock()
s.pendingRecv = &recvState{offer: offer, dir: downloadDir} if s.pendingDones == nil {
s.pendingDones = make(map[string]chan string)
}
s.pendingDones[offer.XID] = doneCh
if s.pendingRecvs == nil {
s.pendingRecvs = make(map[string]*recvState)
}
s.pendingRecvs[offer.XID] = &recvState{offer: offer, dir: downloadDir, have: offset, doneCh: doneCh}
s.mu.Unlock() s.mu.Unlock()
s.controlSend(map[string]string{"type": "file-accept", "xid": offer.XID, "offset": fmt.Sprint(offset)})
} }
func (s *Session) RejectOffer(xid string) { func (s *Session) RejectOffer(xid string) {
@@ -547,44 +814,145 @@ func (s *Session) RejectOffer(xid string) {
} }
type recvState struct { type recvState struct {
offer FileOffer offer FileOffer
dir string dir string
have int64 have int64
f *os.File f *os.File
doneCh chan string
} }
func (s *Session) wireFileRecv(dc *webrtc.DataChannel) { func (s *Session) wireFileRecv(dc *webrtc.DataChannel) {
xid := strings.TrimPrefix(dc.Label(), "f:") xid := strings.TrimPrefix(dc.Label(), "f:")
s.mu.Lock() s.mu.Lock()
rs := s.pendingRecv rs := s.pendingRecvs[xid]
s.pendingRecv = nil delete(s.pendingRecvs, xid)
s.mu.Unlock() s.mu.Unlock()
if rs == nil || rs.offer.XID != xid { if rs == nil {
return 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) { log.Printf("flit: wireFileRecv: %s size=%d state=%s", rs.offer.Name, rs.offer.Size, dc.ReadyState())
if _, err := f.Write(msg.Data); err != nil {
// 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 return
} }
rs.have += int64(len(msg.Data)) log.Printf("flit: file DC open: %s", rs.offer.Name)
if s.OnFileProgress != nil {
s.OnFileProgress(xid, rs.have, rs.offer.Size) 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 was registered in AcceptOffer before file-accept was sent,
// so file-done can never arrive before the channel exists.
doneCh := rs.doneCh
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
} }
}) fp, err := hex.DecodeString(strings.ReplaceAll(strings.TrimPrefix(line, marker), ":", ""))
dc.OnClose(func() { if err != nil || len(fp) != 32 {
f.Close() return nil, fmt.Errorf("bad DTLS fingerprint in SDP")
if s.OnFileDone != nil {
s.OnFileDone(rs.offer.Name, path)
} }
}) return fp, nil
}
return nil, fmt.Errorf("no sha-256 DTLS fingerprint in SDP")
} }
func mustHex(s string) []byte { func mustHex(s string) []byte {

27
cli/watch.toml.example Normal file
View File

@@ -0,0 +1,27 @@
# flit watch config — copy to ~/.flit/watch.toml and fill in.
#
# Run with: flit watch
# Sanity-check what would be sent, without connecting to anything:
# flit watch --dry-run
#
# One-way sync only: new/changed files in `dir` are pushed to `peer_id`.
# Deletes are never propagated. The receiving device just needs to be
# running `flit daemon` with this device listed as a trusted peer.
# Required — WebSocket URL of your waste-go anchor
signal_url = "wss://your-anchor.example.com/ws"
# Optional — TURN relay for cross-network transfers
turn_url = "turn:your-anchor.example.com:3478"
turn_secret = "" # coturn use-auth-secret; leave empty to use STUN only
# One [[watch]] block per watched directory. Multiple entries may target
# different peers, or the same peer — entries sharing a peer_id share one
# connection.
[[watch]]
dir = "~/backups/documents"
peer_id = "aabbccddeeff..." # hex Ed25519 pubkey of the destination device
peer_label = "home-server"
pattern = "*.pdf,*.docx" # comma-separated glob include-list; default "*" if omitted
stable_after = "10s" # quiet period after the last write before a file is considered ready

24
pwa/package-lock.json generated
View File

@@ -611,6 +611,29 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
@@ -676,7 +699,6 @@
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }

View File

@@ -2,6 +2,7 @@
"name": "pwa", "name": "pwa",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -106,6 +106,45 @@
background: rgba(255,107,107,0.08); 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 { .btn-link {
background: none; background: none;
border: none; border: none;
@@ -200,9 +239,8 @@
.known-row { .known-row {
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: space-between; gap: 10px;
gap: 12px;
padding: 14px 16px; padding: 14px 16px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
@@ -212,6 +250,14 @@
.known-row:hover { border-color: var(--accent-border); } .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 { .known-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -232,18 +278,35 @@
font-weight: 400; font-weight: 400;
} }
.presence-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.presence-online { background: var(--accent); box-shadow: 0 0 4px var(--accent); }
.presence-offline { background: var(--muted); opacity: 0.5; }
.presence-unknown { background: var(--muted); opacity: 0.25; }
.peer-id { .peer-id {
font-family: var(--mono); font-family: var(--mono);
font-size: 11px; font-size: 11px;
color: var(--muted); color: var(--muted);
} }
.known-actions { .known-secondary {
display: flex; display: flex;
gap: 6px; gap: 6px;
flex-shrink: 0; flex-shrink: 0;
} }
.known-connect {
width: 100%;
}
/* ── QR / hosting card ───────────────────────────────────────────────────────── */ /* ── QR / hosting card ───────────────────────────────────────────────────────── */
.qr-card { .qr-card {

View File

@@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { FlitSession, type FileOfferEvent, type FileProgressEvent, type FileRecvEvent, type FileSendProgressEvent, type FileSentEvent, type FlitConfig } from './transport/flit' import { FlitSession, queryPresence, netHash, type FileOfferEvent, type FileProgressEvent, type FileRecvEvent, type FileSendProgressEvent, type FileSentEvent, type FlitConfig } from './transport/flit'
import { createInvite, decodeInvite, pairRoomName } from './pairing/ephemeral' import { createInvite, decodeInvite, pairRoomName } from './pairing/ephemeral'
import { QrScanner } from './pairing/QrScanner' 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 { consumeSharedFiles, registerServiceWorker } from './share-target'
import './App.css' import './App.css'
@@ -39,7 +39,12 @@ function App() {
const [sendProgress, setSendProgress] = useState<FileSendProgressEvent | null>(null) const [sendProgress, setSendProgress] = useState<FileSendProgressEvent | null>(null)
const [sentFiles, setSentFiles] = useState<FileSentEvent[]>([]) const [sentFiles, setSentFiles] = useState<FileSentEvent[]>([])
const [scanning, setScanning] = useState(false) const [scanning, setScanning] = useState(false)
const [myId, setMyId] = useState<string | null>(null)
const [presence, setPresence] = useState<Record<string, boolean | null>>({})
const [addIdInput, setAddIdInput] = useState('')
const [copied, setCopied] = useState<'snip' | 'id' | null>(null)
const sessionRef = useRef<FlitSession | null>(null) const sessionRef = useRef<FlitSession | null>(null)
const verifiedRef = useRef(false)
useEffect(() => { useEffect(() => {
registerServiceWorker() registerServiceWorker()
@@ -48,6 +53,10 @@ function App() {
if (files.length) void sendFiles(files) 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
@@ -57,11 +66,44 @@ function App() {
return c return c
} }
// Presence check for known devices (waste-go EXT-009). Each pairing has its
// own deterministic anchor room, so this is one short-lived query per
// device — no persistent connection held while idle.
async function refreshPresence() {
if (!myId || trusted.length === 0) return
const signalURL = cfg().signalURL
const results = await Promise.all(trusted.map(async (p) => {
try {
const hash = await netHash(pairRoomName(myId, p.id))
const online = await queryPresence(signalURL, hash, p.id)
return [p.id, online] as const
} catch {
return [p.id, null] as const
}
}))
setPresence(Object.fromEntries(results))
}
useEffect(() => {
if (mode !== 'known' || phase !== 'idle' || !myId || trusted.length === 0) return
void refreshPresence()
const iv = setInterval(() => void refreshPresence(), 15000)
return () => clearInterval(iv)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, phase, myId, trusted])
function peerEvents() { function peerEvents() {
return { 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), 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), fileProgress: (e: FileProgressEvent) => setProgress(e),
fileRecv: (e: FileRecvEvent) => { fileRecv: (e: FileRecvEvent) => {
setReceived((r) => [...r, e]) setReceived((r) => [...r, e])
@@ -162,6 +204,58 @@ function App() {
setTrusted(listTrusted()) 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 ( return (
<main className="app"> <main className="app">
<h1>flit<span className="dot">.</span></h1> <h1>flit<span className="dot">.</span></h1>
@@ -190,15 +284,28 @@ function App() {
: <ul className="known-list"> : <ul className="known-list">
{trusted.map(p => ( {trusted.map(p => (
<li key={p.id} className="known-row"> <li key={p.id} className="known-row">
<div className="known-info"> <div className="known-top">
<span className="known-label">{p.label}</span> <div className="known-info">
<span className="peer-id">{p.id.slice(0, 16)}</span> <span className="known-label">
</div> <span
<div className="known-actions"> className={`presence-dot ${presence[p.id] === true ? 'presence-online' : presence[p.id] === false ? 'presence-offline' : 'presence-unknown'}`}
<button className="btn btn-primary btn-sm" onClick={() => connectToKnown(p)}>Connect</button> title={presence[p.id] === true ? 'Online' : presence[p.id] === false ? 'Offline' : 'Unknown'}
<button className="btn btn-sm" onClick={() => renameKnown(p)}>Rename</button> />
<button className="btn btn-sm btn-danger" onClick={() => forgetKnown(p)}>Forget</button> {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> </div>
<button className="btn btn-primary known-connect" onClick={() => connectToKnown(p)}>Connect</button>
</li> </li>
))} ))}
</ul> </ul>
@@ -218,6 +325,29 @@ function App() {
/> />
<button className="btn" onClick={() => joinFromInvite(inviteInput)}>Join</button> <button className="btn" onClick={() => joinFromInvite(inviteInput)}>Join</button>
</div> </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>
)} )}
</div> </div>
@@ -251,7 +381,10 @@ function App() {
</span> </span>
{peerId && <span className="peer-id">{peerId.slice(0, 16)}</span>} {peerId && <span className="peer-id">{peerId.slice(0, 16)}</span>}
</div> </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"> <label className="dropzone">
Tap to choose file(s) to send Tap to choose file(s) to send

View File

@@ -5,6 +5,7 @@ export interface TrustedPeer {
id: string // hex Ed25519 pubkey id: string // hex Ed25519 pubkey
label: string label: string
addedAt: number addedAt: number
autoAccept?: boolean
} }
const KEY = 'flit_keyring' const KEY = 'flit_keyring'
@@ -19,6 +20,11 @@ export function addTrusted(id: string, label: string): void {
localStorage.setItem(KEY, JSON.stringify(peers)) 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 { export function removeTrusted(id: string): void {
localStorage.setItem(KEY, JSON.stringify(listTrusted().filter(p => p.id !== id))) 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 { export function isTrusted(id: string): boolean {
return listTrusted().some(p => p.id === id) 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) 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 { function concat(...arrs: Uint8Array[]): Uint8Array {
const n = arrs.reduce((a, b) => a + b.length, 0) const n = arrs.reduce((a, b) => a + b.length, 0)
const out = new Uint8Array(n); let o = 0 const out = new Uint8Array(n); let o = 0
@@ -67,6 +79,38 @@ export async function netHash(name: string): Promise<string> {
throw new Error('SHA-256 not available in this browser runtime') throw new Error('SHA-256 not available in this browser runtime')
} }
// queryPresence asks the anchor whether `id` is currently connected in
// network `net` (waste-go EXT-009). Opens a short-lived WS connection —
// no join, no identity signature required, since the query is anchor-local
// and scoped to a (net, id) pair the caller must already know. Resolves
// `null` on timeout/error (treat the same as "unknown", not "offline").
export function queryPresence(url: string, net: string, id: string, timeoutMs = 4000): Promise<boolean | null> {
return new Promise((resolve) => {
let settled = false
const ws = new WebSocket(url)
const finish = (result: boolean | null) => {
if (settled) return
settled = true
clearTimeout(timer)
try { ws.close() } catch { /* ignore */ }
resolve(result)
}
const timer = setTimeout(() => finish(null), timeoutMs)
ws.onopen = () => {
try { ws.send(JSON.stringify({ type: 'presence_query', net, id })) } catch { finish(null) }
}
ws.onmessage = (ev) => {
let m: Record<string, unknown>
try { m = JSON.parse(ev.data) } catch { return }
if (m['type'] === 'presence' && m['id'] === id && m['net'] === net) {
finish(Boolean(m['online']))
}
}
ws.onerror = () => finish(null)
ws.onclose = () => finish(null)
})
}
// ── Identity ───────────────────────────────────────────────────────────────── // ── Identity ─────────────────────────────────────────────────────────────────
export class Identity { export class Identity {
@@ -250,25 +294,31 @@ export class PeerConn {
private _offerPending = false private _offerPending = false
private _offered = 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 _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 identity: Identity
private sig: Signaling 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 peerId: string
private events: Partial<PeerEvents> private events: Partial<PeerEvents>
constructor( constructor(
identity: Identity, identity: Identity,
sig: Signaling, sig: Signaling,
peerId: string, signalingId: string,
cryptoId: string,
events: Partial<PeerEvents>, events: Partial<PeerEvents>,
ice: RTCIceServer[], ice: RTCIceServer[],
) { ) {
this.identity = identity this.identity = identity
this.sig = sig this.sig = sig
this.peerId = peerId this._signalingId = signalingId
this.peerId = cryptoId
this.events = events this.events = events
this.pc = new RTCPeerConnection({ iceServers: ice }) this.pc = new RTCPeerConnection({ iceServers: ice })
const kp = sodium.crypto_box_keypair() const kp = sodium.crypto_box_keypair()
@@ -303,7 +353,7 @@ export class PeerConn {
this._ekeySent = true this._ekeySent = true
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.identity.id), sodium.from_hex(this.peerId), this._epk) 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)) } 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>) { private async _onEkey(obj: Record<string, unknown>) {
@@ -333,7 +383,7 @@ export class PeerConn {
this._wire(this.dc) this._wire(this.dc)
await this.pc.setLocalDescription(await this.pc.createOffer()) await this.pc.setLocalDescription(await this.pc.createOffer())
await gatherComplete(this.pc) 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) { async onBox(box: string) {
@@ -349,7 +399,7 @@ export class PeerConn {
await this.pc.setRemoteDescription({ type: 'offer', sdp: obj['sdp'] as string }) await this.pc.setRemoteDescription({ type: 'offer', sdp: obj['sdp'] as string })
await this.pc.setLocalDescription(await this.pc.createAnswer()) await this.pc.setLocalDescription(await this.pc.createAnswer())
await gatherComplete(this.pc) 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') { } else if (obj['kind'] === 'answer') {
await this.pc.setRemoteDescription({ type: 'answer', sdp: obj['sdp'] as string }) await this.pc.setRemoteDescription({ type: 'answer', sdp: obj['sdp'] as string })
} }
@@ -376,17 +426,32 @@ export class PeerConn {
} }
channel.onclose = () => { channel.onclose = () => {
if (rx.cancelled) { delete this._recv[xid]; return } if (rx.cancelled) { delete this._recv[xid]; return }
const blob = new Blob(rx.buf) rx.dataComplete = true
const url = URL.createObjectURL(blob) // If file-done already arrived before DC closed, finalize now.
this.events.fileRecv?.({ peer: this.peerId, name: rx.name, size: rx.size, url }) if (rx.expectedSHA256 !== undefined) void this._finalizeRecv(xid)
delete this._recv[xid]
} }
this._recvChannels[xid] = channel this._recvChannels[xid] = channel
} }
} }
private _sendHello() { private _sendHello() {
this._dc({ type: 'hello', id: this.identity.id, sig: sodium.to_hex(this.identity.sign(enc(BIND_PREFIX))) }) // §6: sign prefix || local_fp || remote_fp when both are extractable.
// Best-effort, not required — different WebRTC stacks report the DTLS
// certificate hash under different algorithms (WebKit: sha-512,
// pion/Chromium: sha-256), so a legitimate cross-stack peer may have no
// fingerprint we know how to parse. Trust rests on the sealed signaling
// exchange that already authenticated peerId (peerAuthed, set in onBox)
// plus hello.id matching it, not on this signature — see _onControl.
const msg: Record<string, string> = { type: 'hello', id: this.identity.id }
try {
const lfp = dtlsFP(this.pc.localDescription!.sdp)
const rfp = dtlsFP(this.pc.remoteDescription!.sdp)
const bindMsg = concat(enc(BIND_PREFIX), lfp, rfp)
msg['sig'] = sodium.to_hex(this.identity.sign(bindMsg))
} catch {
// no parseable fingerprint on one or both sides — hello still goes out
}
this._dc(msg)
} }
private _onControl(data: string) { private _onControl(data: string) {
@@ -394,13 +459,37 @@ export class PeerConn {
try { m = JSON.parse(data) } catch { return } try { m = JSON.parse(data) } catch { return }
if (m['type'] === 'hello') { if (m['type'] === 'hello') {
this.verified = m['id'] === this.peerId && this.peerAuthed const helloId = m['id'] as string
// Trust rests on the sealed signaling exchange that already
// authenticated peerId (peerAuthed, set in onBox) plus hello.id
// matching it — not on the fingerprint-bound signature below, which
// is only checkable when both sides report their DTLS cert hash
// under an algorithm we can parse. See _sendHello for why that
// can't be a hard requirement.
this.verified = helloId === this.peerId && this.peerAuthed
const sigHex = m['sig'] as string | undefined
if (sigHex) {
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 helloSig = sodium.from_hex(sigHex)
if (!Identity.verify(helloId, bindMsg, helloSig)) {
console.warn(`flit: hello: fingerprint-bind signature did not verify for ${helloId} (informational only)`)
}
} catch {
// no parseable fingerprint on one or both sides — nothing to check
}
}
this.events.connected?.(this.verified, this.peerId) this.events.connected?.(this.verified, this.peerId)
} else if (m['type'] === 'file-offer') { } 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 }) 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') { } else if (m['type'] === 'file-accept') {
const xid = m['xid'] as string 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') { } else if (m['type'] === 'file-cancel') {
const xid = m['xid'] as string const xid = m['xid'] as string
if (this._recv[xid]) { if (this._recv[xid]) {
@@ -409,9 +498,35 @@ export class PeerConn {
delete this._recvChannels[xid] delete this._recvChannels[xid]
this.events.fileCancelled?.(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) { acceptOffer(xid: string, name: string, size: number) {
this._recv[xid] = { name, size, buf: [], have: 0 } this._recv[xid] = { name, size, buf: [], have: 0 }
this._dc({ type: 'file-accept', xid }) this._dc({ type: 'file-accept', xid })
@@ -421,31 +536,34 @@ export class PeerConn {
this._dc({ type: 'file-cancel', xid }) 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)}` const xid = `push-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
this._pushQueue.set(xid, file) const buf = await file.arrayBuffer()
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid }) 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) { private async _stream(xid: string, resumeOffset = 0) {
const file = this._pushQueue.get(xid) const entry = this._pushQueue.get(xid)
if (!file) return if (!entry) return
this._pushQueue.delete(xid) this._pushQueue.delete(xid)
const { file, sha256: hash, buf: fullBuf } = entry
const dc = this.pc.createDataChannel(`f:${xid}`) const dc = this.pc.createDataChannel(`f:${xid}`)
dc.binaryType = 'arraybuffer' dc.binaryType = 'arraybuffer'
await new Promise<void>(res => { dc.onopen = () => res() }) await new Promise<void>(res => { dc.onopen = () => res() })
const buf = await file.arrayBuffer() const buf = resumeOffset > 0 ? fullBuf.slice(resumeOffset) : fullBuf
let offset = 0 let localOffset = 0
while (offset < buf.byteLength) { while (localOffset < buf.byteLength) {
while (dc.bufferedAmount > 1024 * 1024) await new Promise(r => setTimeout(r, 10)) while (dc.bufferedAmount > 1024 * 1024) await new Promise(r => setTimeout(r, 10))
dc.send(buf.slice(offset, offset + CHUNK)) dc.send(buf.slice(localOffset, localOffset + CHUNK))
offset += CHUNK localOffset += CHUNK
this.events.fileSendProgress?.({ peer: this.peerId, xid, name: file.name, sent: offset, total: buf.byteLength }) this.events.fileSendProgress?.({ peer: this.peerId, xid, name: file.name, sent: resumeOffset + localOffset, total: file.size })
// 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.
await new Promise(r => setTimeout(r, 0)) await new Promise(r => setTimeout(r, 0))
} }
// §9: send file-done before closing the file DC so the control channel
// is still healthy when the message is dispatched.
this._dc({ type: 'file-done', xid, sha256: hash })
dc.close() dc.close()
this.events.fileSent?.({ peer: this.peerId, xid, name: file.name }) this.events.fileSent?.({ peer: this.peerId, xid, name: file.name })
} }
@@ -488,27 +606,34 @@ export class FlitSession {
this.sig = sig this.sig = sig
const ice = await iceServers(this.cfg) const ice = await iceServers(this.cfg)
const connectTo = async (pid: string) => { const connectTo = async (signalingId: string) => {
if (pid === this.identity.id) return if (signalingId === this.identity.id) return
if (trustedPeerId && pid !== trustedPeerId) return
if (this.peer) return if (this.peer) return
this.peer = new PeerConn(this.identity, sig, pid, events, ice) // cryptoId is the real Ed25519 identity used for seal/open and hello.
if (this.identity.id < pid) await this.peer.startOffer() // 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( const present = await sig.connect(
(from, box) => { void this._onFrom(from, box, events, ice, trustedPeerId) }, (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 */ }, () => { /* 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) { private async _onFrom(from: string, box: string, events: Partial<PeerEvents>, ice: RTCIceServer[], trustedPeerId?: string) {
if (from === this.identity.id) return if (from === this.identity.id) return
if (trustedPeerId && from !== trustedPeerId) return if (!this.peer) {
if (!this.peer) this.peer = new PeerConn(this.identity, this.sig!, from, events, ice) const cryptoId = trustedPeerId ?? from
this.peer = new PeerConn(this.identity, this.sig!, from, cryptoId, events, ice)
}
await this.peer.onBox(box) await this.peer.onBox(box)
} }