Scaffold flit: PWA + Go CLI for ephemeral E2E file transfer
Ports the yaw/2.1 identity/signaling/WebRTC transport from waste-go to both a browser PWA (with QR pairing and Web Share Target) and a headless Go CLI, trimmed to 1:1 ephemeral file transfer only. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
cli/flit
|
||||
*.local
|
||||
96
PLAN.md
Normal file
96
PLAN.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# flit
|
||||
|
||||
Self-hosted, ephemeral, end-to-end encrypted file transfer between your own
|
||||
devices — regardless of architecture. Think "AirDrop, but self-hosted and
|
||||
cross-platform," built on the WebRTC/Ed25519 plumbing already proven out in
|
||||
waste-go.
|
||||
|
||||
## Why
|
||||
|
||||
[Zipline](https://github.com/diced/zipline) already covers persistent,
|
||||
link-based "upload once, share via URL" — good for sharing *to others* or
|
||||
storing semi-long-term. This tool is different: ad hoc, point-to-point
|
||||
transfer between *your own* devices (dev box → homelab box → laptop →
|
||||
phone), without anything landing on a server's disk if it can be avoided.
|
||||
|
||||
Two use cases to design for:
|
||||
1. **Desktop ↔ desktop** — browser tab or small CLI on each end.
|
||||
2. **Desktop ↔ mobile (Android)** — the share-sheet flow is the whole point.
|
||||
|
||||
No iOS constraint — Android + desktop only. This drops a meaningful chunk of
|
||||
complexity (no Safari/PWA quirks, no manual-code fallback needed for pairing).
|
||||
|
||||
## How
|
||||
|
||||
You can take a look at ../waste-go for inspiration, it does parts of this already.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No persistent storage, no accounts, no link sharing with third parties.
|
||||
- Not a Zipline replacement — strictly "get a file from my hand to my other
|
||||
hand."
|
||||
|
||||
|
||||
## Architecture
|
||||
|
||||
**Transport**: WebRTC data channels for the actual file bytes, P2P whenever
|
||||
NAT traversal allows it.
|
||||
|
||||
- A lightweight **signaling server** (self-hosted, homelab) exchanges
|
||||
SDP/ICE candidates between peers. Stateless, ephemeral rooms, no file data
|
||||
ever touches it.
|
||||
- A **TURN relay fallback** (coturn, self-hosted) for when direct P2P fails
|
||||
— common on mobile networks/CGNAT. Data stays E2E encrypted across the
|
||||
relay, so it just shovels encrypted bytes.
|
||||
|
||||
**Identity & security**: reuse the Ed25519 keypair model from waste-go. Each
|
||||
device has a long-lived identity key; pairing two devices derives a session
|
||||
key (X25519 ECDH) so transfers are E2E encrypted even across the TURN relay.
|
||||
No accounts, no passwords.
|
||||
|
||||
**Pairing UX**: QR code as the default everywhere, since there's no need to
|
||||
support a browser that handles it poorly (no iOS).
|
||||
|
||||
- Generate an ephemeral room code / QR code on the sending device.
|
||||
- Scan it on the receiving device to join the WebRTC session.
|
||||
- Once paired, drag-and-drop (desktop) or native share sheet (Android) to
|
||||
send.
|
||||
|
||||
**Clients**:
|
||||
|
||||
- **PWA** — covers desktop browsers and Android. Installable on Android home
|
||||
screen, registered as a **Web Share Target** so "Share → flit" works
|
||||
directly from any app's OS share sheet (Photos, Files, browser downloads,
|
||||
anything). This is the primary mobile flow, not a fallback.
|
||||
- **Go CLI** — for headless machines (homelab boxes, servers without a GUI),
|
||||
reusing waste-go's transport/identity code directly. `flit send file.tar.gz`
|
||||
prints a code/QR for the other end to scan.
|
||||
|
||||
## Build plan
|
||||
|
||||
1. **Scaffold the repo.** Reuse waste-go's Ed25519 identity + WebRTC
|
||||
handshake code as a starting library if cleanly extractable; otherwise
|
||||
port the logic.
|
||||
2. **Signaling server** — minimal Go service, WebSocket-based room exchange
|
||||
(peer A creates room, gets code; peer B joins with code; server relays
|
||||
SDP/ICE only, then gets out of the way). Deploy alongside Zipline in the
|
||||
homelab.
|
||||
3. **PWA client** — file picker + drag-drop, QR code generation/scanning
|
||||
(camera access for scan), Web Share Target manifest entry, WebRTC data
|
||||
channel transfer with progress UI.
|
||||
4. **Go CLI** — thin wrapper sharing core logic with the
|
||||
signaling/transport layer, for headless homelab boxes.
|
||||
5. **TURN fallback** — coturn instance, only invoked when direct P2P ICE
|
||||
candidates fail.
|
||||
6. **Stretch**: chunked transfer + resume for large files over flaky mobile
|
||||
connections; multi-file/folder zip-on-the-fly.
|
||||
|
||||
## Open question
|
||||
|
||||
Host the PWA under goonk.se (e.g. `/flit`, alongside Pitwall/Delve as a
|
||||
public-facing sub-site with a project badge) or keep it standalone on a
|
||||
homelab subdomain as a personal tool rather than a portfolio piece? This
|
||||
decides whether the implementing work also touches the goonk.se repo's
|
||||
project content collection.
|
||||
|
||||
|
||||
49
PROTOCOL.md
Normal file
49
PROTOCOL.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# flit protocol
|
||||
|
||||
flit speaks a trimmed subset of YAW/2.1 (forward-secret signaling) — see
|
||||
`../waste-go/PROTOCOL.md` for the full spec. This file only notes where flit
|
||||
diverges.
|
||||
|
||||
## What's reused as-is
|
||||
|
||||
- Ed25519 device identity; `id = hex(pubkey)`.
|
||||
- Signaling: WebSocket join/challenge, sealed `to`/`from` relay (§5).
|
||||
- yaw/2.1 ephemeral-key (`ekey`) forward-secret signaling, falling back to
|
||||
static (yaw/2.0) seal if the peer doesn't send one (§6′, §6.1).
|
||||
- The `hello` identity-confirm bind over DTLS fingerprints (§6).
|
||||
- File transfer: `file-offer` / `file-accept` / `file-cancel`, chunked
|
||||
64 KiB binary DataChannel labeled `f:<xid>` (§9).
|
||||
|
||||
## What's dropped
|
||||
|
||||
flit has no chat, presence, multi-peer mesh, or file browsing — every
|
||||
session is exactly one peer, ends after one transfer (or a cancel/close).
|
||||
`chat`, `pm`, `reaction`, `peer_gossip`, `browse`/`get`/`files` message
|
||||
types are not implemented.
|
||||
|
||||
## What's flit-specific
|
||||
|
||||
- **Room naming**: flit never uses a human-chosen network name. Rooms are
|
||||
16 random bytes (128 bits), hex-encoded, generated fresh per pairing —
|
||||
`net = sha256("yaw2-net:" + random_hex)`. This is deliberately different
|
||||
from yaw2's named-network convention: a flit room must not be guessable or
|
||||
brute-forceable from the anchor's point of view, since pairing is the only
|
||||
trust mechanism (no keyring-gated network name to fall back on for an
|
||||
ephemeral session).
|
||||
- **Invite encoding**: `flit:<base64url(json)>` where the JSON is
|
||||
`{"anchor": "<wss url>", "room": "<hex>"}`. Distinct from waste-go's
|
||||
`waste:` invite format (which carries a network *name*, not a random room).
|
||||
See `pwa/src/pairing/ephemeral.ts` and `cli/cmd/flit/main.go`.
|
||||
- **Pairing trust**: two independent flows —
|
||||
- *Ephemeral* (above): trust is "whoever shows up in this freshly
|
||||
generated, never-reused room," confirmed by the `hello` signature.
|
||||
- *Persistent keyring* (`pwa/src/pairing/keyring.ts`): optionally
|
||||
remember a peer's `id` after a verified session, for repeat pairing
|
||||
without re-scanning. Not yet wired into the join flow to pin sessions
|
||||
to a specific id — currently advisory/UI-only.
|
||||
- **TURN credentials**: short-lived, minted server-side by the anchor's
|
||||
`GET /turn-credentials` (coturn `use-auth-secret` HMAC; see
|
||||
`waste-go/cmd/anchor/main.go`). The PWA fetches from there. The CLI
|
||||
computes the HMAC locally from `FLIT_TURN_SECRET` env (acceptable for a
|
||||
native binary — unlike browser JS, it isn't visible to anyone who opens
|
||||
the page source).
|
||||
191
cli/cmd/flit/main.go
Normal file
191
cli/cmd/flit/main.go
Normal file
@@ -0,0 +1,191 @@
|
||||
// flit — headless CLI for ephemeral, E2E-encrypted file transfer.
|
||||
//
|
||||
// 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
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
|
||||
"flit/internal/transport"
|
||||
)
|
||||
|
||||
const defaultSignalURL = "wss://waste.dev.xplwd.com/ws"
|
||||
const defaultTurnURL = "turn:waste.dev.xplwd.com:3478"
|
||||
|
||||
// invite mirrors pwa/src/pairing/ephemeral.ts's "flit:" invite format —
|
||||
// keep them in sync.
|
||||
type invite struct {
|
||||
Anchor string `json:"anchor"`
|
||||
Room string `json:"room"`
|
||||
}
|
||||
|
||||
func randomRoomName() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func encodeInvite(anchor, room string) string {
|
||||
data, _ := json.Marshal(invite{Anchor: anchor, Room: room})
|
||||
return "flit:" + base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
func decodeInvite(s string) (*invite, error) {
|
||||
const prefix = "flit:"
|
||||
if len(s) < len(prefix) || s[:len(prefix)] != prefix {
|
||||
return nil, fmt.Errorf("not a flit invite")
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(s[len(prefix):])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var inv invite
|
||||
if err := json.Unmarshal(raw, &inv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if inv.Anchor == "" || inv.Room == "" {
|
||||
return nil, fmt.Errorf("invite missing anchor or room")
|
||||
}
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
func dataDir() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
dir := home + "/.flit"
|
||||
_ = os.MkdirAll(dir, 0700)
|
||||
return dir
|
||||
}
|
||||
|
||||
func cfg() transport.Config {
|
||||
c := transport.Config{SignalURL: defaultSignalURL, TurnURL: defaultTurnURL}
|
||||
// TODO: same stopgap as the PWA — TurnSecret should come from a
|
||||
// server-minted credential, not a local secret, once that flow is
|
||||
// wired for native clients too. For now leave TurnSecret unset; flit
|
||||
// falls back to STUN-only (works whenever direct P2P is reachable).
|
||||
if s := os.Getenv("FLIT_TURN_SECRET"); s != "" {
|
||||
c.TurnSecret = s
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("usage: flit send <path> | flit recv <invite>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
switch os.Args[1] {
|
||||
case "send":
|
||||
send(ctx, os.Args[2])
|
||||
case "recv":
|
||||
recv(ctx, os.Args[2])
|
||||
default:
|
||||
fmt.Println("usage: flit send <path> | flit recv <invite>")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func send(ctx context.Context, path string) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "flit:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
c := cfg()
|
||||
sess, err := transport.NewSession(dataDir(), c)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "flit:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
room := randomRoomName()
|
||||
inv := encodeInvite(c.SignalURL, room)
|
||||
fmt.Println("Scan this on the receiving device:")
|
||||
qrterminal.GenerateHalfBlock(inv, qrterminal.L, os.Stdout)
|
||||
fmt.Println(inv)
|
||||
|
||||
done := make(chan struct{})
|
||||
sess.OnConnected = func(verified bool) {
|
||||
status := "unverified"
|
||||
if verified {
|
||||
status = "verified"
|
||||
}
|
||||
fmt.Printf("peer connected (%s): %s\n", status, sess.PeerID())
|
||||
if err := sess.SendFile(path); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "flit: send failed:", err)
|
||||
} else {
|
||||
fmt.Println("transfer complete")
|
||||
}
|
||||
close(done)
|
||||
}
|
||||
|
||||
if err := sess.Join(ctx, room, ""); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "flit:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
func recv(ctx context.Context, inviteStr string) {
|
||||
inv, err := decodeInvite(inviteStr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "flit:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
c := cfg()
|
||||
c.SignalURL = inv.Anchor
|
||||
sess, err := transport.NewSession(dataDir(), c)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "flit:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
done := make(chan struct{})
|
||||
sess.OnConnected = func(verified bool) {
|
||||
status := "unverified"
|
||||
if verified {
|
||||
status = "verified"
|
||||
}
|
||||
fmt.Printf("peer connected (%s): %s — waiting for file offer\n", status, sess.PeerID())
|
||||
}
|
||||
sess.OnFileOffer = func(offer transport.FileOffer) {
|
||||
fmt.Printf("incoming file: %s (%d bytes) — accepting\n", offer.Name, offer.Size)
|
||||
sess.AcceptOffer(offer, cwd)
|
||||
}
|
||||
sess.OnFileProgress = func(xid string, received, total int64) {
|
||||
fmt.Printf("\rreceiving: %d/%d bytes", received, total)
|
||||
}
|
||||
sess.OnFileDone = func(name, path string) {
|
||||
fmt.Printf("\nsaved %s\n", path)
|
||||
close(done)
|
||||
}
|
||||
|
||||
if err := sess.Join(ctx, inv.Room, ""); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "flit:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
39
cli/go.mod
Normal file
39
cli/go.mod
Normal file
@@ -0,0 +1,39 @@
|
||||
module flit
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0
|
||||
github.com/mdp/qrterminal/v3 v3.2.1
|
||||
github.com/pion/webrtc/v3 v3.3.6
|
||||
golang.org/x/crypto v0.53.0
|
||||
nhooyr.io/websocket v1.8.17
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/google/uuid v1.3.1 // indirect
|
||||
github.com/pion/datachannel v1.5.8 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||
github.com/pion/ice/v2 v2.3.38 // indirect
|
||||
github.com/pion/interceptor v0.1.29 // indirect
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
github.com/pion/mdns v0.0.12 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.14 // indirect
|
||||
github.com/pion/rtp v1.8.7 // indirect
|
||||
github.com/pion/sctp v1.8.19 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.9 // indirect
|
||||
github.com/pion/srtp/v2 v2.0.20 // indirect
|
||||
github.com/pion/stun v0.6.1 // indirect
|
||||
github.com/pion/transport/v2 v2.2.10 // indirect
|
||||
github.com/pion/turn/v2 v2.1.6 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/testify v1.9.0 // indirect
|
||||
github.com/wlynxg/anet v0.0.3 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/term v0.44.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
)
|
||||
138
cli/go.sum
Normal file
138
cli/go.sum
Normal file
@@ -0,0 +1,138 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||
github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=
|
||||
github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI=
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
|
||||
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
|
||||
github.com/pion/ice/v2 v2.3.38 h1:DEpt13igPfvkE2+1Q+6e8mP30dtWnQD3CtMIKoRDRmA=
|
||||
github.com/pion/ice/v2 v2.3.38/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ=
|
||||
github.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M=
|
||||
github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4=
|
||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
|
||||
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
|
||||
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||
github.com/pion/rtp v1.8.7 h1:qslKkG8qxvQ7hqaxkmL7Pl0XcUm+/Er7nMnu6Vq+ZxM=
|
||||
github.com/pion/rtp v1.8.7/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||
github.com/pion/sctp v1.8.19 h1:2CYuw+SQ5vkQ9t0HdOPccsCz1GQMDuVy5PglLgKVBW8=
|
||||
github.com/pion/sctp v1.8.19/go.mod h1:P6PbDVA++OJMrVNg2AL3XtYHV4uD6dvfyOovCgMs0PE=
|
||||
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
|
||||
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
|
||||
github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk=
|
||||
github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
|
||||
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
|
||||
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
|
||||
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
||||
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
|
||||
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
|
||||
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
||||
github.com/pion/transport/v3 v3.0.2 h1:r+40RJR25S9w3jbA6/5uEPTzcdn7ncyU44RWCbHkLg4=
|
||||
github.com/pion/transport/v3 v3.0.2/go.mod h1:nIToODoOlb5If2jF9y2Igfx3PFYWfuXi37m0IlWa/D0=
|
||||
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
|
||||
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/webrtc/v3 v3.3.6 h1:7XAh4RPtlY1Vul6/GmZrv7z+NnxKA6If0KStXBI2ZLE=
|
||||
github.com/pion/webrtc/v3 v3.3.6/go.mod h1:zyN7th4mZpV27eXybfR/cnUf3J2DRy8zw/mdjD9JTNM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
|
||||
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
186
cli/internal/crypto/crypto.go
Normal file
186
cli/internal/crypto/crypto.go
Normal file
@@ -0,0 +1,186 @@
|
||||
// Package crypto implements flit's identity and signaling crypto: Ed25519
|
||||
// device identity, X25519 ECDH (static, derived from Ed25519; and ephemeral,
|
||||
// yaw/2.1 forward-secret signaling), and the nacl/box signaling seal.
|
||||
// Ported and trimmed from waste-go/internal/crypto — dropped chat/backup
|
||||
// helpers not needed for 1:1 file transfer.
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"filippo.io/edwards25519"
|
||||
"golang.org/x/crypto/curve25519"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
// ── Identity ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Identity struct {
|
||||
privateKey ed25519.PrivateKey
|
||||
PublicKey ed25519.PublicKey
|
||||
}
|
||||
|
||||
type identityFile struct {
|
||||
PrivateKeyB64 string `json:"private_key"`
|
||||
}
|
||||
|
||||
// LoadOrCreate loads the identity from dataDir/identity.json, or generates a
|
||||
// fresh keypair if none exists yet.
|
||||
func LoadOrCreate(dataDir string) (*Identity, error) {
|
||||
if err := os.MkdirAll(dataDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("creating data dir: %w", err)
|
||||
}
|
||||
path := filepath.Join(dataDir, "identity.json")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
var f identityFile
|
||||
if err := json.Unmarshal(data, &f); err != nil {
|
||||
return nil, fmt.Errorf("parsing identity file: %w", err)
|
||||
}
|
||||
privBytes, err := b64.DecodeString(f.PrivateKeyB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding private key: %w", err)
|
||||
}
|
||||
priv := ed25519.PrivateKey(privBytes)
|
||||
return &Identity{privateKey: priv, PublicKey: priv.Public().(ed25519.PublicKey)}, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("reading identity file: %w", err)
|
||||
}
|
||||
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating keypair: %w", err)
|
||||
}
|
||||
f := identityFile{PrivateKeyB64: b64.EncodeToString(priv)}
|
||||
raw, _ := json.MarshalIndent(f, "", " ")
|
||||
if err := os.WriteFile(path, raw, 0600); err != nil {
|
||||
return nil, fmt.Errorf("saving identity: %w", err)
|
||||
}
|
||||
return &Identity{privateKey: priv, PublicKey: pub}, nil
|
||||
}
|
||||
|
||||
// PeerID returns the lowercase hex encoding of the 32-byte Ed25519 public key.
|
||||
func (id *Identity) PeerID() string { return hex.EncodeToString(id.PublicKey) }
|
||||
|
||||
func (id *Identity) Sign(data []byte) string {
|
||||
return hex.EncodeToString(ed25519.Sign(id.privateKey, data))
|
||||
}
|
||||
|
||||
func Verify(publicKeyHex string, data []byte, sigHex string) error {
|
||||
pubBytes, err := hex.DecodeString(publicKeyHex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding public key: %w", err)
|
||||
}
|
||||
sigBytes, err := hex.DecodeString(sigHex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding signature: %w", err)
|
||||
}
|
||||
if !ed25519.Verify(ed25519.PublicKey(pubBytes), data, sigBytes) {
|
||||
return errors.New("signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CurvePublicKey returns the X25519 public key derived from this Ed25519 identity.
|
||||
func (id *Identity) CurvePublicKey() *[32]byte {
|
||||
edPoint, _ := new(edwards25519.Point).SetBytes(id.PublicKey)
|
||||
mont := edPoint.BytesMontgomery()
|
||||
var out [32]byte
|
||||
copy(out[:], mont)
|
||||
return &out
|
||||
}
|
||||
|
||||
// CurvePrivateKey returns the X25519 private key derived from this Ed25519 identity.
|
||||
func (id *Identity) CurvePrivateKey() *[32]byte {
|
||||
h := sha512.Sum512(id.privateKey[:32])
|
||||
h[0] &= 248
|
||||
h[31] &= 127
|
||||
h[31] |= 64
|
||||
var out [32]byte
|
||||
copy(out[:], h[:32])
|
||||
return &out
|
||||
}
|
||||
|
||||
// CurveFromPeerID derives the X25519 public key from a peer's hex Ed25519 id.
|
||||
func CurveFromPeerID(idHex string) (*[32]byte, error) {
|
||||
pubBytes, err := hex.DecodeString(idHex)
|
||||
if err != nil || len(pubBytes) != 32 {
|
||||
return nil, fmt.Errorf("invalid peer id %q", idHex)
|
||||
}
|
||||
edPoint, err := new(edwards25519.Point).SetBytes(pubBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ed25519 point: %w", err)
|
||||
}
|
||||
mont := edPoint.BytesMontgomery()
|
||||
var out [32]byte
|
||||
copy(out[:], mont)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ── Signaling seal (nacl/box, base64 std-padded) ──────────────────────────────
|
||||
|
||||
func SignalingBox(plaintext []byte, recipientPub, senderPriv *[32]byte) string {
|
||||
var nonce [24]byte
|
||||
rand.Read(nonce[:]) //nolint:errcheck
|
||||
ct := box.Seal(nonce[:], plaintext, &nonce, recipientPub, senderPriv)
|
||||
return base64.StdEncoding.EncodeToString(ct)
|
||||
}
|
||||
|
||||
func SignalingOpen(b64box string, senderPub, recipientPriv *[32]byte) ([]byte, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(b64box)
|
||||
if err != nil || len(raw) < 24 {
|
||||
return nil, errors.New("invalid box")
|
||||
}
|
||||
var nonce [24]byte
|
||||
copy(nonce[:], raw[:24])
|
||||
out, ok := box.Open(nil, raw[24:], &nonce, senderPub, recipientPriv)
|
||||
if !ok {
|
||||
return nil, errors.New("box open failed")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── X25519 ephemeral keys (yaw/2.1 forward secrecy) ───────────────────────────
|
||||
|
||||
type EphemeralKey struct {
|
||||
private [32]byte
|
||||
public [32]byte
|
||||
}
|
||||
|
||||
func GenerateEphemeral() (*EphemeralKey, error) {
|
||||
ek := &EphemeralKey{}
|
||||
if _, err := rand.Read(ek.private[:]); err != nil {
|
||||
return nil, fmt.Errorf("generating ephemeral key: %w", err)
|
||||
}
|
||||
ek.private[0] &= 248
|
||||
ek.private[31] &= 127
|
||||
ek.private[31] |= 64
|
||||
pub, err := curve25519.X25519(ek.private[:], curve25519.Basepoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computing public key: %w", err)
|
||||
}
|
||||
copy(ek.public[:], pub)
|
||||
return ek, nil
|
||||
}
|
||||
|
||||
func (ek *EphemeralKey) PublicRaw() *[32]byte { return &ek.public }
|
||||
func (ek *EphemeralKey) PrivateRaw() *[32]byte { return &ek.private }
|
||||
|
||||
func (ek *EphemeralKey) Wipe() {
|
||||
for i := range ek.private {
|
||||
ek.private[i] = 0
|
||||
}
|
||||
}
|
||||
593
cli/internal/transport/transport.go
Normal file
593
cli/internal/transport/transport.go
Normal file
@@ -0,0 +1,593 @@
|
||||
// Package transport implements flit's yaw/2.1 signaling + WebRTC session for
|
||||
// 1:1 ephemeral file transfer, mirroring pwa/src/transport/flit.ts. Trimmed
|
||||
// from waste-go: no chat, no multi-peer mesh — exactly one peer per session.
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v3"
|
||||
"nhooyr.io/websocket"
|
||||
"nhooyr.io/websocket/wsjson"
|
||||
|
||||
"flit/internal/crypto"
|
||||
)
|
||||
|
||||
const (
|
||||
bindPrefix = "yaw/2 bind"
|
||||
ekeyPrefix = "yaw/2.1 ekey"
|
||||
ekeyTimeout = 2 * time.Second
|
||||
chunkSize = 64 * 1024
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
SignalURL string
|
||||
TurnURL string
|
||||
// TurnSecret holds the coturn use-auth-secret shared secret. Safe to keep
|
||||
// in a native config file (unlike the browser, this never ships to a
|
||||
// client that can view-source it).
|
||||
TurnSecret string
|
||||
}
|
||||
|
||||
func NetHash(name string) string {
|
||||
h := sha256.Sum256([]byte("yaw2-net:" + name))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func iceServers(cfg Config) []webrtc.ICEServer {
|
||||
servers := []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}
|
||||
if cfg.TurnURL != "" && cfg.TurnSecret != "" {
|
||||
expiry := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + ":flit"
|
||||
mac := hmac.New(sha1.New, []byte(cfg.TurnSecret))
|
||||
mac.Write([]byte(expiry))
|
||||
cred := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
servers = append(servers, webrtc.ICEServer{
|
||||
URLs: []string{cfg.TurnURL}, Username: expiry, Credential: cred,
|
||||
CredentialType: webrtc.ICECredentialTypePassword,
|
||||
})
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
// ── Signaling ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type anchorMsg struct {
|
||||
Type string `json:"type"`
|
||||
Nonce string `json:"nonce,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Net string `json:"net,omitempty"`
|
||||
Sig string `json:"sig,omitempty"`
|
||||
Peers []string `json:"peers,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
Box string `json:"box,omitempty"`
|
||||
}
|
||||
|
||||
type signaling struct {
|
||||
conn *websocket.Conn
|
||||
sendCh chan anchorMsg
|
||||
}
|
||||
|
||||
func dialSignaling(ctx context.Context, url string, id *crypto.Identity, netHash string) (*signaling, []string, error) {
|
||||
conn, _, err := websocket.Dial(ctx, url, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("dial: %w", err)
|
||||
}
|
||||
s := &signaling{conn: conn, sendCh: make(chan anchorMsg, 64)}
|
||||
go func() {
|
||||
for msg := range s.sendCh {
|
||||
if err := wsjson.Write(ctx, conn, msg); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
var msg anchorMsg
|
||||
if err := wsjson.Read(ctx, conn, &msg); err != nil {
|
||||
return nil, nil, fmt.Errorf("read: %w", err)
|
||||
}
|
||||
if msg.Type == "challenge" {
|
||||
nonceBytes, err := hex.DecodeString(msg.Nonce)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("bad challenge nonce: %w", err)
|
||||
}
|
||||
sig := id.Sign(append(nonceBytes, []byte(netHash)...))
|
||||
s.sendCh <- anchorMsg{Type: "join", ID: id.PeerID(), Net: netHash, Sig: sig}
|
||||
} else if msg.Type == "joined" {
|
||||
return s, msg.Peers, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signaling) sendTo(to, box string) {
|
||||
select {
|
||||
case s.sendCh <- anchorMsg{Type: "to", To: to, Box: box}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type FileOffer struct {
|
||||
XID string
|
||||
Name string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// Session is a 1:1 ephemeral pairing: join a room, connect to exactly one
|
||||
// peer (optionally pinned to a specific id), exchange files.
|
||||
type Session struct {
|
||||
identity *crypto.Identity
|
||||
cfg Config
|
||||
|
||||
mu sync.Mutex
|
||||
pc *webrtc.PeerConnection
|
||||
dc *webrtc.DataChannel
|
||||
peerID string
|
||||
verified bool
|
||||
esk, epk *[32]byte
|
||||
peerEPK *[32]byte
|
||||
ekeySent bool
|
||||
offered bool
|
||||
|
||||
sig *signaling
|
||||
pendingRecv *recvState
|
||||
|
||||
OnConnected func(verified bool)
|
||||
OnFileOffer func(FileOffer)
|
||||
OnFileProgress func(xid string, received, total int64)
|
||||
OnFileDone func(name string, path string)
|
||||
}
|
||||
|
||||
func NewSession(dataDir string, cfg Config) (*Session, error) {
|
||||
id, err := crypto.LoadOrCreate(dataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Session{identity: id, cfg: cfg}, nil
|
||||
}
|
||||
|
||||
func (s *Session) PeerID() string { return s.identity.PeerID() }
|
||||
|
||||
// Join connects to the signaling server for roomName and waits for exactly
|
||||
// one peer (or the pinned trustedPeerID) to complete the handshake.
|
||||
func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID string) error {
|
||||
hash := NetHash(roomName)
|
||||
sig, present, err := dialSignaling(ctx, s.cfg.SignalURL, s.identity, hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.sig = sig
|
||||
|
||||
for _, pid := range present {
|
||||
if trustedPeerID != "" && pid != trustedPeerID {
|
||||
continue
|
||||
}
|
||||
if err := s.connectTo(ctx, pid); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
var msg anchorMsg
|
||||
if err := wsjson.Read(ctx, sig.conn, &msg); err != nil {
|
||||
return
|
||||
}
|
||||
switch msg.Type {
|
||||
case "peer-join":
|
||||
if trustedPeerID != "" && msg.ID != trustedPeerID {
|
||||
continue
|
||||
}
|
||||
_ = s.connectTo(ctx, msg.ID)
|
||||
case "from":
|
||||
if trustedPeerID != "" && msg.From != trustedPeerID {
|
||||
continue
|
||||
}
|
||||
s.onBox(msg.From, msg.Box)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) connectTo(ctx context.Context, peerID string) error {
|
||||
s.mu.Lock()
|
||||
if s.pc != nil {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.peerID = peerID
|
||||
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers(s.cfg)})
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
s.pc = pc
|
||||
kp, err := crypto.GenerateEphemeral()
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
s.esk, s.epk = kp.PrivateRaw(), kp.PublicRaw()
|
||||
s.mu.Unlock()
|
||||
|
||||
pc.OnDataChannel(func(dc *webrtc.DataChannel) { s.wireDC(dc) })
|
||||
|
||||
s.sendEkey()
|
||||
offerByOrder := s.identity.PeerID() < peerID
|
||||
if offerByOrder {
|
||||
go func() {
|
||||
time.Sleep(ekeyTimeout)
|
||||
s.maybeOffer()
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) sendEkey() {
|
||||
s.mu.Lock()
|
||||
if s.ekeySent {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.ekeySent = true
|
||||
epk := *s.epk
|
||||
s.mu.Unlock()
|
||||
|
||||
signed := append([]byte(ekeyPrefix), mustHex(s.identity.PeerID())...)
|
||||
signed = append(signed, mustHex(s.peerID)...)
|
||||
signed = append(signed, epk[:]...)
|
||||
msg := map[string]string{
|
||||
"kind": "ekey", "v": "yaw/2.1",
|
||||
"epk": hex.EncodeToString(epk[:]),
|
||||
"sig": s.identity.Sign(signed),
|
||||
}
|
||||
s.sealAndSend(msg, false)
|
||||
}
|
||||
|
||||
func (s *Session) maybeOffer() {
|
||||
s.mu.Lock()
|
||||
if s.offered || s.pc == nil {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.offered = true
|
||||
pc := s.pc
|
||||
s.mu.Unlock()
|
||||
|
||||
dc, err := pc.CreateDataChannel("yaw", nil)
|
||||
if err != nil {
|
||||
log.Printf("flit: create datachannel: %v", err)
|
||||
return
|
||||
}
|
||||
s.wireDC(dc)
|
||||
|
||||
offer, err := pc.CreateOffer(nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := pc.SetLocalDescription(offer); err != nil {
|
||||
return
|
||||
}
|
||||
<-gatherComplete(pc)
|
||||
s.mu.Lock()
|
||||
hasEPK := s.peerEPK != nil
|
||||
s.mu.Unlock()
|
||||
s.sealAndSend(map[string]string{"kind": "offer", "sdp": pc.LocalDescription().SDP}, hasEPK)
|
||||
}
|
||||
|
||||
func (s *Session) onBox(from, box string) {
|
||||
plain, usedEph := s.openBox(box)
|
||||
if plain == nil {
|
||||
return
|
||||
}
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(plain, &obj); err != nil {
|
||||
return
|
||||
}
|
||||
switch obj["kind"] {
|
||||
case "ekey":
|
||||
s.onEkey(obj)
|
||||
case "offer":
|
||||
s.mu.Lock()
|
||||
pc := s.pc
|
||||
s.mu.Unlock()
|
||||
if pc == nil {
|
||||
return
|
||||
}
|
||||
_ = pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: obj["sdp"].(string)})
|
||||
answer, err := pc.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := pc.SetLocalDescription(answer); err != nil {
|
||||
return
|
||||
}
|
||||
<-gatherComplete(pc)
|
||||
s.sealAndSend(map[string]string{"kind": "answer", "sdp": pc.LocalDescription().SDP}, usedEph)
|
||||
case "answer":
|
||||
s.mu.Lock()
|
||||
pc := s.pc
|
||||
s.mu.Unlock()
|
||||
if pc == nil {
|
||||
return
|
||||
}
|
||||
_ = pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: obj["sdp"].(string)})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) onEkey(obj map[string]any) {
|
||||
s.mu.Lock()
|
||||
if s.peerEPK != nil {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
epkHex, _ := obj["epk"].(string)
|
||||
sigHex, _ := obj["sig"].(string)
|
||||
epkRaw, err := hex.DecodeString(epkHex)
|
||||
if err != nil || len(epkRaw) != 32 {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
signed := append([]byte(ekeyPrefix), mustHex(s.peerID)...)
|
||||
signed = append(signed, mustHex(s.identity.PeerID())...)
|
||||
signed = append(signed, epkRaw...)
|
||||
if err := crypto.Verify(s.peerID, signed, sigHex); err != nil {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
var epk [32]byte
|
||||
copy(epk[:], epkRaw)
|
||||
s.peerEPK = &epk
|
||||
s.mu.Unlock()
|
||||
|
||||
s.sendEkey()
|
||||
s.maybeOffer()
|
||||
}
|
||||
|
||||
func (s *Session) sealAndSend(obj map[string]string, preferEph bool) {
|
||||
data, _ := json.Marshal(obj)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var sealed string
|
||||
if preferEph && s.peerEPK != nil {
|
||||
sealed = crypto.SignalingBox(data, s.peerEPK, s.esk)
|
||||
} else {
|
||||
recip, err := crypto.CurveFromPeerID(s.peerID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sealed = crypto.SignalingBox(data, recip, s.identity.CurvePrivateKey())
|
||||
}
|
||||
s.sig.sendTo(s.peerID, sealed)
|
||||
}
|
||||
|
||||
func (s *Session) openBox(boxB64 string) ([]byte, bool) {
|
||||
s.mu.Lock()
|
||||
peerEPK, esk := s.peerEPK, s.esk
|
||||
peerID := s.peerID
|
||||
s.mu.Unlock()
|
||||
|
||||
if peerEPK != nil {
|
||||
if pt, err := crypto.SignalingOpen(boxB64, peerEPK, esk); err == nil {
|
||||
return pt, true
|
||||
}
|
||||
}
|
||||
senderCurve, err := crypto.CurveFromPeerID(peerID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
pt, err := crypto.SignalingOpen(boxB64, senderCurve, s.identity.CurvePrivateKey())
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return pt, false
|
||||
}
|
||||
|
||||
// ── control channel + file transfer ───────────────────────────────────────────
|
||||
|
||||
func (s *Session) wireDC(dc *webrtc.DataChannel) {
|
||||
if dc.Label() == "yaw" {
|
||||
s.mu.Lock()
|
||||
s.dc = dc
|
||||
s.mu.Unlock()
|
||||
dc.OnOpen(func() { s.sendHello() })
|
||||
dc.OnMessage(func(msg webrtc.DataChannelMessage) { s.onControl(msg.Data) })
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(dc.Label(), "f:") {
|
||||
s.wireFileRecv(dc)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) sendHello() {
|
||||
s.controlSend(map[string]string{
|
||||
"type": "hello", "id": s.identity.PeerID(),
|
||||
"sig": s.identity.Sign([]byte(bindPrefix)),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Session) onControl(data []byte) {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return
|
||||
}
|
||||
switch m["type"] {
|
||||
case "hello":
|
||||
s.mu.Lock()
|
||||
s.verified = m["id"] == s.peerID
|
||||
s.mu.Unlock()
|
||||
if s.OnConnected != nil {
|
||||
s.OnConnected(s.verified)
|
||||
}
|
||||
case "file-offer":
|
||||
if s.OnFileOffer != nil {
|
||||
size, _ := m["size"].(float64)
|
||||
s.OnFileOffer(FileOffer{XID: m["xid"].(string), Name: m["name"].(string), Size: int64(size)})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) controlSend(obj map[string]string) {
|
||||
s.mu.Lock()
|
||||
dc := s.dc
|
||||
s.mu.Unlock()
|
||||
if dc == nil || dc.ReadyState() != webrtc.DataChannelStateOpen {
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(obj)
|
||||
_ = dc.SendText(string(data))
|
||||
}
|
||||
|
||||
// SendFile offers and streams a file to the connected peer.
|
||||
func (s *Session) SendFile(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
xid := fmt.Sprintf("push-%d", time.Now().UnixNano())
|
||||
name := st.Name()
|
||||
|
||||
s.controlSend(map[string]string{"type": "file-offer", "name": name, "size": fmt.Sprint(st.Size()), "xid": xid})
|
||||
|
||||
s.mu.Lock()
|
||||
pc := s.pc
|
||||
s.mu.Unlock()
|
||||
dc, err := pc.CreateDataChannel("f:"+xid, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opened := make(chan struct{})
|
||||
dc.OnOpen(func() { close(opened) })
|
||||
select {
|
||||
case <-opened:
|
||||
case <-time.After(15 * time.Second):
|
||||
return fmt.Errorf("timed out waiting for file-accept")
|
||||
}
|
||||
|
||||
buf := make([]byte, chunkSize)
|
||||
for {
|
||||
n, err := f.Read(buf)
|
||||
if n > 0 {
|
||||
for dc.BufferedAmount() > 1<<20 {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if err := dc.Send(buf[:n]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return dc.Close()
|
||||
}
|
||||
|
||||
// AcceptOffer accepts an incoming file-offer and writes the transfer to downloadDir.
|
||||
func (s *Session) AcceptOffer(offer FileOffer, downloadDir string) {
|
||||
s.controlSend(map[string]string{"type": "file-accept", "xid": offer.XID})
|
||||
s.mu.Lock()
|
||||
s.pendingRecv = &recvState{offer: offer, dir: downloadDir}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Session) RejectOffer(xid string) {
|
||||
s.controlSend(map[string]string{"type": "file-cancel", "xid": xid})
|
||||
}
|
||||
|
||||
type recvState struct {
|
||||
offer FileOffer
|
||||
dir string
|
||||
have int64
|
||||
f *os.File
|
||||
}
|
||||
|
||||
func (s *Session) wireFileRecv(dc *webrtc.DataChannel) {
|
||||
xid := strings.TrimPrefix(dc.Label(), "f:")
|
||||
s.mu.Lock()
|
||||
rs := s.pendingRecv
|
||||
s.pendingRecv = nil
|
||||
s.mu.Unlock()
|
||||
if rs == nil || rs.offer.XID != xid {
|
||||
return
|
||||
}
|
||||
path := rs.dir + "/" + rs.offer.Name
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
log.Printf("flit: create %s: %v", path, err)
|
||||
return
|
||||
}
|
||||
rs.f = f
|
||||
|
||||
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
if _, err := f.Write(msg.Data); err != nil {
|
||||
return
|
||||
}
|
||||
rs.have += int64(len(msg.Data))
|
||||
if s.OnFileProgress != nil {
|
||||
s.OnFileProgress(xid, rs.have, rs.offer.Size)
|
||||
}
|
||||
})
|
||||
dc.OnClose(func() {
|
||||
f.Close()
|
||||
if s.OnFileDone != nil {
|
||||
s.OnFileDone(rs.offer.Name, path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func mustHex(s string) []byte {
|
||||
b, _ := hex.DecodeString(s)
|
||||
return b
|
||||
}
|
||||
|
||||
func gatherComplete(pc *webrtc.PeerConnection) <-chan struct{} {
|
||||
ch := make(chan struct{})
|
||||
if pc.ICEGatheringState() == webrtc.ICEGatheringStateComplete {
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
pc.OnICEGatheringStateChange(func(s webrtc.ICEGathererState) {
|
||||
if s == webrtc.ICEGathererStateComplete {
|
||||
select {
|
||||
case <-ch:
|
||||
default:
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
})
|
||||
go func() {
|
||||
time.Sleep(6 * time.Second)
|
||||
select {
|
||||
case <-ch:
|
||||
default:
|
||||
close(ch)
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
24
pwa/.gitignore
vendored
Normal file
24
pwa/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
8
pwa/.oxlintrc.json
Normal file
8
pwa/.oxlintrc.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
32
pwa/README.md
Normal file
32
pwa/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
17
pwa/index.html
Normal file
17
pwa/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#1f6feb" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>flit</title>
|
||||
<script src="/config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1688
pwa/package-lock.json
generated
Normal file
1688
pwa/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
pwa/package.json
Normal file
29
pwa/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "pwa",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"libsodium-wrappers": "^0.8.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/libsodium-wrappers": "^0.7.14",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
5
pwa/public/config.js
Normal file
5
pwa/public/config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
window.FLIT_CONFIG = {
|
||||
signalURL: 'wss://waste.dev.xplwd.com/ws',
|
||||
turnURL: 'turn:waste.dev.xplwd.com:3478',
|
||||
turnCredentialsURL: 'https://waste.dev.xplwd.com/turn-credentials',
|
||||
}
|
||||
1
pwa/public/favicon.svg
Normal file
1
pwa/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
pwa/public/icons.svg
Normal file
24
pwa/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
23
pwa/public/manifest.json
Normal file
23
pwa/public/manifest.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "flit",
|
||||
"short_name": "flit",
|
||||
"description": "Ephemeral, end-to-end encrypted file transfer between your own devices.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0d1117",
|
||||
"theme_color": "#1f6feb",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"files": [
|
||||
{ "name": "files", "accept": ["*/*"] }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
44
pwa/public/sw.js
Normal file
44
pwa/public/sw.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// Minimal service worker: only exists to catch the POST /share-target
|
||||
// request from Android's share sheet (required for Web Share Target to
|
||||
// work — the browser needs an installed SW controlling the scope). Stores
|
||||
// the shared files in a temporary Cache entry, then redirects to a page
|
||||
// that reads them and feeds them into the send flow.
|
||||
|
||||
const SHARE_CACHE = 'flit-share-v1'
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
if (event.request.method === 'POST' && url.pathname === '/share-target') {
|
||||
event.respondWith(handleShareTarget(event))
|
||||
}
|
||||
})
|
||||
|
||||
async function handleShareTarget(event) {
|
||||
const formData = await event.request.formData()
|
||||
const files = formData.getAll('files')
|
||||
const cache = await caches.open(SHARE_CACHE)
|
||||
await cache.put('/shared-files', new Response(await packFiles(files)))
|
||||
return Response.redirect('/?shared=1', 303)
|
||||
}
|
||||
|
||||
async function packFiles(files) {
|
||||
// Pack as a multipart-ish JSON manifest + blobs isn't trivial inside a SW;
|
||||
// simplest robust approach: store each file as its own cache entry keyed
|
||||
// by index, plus a manifest of names/types.
|
||||
const cache = await caches.open(SHARE_CACHE)
|
||||
const manifest = []
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const f = files[i]
|
||||
manifest.push({ name: f.name, type: f.type, size: f.size, key: `/shared-file-${i}` })
|
||||
await cache.put(`/shared-file-${i}`, new Response(f))
|
||||
}
|
||||
return JSON.stringify(manifest)
|
||||
}
|
||||
164
pwa/src/App.tsx
Normal file
164
pwa/src/App.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import QRCode from 'qrcode'
|
||||
import { FlitSession, type FileOfferEvent, type FileProgressEvent, type FileRecvEvent, type FlitConfig } from './transport/flit'
|
||||
import { createInvite, decodeInvite } from './pairing/ephemeral'
|
||||
import { addTrusted, listTrusted } from './pairing/keyring'
|
||||
import { consumeSharedFiles, registerServiceWorker } from './share-target'
|
||||
|
||||
declare global {
|
||||
interface Window { FLIT_CONFIG?: FlitConfig }
|
||||
}
|
||||
|
||||
type Phase = 'idle' | 'hosting' | 'joining' | 'connected'
|
||||
|
||||
function App() {
|
||||
const [phase, setPhase] = useState<Phase>('idle')
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>('')
|
||||
const [inviteInput, setInviteInput] = useState('')
|
||||
const [verified, setVerified] = useState(false)
|
||||
const [peerId, setPeerId] = useState('')
|
||||
const [offer, setOffer] = useState<FileOfferEvent | null>(null)
|
||||
const [progress, setProgress] = useState<FileProgressEvent | null>(null)
|
||||
const [received, setReceived] = useState<FileRecvEvent[]>([])
|
||||
const sessionRef = useRef<FlitSession | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
registerServiceWorker()
|
||||
if (new URLSearchParams(location.search).get('shared') === '1') {
|
||||
consumeSharedFiles().then((files) => {
|
||||
if (files.length) void sendFiles(files)
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
function cfg(): FlitConfig {
|
||||
const c = window.FLIT_CONFIG
|
||||
if (!c?.signalURL) throw new Error('FLIT_CONFIG missing — check public/config.js')
|
||||
return c
|
||||
}
|
||||
|
||||
function peerEvents() {
|
||||
return {
|
||||
connected: (v: boolean) => { setVerified(v); setPhase('connected') },
|
||||
status: () => {},
|
||||
fileOffer: (e: FileOfferEvent) => { setOffer(e); setPeerId(e.peer) },
|
||||
fileProgress: (e: FileProgressEvent) => setProgress(e),
|
||||
fileRecv: (e: FileRecvEvent) => { setReceived((r) => [...r, e]); setProgress(null) },
|
||||
fileCancelled: () => setOffer(null),
|
||||
}
|
||||
}
|
||||
|
||||
async function startHost() {
|
||||
const { invite, room } = createInvite(cfg().signalURL)
|
||||
setQrDataUrl(await QRCode.toDataURL(invite, { margin: 1, width: 256 }))
|
||||
setPhase('hosting')
|
||||
const session = await FlitSession.init(cfg())
|
||||
sessionRef.current = session
|
||||
await session.join(room, peerEvents())
|
||||
}
|
||||
|
||||
async function joinFromInvite(raw: string) {
|
||||
const inv = decodeInvite(raw)
|
||||
if (!inv) { alert('Not a valid flit invite'); return }
|
||||
setPhase('joining')
|
||||
const session = await FlitSession.init(cfg())
|
||||
sessionRef.current = session
|
||||
await session.join(inv.room, peerEvents())
|
||||
}
|
||||
|
||||
async function sendFiles(files: File[]) {
|
||||
const session = sessionRef.current
|
||||
if (!session) return
|
||||
for (const f of files) session.sendFile(f)
|
||||
}
|
||||
|
||||
function acceptOffer() {
|
||||
if (!offer) return
|
||||
sessionRef.current?.acceptOffer(offer.xid, offer.name, offer.size)
|
||||
setOffer(null)
|
||||
}
|
||||
|
||||
function rejectOffer() {
|
||||
if (!offer) return
|
||||
sessionRef.current?.rejectOffer(offer.xid)
|
||||
setOffer(null)
|
||||
}
|
||||
|
||||
function trustCurrentPeer() {
|
||||
if (!peerId) return
|
||||
addTrusted(peerId, peerId.slice(0, 8))
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 480, margin: '0 auto', padding: 16, fontFamily: 'system-ui' }}>
|
||||
<h1>flit</h1>
|
||||
|
||||
{phase === 'idle' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<button onClick={startHost}>Send a file (show QR)</button>
|
||||
<div>
|
||||
<input
|
||||
placeholder="paste flit: invite, or scan"
|
||||
value={inviteInput}
|
||||
onChange={(e) => setInviteInput(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<button onClick={() => joinFromInvite(inviteInput)}>Join</button>
|
||||
</div>
|
||||
<details>
|
||||
<summary>Trusted devices ({listTrusted().length})</summary>
|
||||
<ul>
|
||||
{listTrusted().map((p) => <li key={p.id}>{p.label} — {p.id.slice(0, 16)}…</li>)}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'hosting' && qrDataUrl && (
|
||||
<div>
|
||||
<p>Scan this on the receiving device:</p>
|
||||
<img src={qrDataUrl} alt="pairing QR" width={256} height={256} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'joining' && <p>Connecting…</p>}
|
||||
|
||||
{phase === 'connected' && (
|
||||
<div>
|
||||
<p>{verified ? '✅ Verified peer' : '⚠️ Unverified — connection not confirmed'} {peerId && `(${peerId.slice(0, 16)}…)`}</p>
|
||||
<button onClick={trustCurrentPeer}>Remember this device</button>
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => e.target.files && sendFiles(Array.from(e.target.files))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{offer && (
|
||||
<div style={{ border: '1px solid', padding: 8, marginTop: 12 }}>
|
||||
<p>Incoming file: {offer.name} ({offer.size} bytes)</p>
|
||||
<button onClick={acceptOffer}>Accept</button>
|
||||
<button onClick={rejectOffer}>Reject</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
<p>Receiving {progress.name}: {progress.received}/{progress.total}</p>
|
||||
)}
|
||||
|
||||
{received.length > 0 && (
|
||||
<ul>
|
||||
{received.map((r, i) => (
|
||||
<li key={i}><a href={r.url} download={r.name}>{r.name}</a> ({r.size} bytes)</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
111
pwa/src/index.css
Normal file
111
pwa/src/index.css
Normal file
@@ -0,0 +1,111 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
10
pwa/src/main.tsx
Normal file
10
pwa/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
39
pwa/src/pairing/ephemeral.ts
Normal file
39
pwa/src/pairing/ephemeral.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Ephemeral one-shot QR pairing: generate a random room name, encode it (plus
|
||||
// the anchor URL) into a "flit:" invite string for a QR code. No persistent
|
||||
// trust is established unless the user explicitly adds the peer afterward
|
||||
// (see pairing/keyring.ts) — this is intentionally a *separate*, lower-trust
|
||||
// flow from the persistent keyring.
|
||||
|
||||
const PREFIX = 'flit:'
|
||||
|
||||
export interface FlitInvite {
|
||||
anchor: string
|
||||
room: string
|
||||
}
|
||||
|
||||
function randomRoomName(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16))
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export function createInvite(anchorUrl: string): { invite: string; room: string } {
|
||||
const room = randomRoomName()
|
||||
const payload: FlitInvite = { anchor: anchorUrl, room }
|
||||
const json = JSON.stringify(payload)
|
||||
const b64 = btoa(json).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
return { invite: PREFIX + b64, room }
|
||||
}
|
||||
|
||||
export function decodeInvite(s: string): FlitInvite | null {
|
||||
const trimmed = s.trim()
|
||||
if (!trimmed.startsWith(PREFIX)) return null
|
||||
try {
|
||||
const b64 = trimmed.slice(PREFIX.length).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const json = atob(b64)
|
||||
const parsed = JSON.parse(json) as FlitInvite
|
||||
if (!parsed.anchor || !parsed.room) return null
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
28
pwa/src/pairing/keyring.ts
Normal file
28
pwa/src/pairing/keyring.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Persistent trusted-peer list ("Add device" flow) — separate from the
|
||||
// ephemeral one-shot QR room pairing in pairing/ephemeral.ts.
|
||||
|
||||
export interface TrustedPeer {
|
||||
id: string // hex Ed25519 pubkey
|
||||
label: string
|
||||
addedAt: number
|
||||
}
|
||||
|
||||
const KEY = 'flit_keyring'
|
||||
|
||||
export function listTrusted(): TrustedPeer[] {
|
||||
try { return JSON.parse(localStorage.getItem(KEY) ?? '[]') } catch { return [] }
|
||||
}
|
||||
|
||||
export function addTrusted(id: string, label: string): void {
|
||||
const peers = listTrusted().filter(p => p.id !== id)
|
||||
peers.push({ id, label, addedAt: Date.now() })
|
||||
localStorage.setItem(KEY, JSON.stringify(peers))
|
||||
}
|
||||
|
||||
export function removeTrusted(id: string): void {
|
||||
localStorage.setItem(KEY, JSON.stringify(listTrusted().filter(p => p.id !== id)))
|
||||
}
|
||||
|
||||
export function isTrusted(id: string): boolean {
|
||||
return listTrusted().some(p => p.id === id)
|
||||
}
|
||||
31
pwa/src/share-target/index.ts
Normal file
31
pwa/src/share-target/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Reads files captured by public/sw.js's POST /share-target handler.
|
||||
// Call once on app load when the URL has ?shared=1.
|
||||
|
||||
const SHARE_CACHE = 'flit-share-v1'
|
||||
|
||||
interface ManifestEntry { name: string; type: string; size: number; key: string }
|
||||
|
||||
export async function consumeSharedFiles(): Promise<File[]> {
|
||||
if (!('caches' in window)) return []
|
||||
const cache = await caches.open(SHARE_CACHE)
|
||||
const manifestRes = await cache.match('/shared-files')
|
||||
if (!manifestRes) return []
|
||||
const manifest: ManifestEntry[] = await manifestRes.json()
|
||||
|
||||
const files: File[] = []
|
||||
for (const entry of manifest) {
|
||||
const res = await cache.match(entry.key)
|
||||
if (!res) continue
|
||||
const blob = await res.blob()
|
||||
files.push(new File([blob], entry.name, { type: entry.type }))
|
||||
await cache.delete(entry.key)
|
||||
}
|
||||
await cache.delete('/shared-files')
|
||||
return files
|
||||
}
|
||||
|
||||
export function registerServiceWorker(): void {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => { /* PWA-only feature, fine to no-op */ })
|
||||
}
|
||||
}
|
||||
515
pwa/src/transport/flit.ts
Normal file
515
pwa/src/transport/flit.ts
Normal file
@@ -0,0 +1,515 @@
|
||||
// flit transport — yaw/2.1 signaling + WebRTC, trimmed from waste-go's
|
||||
// web/src/adapter/browser.ts to 1:1 ephemeral pairing + file transfer only.
|
||||
// Dropped vs. waste-go: chat, pm, reactions, file browsing/share lists,
|
||||
// multi-network mesh. Kept: identity, ekey/offer/answer handshake, hello
|
||||
// verification, file-offer/accept/chunked transfer.
|
||||
|
||||
import sodium from 'libsodium-wrappers'
|
||||
|
||||
const BIND_PREFIX = 'yaw/2 bind'
|
||||
const EKEY_PREFIX = 'yaw/2.1 ekey'
|
||||
const FS_TIMEOUT = 2000
|
||||
const STUN = 'stun:stun.l.google.com:19302'
|
||||
const CHUNK = 64 * 1024
|
||||
|
||||
// ── TURN ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Credentials are minted server-side by the anchor's GET /turn-credentials
|
||||
// (coturn use-auth-secret scheme; see waste-go/cmd/anchor/main.go). The raw
|
||||
// shared secret never reaches the browser — only a short-lived
|
||||
// {username,credential} pair.
|
||||
export interface FlitConfig {
|
||||
signalURL: string
|
||||
turnURL?: string
|
||||
turnCredentialsURL?: string
|
||||
}
|
||||
|
||||
async function fetchTurnCredentials(url: string): Promise<{ username: string; credential: string } | null> {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
if (!data.username || !data.credential) return null
|
||||
return { username: data.username, credential: data.credential }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function iceServers(cfg: FlitConfig): Promise<RTCIceServer[]> {
|
||||
const servers: RTCIceServer[] = [{ urls: STUN }]
|
||||
if (cfg.turnURL && cfg.turnCredentialsURL) {
|
||||
const creds = await fetchTurnCredentials(cfg.turnCredentialsURL)
|
||||
if (creds) servers.push({ urls: cfg.turnURL, username: creds.username, credential: creds.credential })
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
const enc = (s: string) => new TextEncoder().encode(s)
|
||||
|
||||
function concat(...arrs: Uint8Array[]): Uint8Array {
|
||||
const n = arrs.reduce((a, b) => a + b.length, 0)
|
||||
const out = new Uint8Array(n); let o = 0
|
||||
for (const a of arrs) { out.set(a, o); o += a.length }
|
||||
return out
|
||||
}
|
||||
|
||||
function toHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export async function netHash(name: string): Promise<string> {
|
||||
const input = enc('yaw2-net:' + name)
|
||||
if (globalThis.crypto?.subtle) {
|
||||
const digest = await globalThis.crypto.subtle.digest('SHA-256', input)
|
||||
return toHex(new Uint8Array(digest))
|
||||
}
|
||||
throw new Error('SHA-256 not available in this browser runtime')
|
||||
}
|
||||
|
||||
// ── Identity ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export class Identity {
|
||||
pub: Uint8Array
|
||||
priv: Uint8Array
|
||||
id: string
|
||||
curvePriv: Uint8Array
|
||||
|
||||
constructor(kp: { publicKey: Uint8Array; privateKey: Uint8Array }) {
|
||||
this.pub = kp.publicKey
|
||||
this.priv = kp.privateKey
|
||||
this.id = sodium.to_hex(this.pub)
|
||||
this.curvePriv = sodium.crypto_sign_ed25519_sk_to_curve25519(this.priv)
|
||||
}
|
||||
|
||||
// load() always persists — this is the long-lived device identity used
|
||||
// both for the persistent keyring and to sign ephemeral sessions.
|
||||
static load(): Identity {
|
||||
const seedHex = localStorage.getItem('flit_seed')
|
||||
let kp
|
||||
if (seedHex) {
|
||||
kp = sodium.crypto_sign_seed_keypair(sodium.from_hex(seedHex))
|
||||
} else {
|
||||
kp = sodium.crypto_sign_keypair()
|
||||
localStorage.setItem('flit_seed', sodium.to_hex(kp.privateKey.slice(0, 32)))
|
||||
}
|
||||
return new Identity(kp)
|
||||
}
|
||||
|
||||
sign(data: Uint8Array): Uint8Array {
|
||||
return sodium.crypto_sign_detached(data, this.priv)
|
||||
}
|
||||
|
||||
static verify(idHex: string, data: Uint8Array, sig: Uint8Array): boolean {
|
||||
try { return sodium.crypto_sign_verify_detached(sig, data, sodium.from_hex(idHex)) }
|
||||
catch { return false }
|
||||
}
|
||||
|
||||
seal(recipIdHex: string, plaintext: Uint8Array): string {
|
||||
const pub = sodium.crypto_sign_ed25519_pk_to_curve25519(sodium.from_hex(recipIdHex))
|
||||
const nonce = sodium.randombytes_buf(24)
|
||||
const ct = sodium.crypto_box_easy(plaintext, nonce, pub, this.curvePriv)
|
||||
return sodium.to_base64(concat(nonce, ct), sodium.base64_variants.ORIGINAL)
|
||||
}
|
||||
|
||||
open(senderIdHex: string, boxB64: string): Uint8Array | null {
|
||||
const pub = sodium.crypto_sign_ed25519_pk_to_curve25519(sodium.from_hex(senderIdHex))
|
||||
const box = sodium.from_base64(boxB64, sodium.base64_variants.ORIGINAL)
|
||||
try { return sodium.crypto_box_open_easy(box.slice(24), box.slice(0, 24), pub, this.curvePriv) }
|
||||
catch { return null }
|
||||
}
|
||||
|
||||
get short(): string {
|
||||
return this.id.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Signaling ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export class Signaling {
|
||||
private ws: WebSocket | null = null
|
||||
private _closed = false
|
||||
private _backoff = 1000
|
||||
private _cbs: {
|
||||
onFrom?: (from: string, box: string) => void
|
||||
onJoin?: (id: string) => void
|
||||
onLeave?: (id: string) => void
|
||||
onReconnect?: (peers: string[]) => void
|
||||
} = {}
|
||||
|
||||
private url: string
|
||||
private identity: Identity
|
||||
private net: string
|
||||
|
||||
constructor(url: string, identity: Identity, net: string) {
|
||||
this.url = url
|
||||
this.identity = identity
|
||||
this.net = net
|
||||
}
|
||||
|
||||
connect(
|
||||
onFrom: (from: string, box: string) => void,
|
||||
onJoin: (id: string) => void,
|
||||
onLeave: (id: string) => void,
|
||||
onReconnect: (peers: string[]) => void,
|
||||
): Promise<string[]> {
|
||||
this._cbs = { onFrom, onJoin, onLeave, onReconnect }
|
||||
return this._open(true)
|
||||
}
|
||||
|
||||
private _open(initial: boolean): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(this.url)
|
||||
this.ws = ws
|
||||
let joined = false
|
||||
|
||||
ws.onerror = () => { if (initial && !joined) reject(new Error('signaling connection failed')) }
|
||||
ws.onclose = () => { if (!this._closed) this._scheduleReconnect() }
|
||||
ws.onmessage = (ev) => {
|
||||
let m: Record<string, unknown>
|
||||
try { m = JSON.parse(ev.data) } catch { return }
|
||||
|
||||
if (m['type'] === 'challenge') {
|
||||
const nonce = sodium.from_hex(m['nonce'] as string)
|
||||
const sig = sodium.to_hex(this.identity.sign(concat(nonce, enc(this.net))))
|
||||
ws.send(JSON.stringify({ type: 'join', id: this.identity.id, net: this.net, sig }))
|
||||
} else if (m['type'] === 'joined') {
|
||||
joined = true; this._backoff = 1000
|
||||
const peers = (m['peers'] as string[]) || []
|
||||
if (initial) resolve(peers)
|
||||
else this._cbs.onReconnect?.(peers)
|
||||
} else if (m['type'] === 'from') {
|
||||
this._cbs.onFrom?.(m['from'] as string, m['box'] as string)
|
||||
} else if (m['type'] === 'peer-join') {
|
||||
this._cbs.onJoin?.(m['id'] as string)
|
||||
} else if (m['type'] === 'peer-leave') {
|
||||
this._cbs.onLeave?.(m['id'] as string)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private _scheduleReconnect() {
|
||||
if (this._closed) return
|
||||
const delay = this._backoff
|
||||
this._backoff = Math.min(this._backoff * 2, 30000)
|
||||
setTimeout(() => { if (!this._closed) this._open(false).catch(() => {}) }, delay)
|
||||
}
|
||||
|
||||
sendTo(toId: string, box: string) {
|
||||
try { this.ws?.send(JSON.stringify({ type: 'to', to: toId, box })) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
close() { this._closed = true; this.ws?.close() }
|
||||
}
|
||||
|
||||
function gatherComplete(pc: RTCPeerConnection): Promise<void> {
|
||||
if (pc.iceGatheringState === 'complete') return Promise.resolve()
|
||||
return new Promise((res) => {
|
||||
const check = () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
pc.removeEventListener('icegatheringstatechange', check)
|
||||
res()
|
||||
}
|
||||
}
|
||||
pc.addEventListener('icegatheringstatechange', check)
|
||||
setTimeout(res, 6000)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Peer connection (yaw/2.1, file transfer only) ─────────────────────────────
|
||||
|
||||
export interface FileOfferEvent { peer: string; xid: string; name: string; size: number }
|
||||
export interface FileProgressEvent { peer: string; xid: string; name: string; received: number; total: number }
|
||||
export interface FileRecvEvent { peer: string; name: string; size: number; url: string }
|
||||
|
||||
type PeerEvents = {
|
||||
connected: (verified: boolean) => void
|
||||
status: (state: RTCPeerConnectionState) => void
|
||||
fileOffer: (e: FileOfferEvent) => void
|
||||
fileProgress: (e: FileProgressEvent) => void
|
||||
fileRecv: (e: FileRecvEvent) => void
|
||||
fileCancelled: (xid: string) => void
|
||||
}
|
||||
|
||||
export class PeerConn {
|
||||
pc: RTCPeerConnection
|
||||
dc: RTCDataChannel | null = null
|
||||
verified = false
|
||||
peerAuthed = false
|
||||
created = Date.now()
|
||||
|
||||
private _esk: Uint8Array
|
||||
private _epk: Uint8Array
|
||||
peer_epk: Uint8Array | null = null
|
||||
private _ekeySent = false
|
||||
private _offerPending = false
|
||||
private _offered = false
|
||||
|
||||
private _recv: Record<string, { name: string; size: number; buf: ArrayBuffer[]; have: number; cancelled?: boolean }> = {}
|
||||
private _recvChannels: Record<string, RTCDataChannel> = {}
|
||||
private _pushQueue: Map<string, File> = new Map()
|
||||
|
||||
private identity: Identity
|
||||
private sig: Signaling
|
||||
peerId: string
|
||||
private events: Partial<PeerEvents>
|
||||
|
||||
constructor(
|
||||
identity: Identity,
|
||||
sig: Signaling,
|
||||
peerId: string,
|
||||
events: Partial<PeerEvents>,
|
||||
ice: RTCIceServer[],
|
||||
) {
|
||||
this.identity = identity
|
||||
this.sig = sig
|
||||
this.peerId = peerId
|
||||
this.events = events
|
||||
this.pc = new RTCPeerConnection({ iceServers: ice })
|
||||
const kp = sodium.crypto_box_keypair()
|
||||
this._esk = kp.privateKey
|
||||
this._epk = kp.publicKey
|
||||
this.pc.ondatachannel = (ev) => this._wire(ev.channel)
|
||||
this.pc.onconnectionstatechange = () => this.events.status?.(this.pc.connectionState)
|
||||
}
|
||||
|
||||
private _seal(obj: object, preferEph: boolean): string {
|
||||
const data = enc(JSON.stringify(obj))
|
||||
if (preferEph && this.peer_epk) {
|
||||
const nonce = sodium.randombytes_buf(24)
|
||||
const ct = sodium.crypto_box_easy(data, nonce, this.peer_epk, this._esk)
|
||||
return sodium.to_base64(concat(nonce, ct), sodium.base64_variants.ORIGINAL)
|
||||
}
|
||||
return this.identity.seal(this.peerId, data)
|
||||
}
|
||||
|
||||
private _open(box: string): [Uint8Array | null, boolean] {
|
||||
if (this.peer_epk) {
|
||||
try {
|
||||
const raw = sodium.from_base64(box, sodium.base64_variants.ORIGINAL)
|
||||
return [sodium.crypto_box_open_easy(raw.slice(24), raw.slice(0, 24), this.peer_epk, this._esk), true]
|
||||
} catch { /* fall through to static */ }
|
||||
}
|
||||
return [this.identity.open(this.peerId, box), false]
|
||||
}
|
||||
|
||||
private _sendEkey() {
|
||||
if (this._ekeySent) return
|
||||
this._ekeySent = true
|
||||
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.identity.id), sodium.from_hex(this.peerId), this._epk)
|
||||
const msg = { kind: 'ekey', v: 'yaw/2.1', epk: sodium.to_hex(this._epk), sig: sodium.to_hex(this.identity.sign(signed)) }
|
||||
this.sig.sendTo(this.peerId, this._seal(msg, false))
|
||||
}
|
||||
|
||||
private async _onEkey(obj: Record<string, unknown>) {
|
||||
if (this.peer_epk) return
|
||||
try {
|
||||
const epkRaw = sodium.from_hex(obj['epk'] as string)
|
||||
const sigBytes = sodium.from_hex(obj['sig'] as string)
|
||||
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.peerId), sodium.from_hex(this.identity.id), epkRaw)
|
||||
if (epkRaw.length !== 32 || !Identity.verify(this.peerId, signed, sigBytes)) return
|
||||
this.peer_epk = epkRaw
|
||||
} catch { return }
|
||||
this._sendEkey()
|
||||
if (this._offerPending) await this._doOffer()
|
||||
}
|
||||
|
||||
async startOffer() {
|
||||
this._sendEkey()
|
||||
this._offerPending = true
|
||||
setTimeout(() => { if (this._offerPending) this._doOffer() }, FS_TIMEOUT)
|
||||
if (this.peer_epk) await this._doOffer()
|
||||
}
|
||||
|
||||
private async _doOffer() {
|
||||
if (this._offered) return
|
||||
this._offered = true; this._offerPending = false
|
||||
this.dc = this.pc.createDataChannel('yaw')
|
||||
this._wire(this.dc)
|
||||
await this.pc.setLocalDescription(await this.pc.createOffer())
|
||||
await gatherComplete(this.pc)
|
||||
this.sig.sendTo(this.peerId, this._seal({ kind: 'offer', sdp: this.pc.localDescription!.sdp }, true))
|
||||
}
|
||||
|
||||
async onBox(box: string) {
|
||||
const [plain, usedEph] = this._open(box)
|
||||
if (!plain) return
|
||||
this.peerAuthed = true
|
||||
let obj: Record<string, unknown>
|
||||
try { obj = JSON.parse(new TextDecoder().decode(plain)) } catch { return }
|
||||
|
||||
if (obj['kind'] === 'ekey') {
|
||||
await this._onEkey(obj)
|
||||
} else if (obj['kind'] === 'offer') {
|
||||
await this.pc.setRemoteDescription({ type: 'offer', sdp: obj['sdp'] as string })
|
||||
await this.pc.setLocalDescription(await this.pc.createAnswer())
|
||||
await gatherComplete(this.pc)
|
||||
this.sig.sendTo(this.peerId, this._seal({ kind: 'answer', sdp: this.pc.localDescription!.sdp }, usedEph))
|
||||
} else if (obj['kind'] === 'answer') {
|
||||
await this.pc.setRemoteDescription({ type: 'answer', sdp: obj['sdp'] as string })
|
||||
}
|
||||
}
|
||||
|
||||
private _wire(channel: RTCDataChannel) {
|
||||
if (channel.label === 'yaw') {
|
||||
this.dc = channel
|
||||
channel.onopen = () => this._sendHello()
|
||||
channel.onmessage = (ev) => this._onControl(ev.data)
|
||||
if (channel.readyState === 'open') this._sendHello()
|
||||
return
|
||||
}
|
||||
if (channel.label.startsWith('f:')) {
|
||||
const xid = channel.label.slice(2)
|
||||
const rx = this._recv[xid]
|
||||
if (!rx) return
|
||||
channel.binaryType = 'arraybuffer'
|
||||
channel.onmessage = (ev) => {
|
||||
if (!(ev.data instanceof ArrayBuffer)) return
|
||||
rx.buf.push(ev.data)
|
||||
rx.have += ev.data.byteLength
|
||||
this.events.fileProgress?.({ peer: this.peerId, xid, name: rx.name, received: rx.have, total: rx.size })
|
||||
}
|
||||
channel.onclose = () => {
|
||||
if (rx.cancelled) { delete this._recv[xid]; return }
|
||||
const blob = new Blob(rx.buf)
|
||||
const url = URL.createObjectURL(blob)
|
||||
this.events.fileRecv?.({ peer: this.peerId, name: rx.name, size: rx.size, url })
|
||||
delete this._recv[xid]
|
||||
}
|
||||
this._recvChannels[xid] = channel
|
||||
}
|
||||
}
|
||||
|
||||
private _sendHello() {
|
||||
this._dc({ type: 'hello', id: this.identity.id, sig: sodium.to_hex(this.identity.sign(enc(BIND_PREFIX))) })
|
||||
}
|
||||
|
||||
private _onControl(data: string) {
|
||||
let m: Record<string, unknown>
|
||||
try { m = JSON.parse(data) } catch { return }
|
||||
|
||||
if (m['type'] === 'hello') {
|
||||
this.verified = m['id'] === this.peerId && this.peerAuthed
|
||||
this.events.connected?.(this.verified)
|
||||
} else if (m['type'] === 'file-offer') {
|
||||
this.events.fileOffer?.({ peer: this.peerId, xid: m['xid'] as string, name: m['name'] as string, size: m['size'] as number })
|
||||
} else if (m['type'] === 'file-accept') {
|
||||
const xid = m['xid'] as string
|
||||
if (this._pushQueue.has(xid)) void this._stream(xid)
|
||||
} else if (m['type'] === 'file-cancel') {
|
||||
const xid = m['xid'] as string
|
||||
if (this._recv[xid]) {
|
||||
this._recv[xid].cancelled = true
|
||||
this._recvChannels[xid]?.close()
|
||||
delete this._recvChannels[xid]
|
||||
this.events.fileCancelled?.(xid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
acceptOffer(xid: string, name: string, size: number) {
|
||||
this._recv[xid] = { name, size, buf: [], have: 0 }
|
||||
this._dc({ type: 'file-accept', xid })
|
||||
}
|
||||
|
||||
rejectOffer(xid: string) {
|
||||
this._dc({ type: 'file-cancel', xid })
|
||||
}
|
||||
|
||||
sendFile(file: File) {
|
||||
const xid = `push-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
this._pushQueue.set(xid, file)
|
||||
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid })
|
||||
}
|
||||
|
||||
private async _stream(xid: string) {
|
||||
const file = this._pushQueue.get(xid)
|
||||
if (!file) return
|
||||
this._pushQueue.delete(xid)
|
||||
const dc = this.pc.createDataChannel(`f:${xid}`)
|
||||
dc.binaryType = 'arraybuffer'
|
||||
await new Promise<void>(res => { dc.onopen = () => res() })
|
||||
const buf = await file.arrayBuffer()
|
||||
let offset = 0
|
||||
while (offset < buf.byteLength) {
|
||||
while (dc.bufferedAmount > 1024 * 1024) await new Promise(r => setTimeout(r, 10))
|
||||
dc.send(buf.slice(offset, offset + CHUNK))
|
||||
offset += CHUNK
|
||||
}
|
||||
dc.close()
|
||||
}
|
||||
|
||||
private _dc(obj: object) {
|
||||
if (this.dc?.readyState === 'open') this.dc.send(JSON.stringify(obj))
|
||||
}
|
||||
|
||||
close() { try { this.pc.close() } catch { /* ignore */ } }
|
||||
}
|
||||
|
||||
// ── Session: one ephemeral or persistent pairing ──────────────────────────────
|
||||
|
||||
export class FlitSession {
|
||||
identity: Identity
|
||||
private sig: Signaling | null = null
|
||||
private peer: PeerConn | null = null
|
||||
networkId = ''
|
||||
|
||||
private cfg: FlitConfig
|
||||
|
||||
constructor(cfg: FlitConfig) {
|
||||
this.cfg = cfg
|
||||
this.identity = Identity.load()
|
||||
}
|
||||
|
||||
static async init(cfg: FlitConfig): Promise<FlitSession> {
|
||||
await sodium.ready
|
||||
return new FlitSession(cfg)
|
||||
}
|
||||
|
||||
// Join an ephemeral or named room by name; trustedPeerId, if given, is the
|
||||
// only peer this session will complete a handshake with (others are
|
||||
// ignored — defends against a third party joining a guessed room name).
|
||||
async join(roomName: string, events: Partial<PeerEvents>, trustedPeerId?: string): Promise<void> {
|
||||
const hash = await netHash(roomName)
|
||||
this.networkId = hash
|
||||
|
||||
const sig = new Signaling(this.cfg.signalURL, this.identity, hash)
|
||||
this.sig = sig
|
||||
const ice = await iceServers(this.cfg)
|
||||
|
||||
const connectTo = async (pid: string) => {
|
||||
if (pid === this.identity.id) return
|
||||
if (trustedPeerId && pid !== trustedPeerId) return
|
||||
if (this.peer) return
|
||||
this.peer = new PeerConn(this.identity, sig, pid, events, ice)
|
||||
if (this.identity.id < pid) await this.peer.startOffer()
|
||||
}
|
||||
|
||||
const present = await sig.connect(
|
||||
(from, box) => { void this._onFrom(from, box, events, ice, trustedPeerId) },
|
||||
(pid) => { void connectTo(pid) },
|
||||
() => { /* peer-leave: nothing to clean up for a single 1:1 session */ },
|
||||
(peers) => { peers.forEach(pid => void connectTo(pid)) },
|
||||
)
|
||||
for (const pid of present) await connectTo(pid)
|
||||
}
|
||||
|
||||
private async _onFrom(from: string, box: string, events: Partial<PeerEvents>, ice: RTCIceServer[], trustedPeerId?: string) {
|
||||
if (from === this.identity.id) return
|
||||
if (trustedPeerId && from !== trustedPeerId) return
|
||||
if (!this.peer) this.peer = new PeerConn(this.identity, this.sig!, from, events, ice)
|
||||
await this.peer.onBox(box)
|
||||
}
|
||||
|
||||
sendFile(file: File) { this.peer?.sendFile(file) }
|
||||
acceptOffer(xid: string, name: string, size: number) { this.peer?.acceptOffer(xid, name, size) }
|
||||
rejectOffer(xid: string) { this.peer?.rejectOffer(xid) }
|
||||
|
||||
close() {
|
||||
this.sig?.close()
|
||||
this.peer?.close()
|
||||
this.sig = null
|
||||
this.peer = null
|
||||
}
|
||||
}
|
||||
26
pwa/tsconfig.app.json
Normal file
26
pwa/tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
pwa/tsconfig.json
Normal file
7
pwa/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
23
pwa/tsconfig.node.json
Normal file
23
pwa/tsconfig.node.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
pwa/vite.config.ts
Normal file
7
pwa/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user