diff --git a/EXTENSIONS.md b/EXTENSIONS.md new file mode 100644 index 0000000..2167b07 --- /dev/null +++ b/EXTENSIONS.md @@ -0,0 +1,173 @@ +# waste-go Protocol Extensions + +These are additive extensions to [YAW/2](PROTOCOL.md) implemented by waste-go. +They do **not** break compatibility — YAW/2-only peers silently ignore all new +fields. Where a waste-go peer connects to a YAW/2-only peer, the extension +simply has no effect on that peer. + +--- + +## EXT-001 — Signed Invites + +**Status:** implemented +**Affects:** `waste:` invite format, `hello` DataChannel message + +### Motivation + +The base YAW/2 network model is open to anyone who knows the anchor URL and +network name (or hash). This extension adds opt-in cryptographic membership +gating: invites are signed by an existing peer, and peers that enforce +`RequireInvite` reject hellos that carry no valid signed invite. + +### Invite format changes + +The `waste:` invite payload (base64-encoded JSON) gains two optional fields: + +```json +{ + "anchor": "wss://...", + "network": "friends", + "net": "<64-hex SHA-256(yaw2-net:name)>", + "inviter": "<64-hex Ed25519 pubkey of signing peer>", + "sig": "" +} +``` + +The signature covers the following bytes (null-separated): + +``` +anchor \x00 network \x00 net \x00 inviter +``` + +Unsigned invites (`inviter`/`sig` absent) remain valid for backward compat. + +### Hello message extension + +The YAW/2 §6 hello message gains one optional field: + +```json +{ + "type": "hello", + "id": "", + "nick": "alice", + "caps": ["chat", "file"], + "sig": "", + "invite": "waste:eyJ..." +} +``` + +`invite` carries the full `waste:` string the connecting peer used to join. +YAW/2-only peers ignore this field. + +### Enforcement + +Per-network flag `RequireInvite` (set via `join_network` IPC command). +When enabled: + +1. A peer that presents no `invite` in hello is disconnected immediately. +2. A peer that presents an invite with no signature is disconnected. +3. A peer whose invite signature is invalid is disconnected. +4. A peer whose invite was signed by an unknown peer ID (not in the store or + currently connected) is disconnected. + +The inviter's key must be a **known peer** — i.e. previously connected and +stored in the per-network SQLite store, or currently connected. This forms a +chain of trust: Alice (founder) invites Bob; Bob's key is now known; Bob can +invite Carol, whose invite Alice will also accept. + +**Default:** off. Networks opt in. Existing networks with no RequireInvite +behave exactly as before. + +--- + +## EXT-002 — Hash-based Hang Link + +**Status:** implemented +**Affects:** web UI URL handling only, no wire changes + +### Motivation + +A shareable URL that pre-fills the join form without conveying cryptographic +membership. Suitable for public announcements ("come hang out here"). The +fragment is never sent to the server, keeping the network name opaque to +server logs and HTTP intermediaries. + +### Format + +``` +https://host/#waste:eyJ... +``` + +The fragment payload is the standard `waste:` base64 JSON with only `network` +and `anchor` fields — no `inviter`, no `sig`. This does **not** grant +membership on networks with `RequireInvite` enabled; it only pre-fills the +join form. + +The web UI generates hang links via the 🔗 button in the Networks sidebar +section. Arriving users see the join form pre-populated and still need a +proper signed invite (if the network enforces it) to be accepted by peers. + +--- + +## EXT-003 — Multi-Share Configuration + +**Status:** implemented +**Affects:** IPC protocol only, no peer-to-peer wire changes + +### New IPC commands + +```jsonc +{"type":"add_share","path":"/home/alice/Music"} // global +{"type":"add_share","path":"/home/alice/Docs","networks":["abc123"]} // scoped +{"type":"remove_share","path":"/home/alice/Music"} +{"type":"list_shares"} +``` + +### New IPC event + +```jsonc +{"type":"shares_list","shares":[{"path":"...","networks":["*"]}]} +``` + +### Persistence + +`shares.json` in the data directory (next to `identity.json`). Each entry: + +```json +{ "path": "/absolute/path", "networks": ["*"] } +``` + +`networks: ["*"]` = global (all networks). Specific network IDs = scoped. +Coexists with the legacy `set_share_dir` single-dir mechanism. + +File listings returned by `get_file_list` and `MsgFileListReq` include +entries from all applicable share roots, with relative `path` fields +(e.g. `"path": "docs/report.pdf"`). + +--- + +## EXT-004 — TURN Relay (browser mode) + +**Status:** implemented (browser mode); pending (daemon mode) +**Affects:** ICE server configuration only, no wire changes + +The browser adapter reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret` +and adds a TURN server to the WebRTC `ICEServers` list. Credentials are +generated using HMAC-SHA1 of the username (coturn `use-auth-secret` scheme). + +YAW/2 §0 explicitly declines TURN ("No relay (TURN)"). This extension is +opt-in via server configuration and does not affect peers that omit it. + +--- + +## EXT-005 — Per-Network Path in FileEntry + +**Status:** implemented +**Affects:** `MsgFileListResp` wire message (additive field) + +`FileEntry` gains an optional `path` field carrying the file's relative path +within its share root (e.g. `"docs/report.pdf"`). Peers that don't understand +this field continue to use `name` for display and download requests. + +`MsgFileListReq` / `get` requests use `path` as the lookup key when present, +falling back to `name` for backward compat with peers that don't send `path`. diff --git a/FUTURE.md b/FUTURE.md index afadd96..190aeb2 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -121,6 +121,9 @@ Web frontend (React, already built) + Tauri shell for native packaging. The IPC | ✅ shipped | Session persistence + logout (browser mode) | | ✅ shipped | Persistent multi-share config (shares.json + localStorage) | | ✅ shipped | Subfolder support + directory browser UI in file browser | +| ✅ shipped | Signed invites + invite-only networks (`RequireInvite`) | +| ✅ shipped | Hash-based "come hang" links (`#waste:...`) | +| ✅ shipped | Protocol extensions documented in EXTENSIONS.md | | next | TURN relay for daemon mode | | next | TUI room creation + daemon-side room persistence | | next | File transfer resume after disconnection | diff --git a/README.md b/README.md index 1573710..482cb55 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,24 @@ The invite encodes the anchor URL and network name. Sharing it only lets the rec Invite links also work in the web UI. Share `https://your-domain.com/?invite=waste:eyJ...` and the join form is pre-filled. +### Signed invites and invite-only networks (waste-go extension) + +Invites generated by `generate_invite` are **cryptographically signed** by the generating peer. The `waste:` payload carries an `inviter` field (Ed25519 public key) and a `sig` field (signature over anchor + network + inviter). When Bob joins, the invite is forwarded in the `hello` message so Alice can verify it. + +To enable invite-only enforcement on a network, pass `require_invite: true` in the `join_network` command. Peers presenting no invite, an unsigned invite, or an invite signed by an unknown peer are rejected. + +### "Come hang" hang links + +The 🔗 button in the web UI copies a **hash-based hang link**: + +``` +https://your-domain.com/#waste:eyJ... +``` + +The fragment (`#...`) is never sent to the server, so the network name stays server-opaque. Anyone who opens the link gets the join form pre-filled — but they still need a proper signed invite to be accepted on networks with `require_invite` enabled. Suitable for public announcements of open or semi-open networks. + +See [EXTENSIONS.md](EXTENSIONS.md) for the full protocol addendum. + --- ## Terminal UI diff --git a/cmd/tui/main.go b/cmd/tui/main.go index 858ec2d..2bc4eef 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -283,7 +283,7 @@ func (m model) applyEvent(evt proto.IpcMessage) model { } case proto.EvtInviteGenerated: - m.invitePopup = evt.InviteString + m.invitePopup = evt.InviteGenerated case proto.EvtMessageReceived: if evt.Message != nil { diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 8a43582..65ebe99 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -131,6 +131,9 @@ func (id *Identity) PeerInfo() proto.PeerInfo { } } +// PeerIDHex satisfies the invite.Signer interface. +func (id *Identity) PeerIDHex() string { return string(id.PeerID()) } + // Sign signs data with our Ed25519 private key. Returns hex-encoded signature. func (id *Identity) Sign(data []byte) string { sig := ed25519.Sign(id.privateKey, data) diff --git a/internal/invite/invite.go b/internal/invite/invite.go index ce7fd84..23db7f6 100644 --- a/internal/invite/invite.go +++ b/internal/invite/invite.go @@ -6,6 +6,11 @@ // 64-char hex SHA-256("yaw2-net:"+name) hash that yaw2 clients pass directly // to the signaling server. A yaw2 client that can parse the base64 JSON can join // the same network without knowing the plaintext name. +// +// Signed invites (waste-go extension): when `inviter` and `sig` are present, +// the invite was issued by a known peer. Receiving peers that enforce +// RequireInvite will reject hellos that carry no valid signed invite. +// YAW/2-only peers ignore both fields. package invite import ( @@ -21,29 +26,69 @@ const prefix = "waste:" // Invite holds the information needed to join a network. type Invite struct { - Anchor string `json:"anchor"` // WebSocket anchor URL - Network string `json:"network"` // plaintext network name - Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field + Anchor string `json:"anchor"` // WebSocket anchor URL + Network string `json:"network"` // plaintext network name + Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field + Inviter string `json:"inviter,omitempty"` // hex Ed25519 pubkey of the signing peer (waste-go extension) + Sig string `json:"sig,omitempty"` // hex Ed25519 sig over canonical payload (waste-go extension) } -// Encode returns a waste: invite string for the given anchor URL and network name. +// IsSigned reports whether the invite carries a signature. +func (inv Invite) IsSigned() bool { return inv.Inviter != "" && inv.Sig != "" } + +// Signer can sign data and report its own peer ID. +type Signer interface { + Sign(data []byte) string + PeerIDHex() string +} + +// Verifier verifies an Ed25519 signature given a hex public key. +type Verifier func(publicKeyHex string, data []byte, sigHex string) error + +// Encode returns an unsigned waste: invite string (backward compatible). func Encode(anchor, network string) (string, error) { + return marshal(Invite{ + Anchor: anchor, + Network: network, + Net: NetHash(network), + }) +} + +// EncodeSigned returns a signed waste: invite string. +// The signature covers: anchor + NUL + network + NUL + net + NUL + inviter. +func EncodeSigned(anchor, network string, signer Signer) (string, error) { if anchor == "" { return "", fmt.Errorf("anchor URL is required") } if network == "" { return "", fmt.Errorf("network name is required") } - h := sha256.Sum256([]byte("yaw2-net:" + network)) - b, err := json.Marshal(Invite{ + inviter := signer.PeerIDHex() + net := NetHash(network) + sig := signer.Sign(sigPayload(anchor, network, net, inviter)) + return marshal(Invite{ Anchor: anchor, Network: network, - Net: hex.EncodeToString(h[:]), + Net: net, + Inviter: inviter, + Sig: sig, }) - if err != nil { - return "", err +} + +// Verify checks the invite signature and that the inviter is in the trusted set. +// Unsigned invites return nil — the caller decides whether to accept them. +func Verify(inv Invite, trusted map[string]bool, verify Verifier) error { + if !inv.IsSigned() { + return nil } - return prefix + base64.URLEncoding.EncodeToString(b), nil + payload := sigPayload(inv.Anchor, inv.Network, inv.Net, inv.Inviter) + if err := verify(inv.Inviter, payload, inv.Sig); err != nil { + return fmt.Errorf("invite signature invalid: %w", err) + } + if !trusted[inv.Inviter] { + return fmt.Errorf("invite signed by unknown peer %s", inv.Inviter[:16]) + } + return nil } // Decode parses a waste: invite string and returns the Invite. @@ -66,9 +111,20 @@ func Decode(s string) (Invite, error) { return inv, nil } -// NetHash returns the full 64-char hex network hash for the given name -// (SHA-256("yaw2-net:" + name)). This is the `net` field sent to the anchor. +// NetHash returns the full 64-char hex network hash for the given name. func NetHash(name string) string { h := sha256.Sum256([]byte("yaw2-net:" + name)) return hex.EncodeToString(h[:]) } + +func marshal(inv Invite) (string, error) { + b, err := json.Marshal(inv) + if err != nil { + return "", err + } + return prefix + base64.URLEncoding.EncodeToString(b), nil +} + +func sigPayload(anchor, network, net, inviter string) []byte { + return []byte(anchor + "\x00" + network + "\x00" + net + "\x00" + inviter) +} diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 5e22874..5cc48cb 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -146,7 +146,6 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) { case cmd.NetworkName != "": netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir) case len(cmd.NetworkHash) == 64: - // yaw2-compatible: join by full 64-char hex hash (net field) netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir) default: send(errMsg("join_network: network_name or network_hash (64 hex chars) required")) @@ -156,7 +155,14 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) { send(errMsg(fmt.Sprintf("join_network: %v", err))) continue } - // network_joined event (with share_dir) is emitted by Manager.Join/JoinByHash. + if n, ok := mgr.Get(netID); ok { + if cmd.RequireInvite { + n.Mesh.RequireInvite = true + } + if cmd.InviteString != "" { + n.Mesh.InviteString = cmd.InviteString + } + } _ = netID case proto.CmdLeaveNetwork: @@ -298,15 +304,15 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) { send(errMsg("generate_invite: daemon was started without -anchor flag")) continue } - inv, err := invite.Encode(mgr.AnchorURL(), n.Name) + inv, err := invite.EncodeSigned(mgr.AnchorURL(), n.Name, n.Identity) if err != nil { send(errMsg(fmt.Sprintf("generate_invite: %v", err))) continue } send(proto.IpcMessage{ - Type: proto.EvtInviteGenerated, - NetworkID: n.ID, - InviteString: inv, + Type: proto.EvtInviteGenerated, + NetworkID: n.ID, + InviteGenerated: inv, }) case proto.CmdSetShareDir: diff --git a/internal/mesh/mesh.go b/internal/mesh/mesh.go index 5720e90..7e17bd0 100644 --- a/internal/mesh/mesh.go +++ b/internal/mesh/mesh.go @@ -27,8 +27,10 @@ type PeerConn struct { type Mesh struct { Identity *crypto.Identity Store *store.Store // may be nil if persistence is disabled - ShareDir string // directory whose contents are shared with peers; "" = no sharing - DownloadDir string // directory where received files are saved + ShareDir string // directory whose contents are shared with peers; "" = no sharing + DownloadDir string // directory where received files are saved + RequireInvite bool // waste-go ext: reject peers that present no valid signed invite + InviteString string // the invite this peer used to join (sent in hello to other peers) // ScanFiles overrides ScanShareDir when set — allows the manager to inject // multi-share scanning without the mesh needing to know about shares.json. ScanFiles func() []proto.FileEntry @@ -63,6 +65,29 @@ func New(id *crypto.Identity, st *store.Store) *Mesh { } } +// trustedPeerIDs returns a set of peer IDs trusted on this network: +// all currently connected peers plus all peers in the persistent store. +func (m *Mesh) trustedPeerIDs() map[string]bool { + trusted := map[string]bool{} + // Own identity is always trusted. + trusted[string(m.Identity.PeerID())] = true + // Connected peers. + m.mu.RLock() + for id := range m.peers { + trusted[string(id)] = true + } + m.mu.RUnlock() + // Previously seen peers from the store. + if m.Store != nil { + if known, err := m.Store.KnownPeers(); err == nil { + for id := range known { + trusted[string(id)] = true + } + } + } + return trusted +} + // ScanShareDir returns the list of files in the local share directory. // Returns an empty slice if ShareDir is unset or the directory is empty. func (m *Mesh) ScanShareDir() []proto.FileEntry { diff --git a/internal/mesh/peer.go b/internal/mesh/peer.go index 5eaa479..a382c0d 100644 --- a/internal/mesh/peer.go +++ b/internal/mesh/peer.go @@ -14,6 +14,7 @@ import ( "github.com/pion/webrtc/v3" "github.com/waste-go/internal/crypto" + "github.com/waste-go/internal/invite" "github.com/waste-go/internal/proto" ) @@ -43,11 +44,12 @@ func WireDataChannel( localFP, remoteFP := dtlsFingerprints(pc) bindBytes := proto.HelloBindString(localFP, remoteFP) hello := proto.HelloMessage{ - Type: "hello", - ID: string(id.PeerID()), - Nick: id.Alias, - Caps: []string{"chat", "file"}, - Sig: id.Sign(bindBytes), + Type: "hello", + ID: string(id.PeerID()), + Nick: id.Alias, + Caps: []string{"chat", "file"}, + Sig: id.Sign(bindBytes), + Invite: m.InviteString, } helloJSON, _ := json.Marshal(hello) if err := dc.SendText(string(helloJSON)); err != nil { @@ -149,6 +151,37 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m log.Printf("peer: bad hello from %s: %v", from.Short(), err) return } + + // Invite enforcement (waste-go extension). + if m.RequireInvite { + if hello.Invite == "" { + log.Printf("peer: rejecting %s — no invite presented (RequireInvite=true)", from.Short()) + m.Emit(proto.IpcMessage{ + Type: proto.EvtError, + ErrorMessage: fmt.Sprintf("peer %s rejected: no invite", from.Short()), + }) + return + } + inv, err := invite.Decode(hello.Invite) + if err != nil || !inv.IsSigned() { + log.Printf("peer: rejecting %s — invite not signed: %v", from.Short(), err) + m.Emit(proto.IpcMessage{ + Type: proto.EvtError, + ErrorMessage: fmt.Sprintf("peer %s rejected: invite not signed", from.Short()), + }) + return + } + trusted := m.trustedPeerIDs() + if err := invite.Verify(inv, trusted, crypto.Verify); err != nil { + log.Printf("peer: rejecting %s — %v", from.Short(), err) + m.Emit(proto.IpcMessage{ + Type: proto.EvtError, + ErrorMessage: fmt.Sprintf("peer %s rejected: %v", from.Short(), err), + }) + return + } + } + // Update alias once we have the verified nick. m.mu.Lock() if conn, ok := m.peers[from]; ok { diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 56493ab..deede0f 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -139,12 +139,16 @@ type FileOffer struct { // HelloMessage is the first message sent on the "yaw" DataChannel. // The signature binds this identity to the specific DTLS session. +// The Invite field is a waste-go extension (§ waste-go/extensions.md): +// when RequireInvite is enabled on a network, peers that omit or present +// an invalid signed invite are disconnected. YAW/2-only peers ignore this field. type HelloMessage struct { - Type string `json:"type"` // always "hello" - ID string `json:"id"` // hex pubkey - Nick string `json:"nick"` // alias - Caps []string `json:"caps"` // capability list, e.g. ["chat","file"] - Sig string `json:"sig"` // hex ed25519 sig over HelloBindString + Type string `json:"type"` // always "hello" + ID string `json:"id"` // hex pubkey + Nick string `json:"nick"` // alias + Caps []string `json:"caps"` // capability list, e.g. ["chat","file"] + Sig string `json:"sig"` // hex ed25519 sig over HelloBindString + Invite string `json:"invite,omitempty"` // waste-go ext: signed waste: invite string } // HelloBindString returns the bytes the hello signature covers: @@ -276,9 +280,11 @@ type IpcMessage struct { Body string `json:"body,omitempty"` // join_network / leave_network - NetworkName string `json:"network_name,omitempty"` - NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name - ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory + NetworkName string `json:"network_name,omitempty"` + NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name + ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory + RequireInvite bool `json:"require_invite,omitempty"` // waste-go ext: reject peers without valid signed invite + InviteString string `json:"invite_string,omitempty"` // waste-go ext: the invite used to join (stored in mesh) // send_file / set_share_dir / file_complete path Path string `json:"path,omitempty"` @@ -301,7 +307,7 @@ type IpcMessage struct { // multi-network: all joined networks (additive) Networks []NetworkInfo `json:"networks,omitempty"` ErrorMessage string `json:"error_message,omitempty"` - InviteString string `json:"invite,omitempty"` + InviteGenerated string `json:"invite,omitempty"` Files []FileEntry `json:"files,omitempty"` Shares []ShareEntry `json:"shares,omitempty"` ShareNetworks []string `json:"networks,omitempty"` // for add_share command diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index f500967..9ca6982 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -54,6 +54,17 @@ export function Sidebar() { const displayId = localPeer?.id ?? masterId ?? '' const card = displayId ? makeYawCard(displayId, displayAlias) : null + function copyHangLink() { + const net = networks.find(n => n.network_id === activeNetworkId) + if (!net) return + const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } }).WASTE_CONFIG + const anchor = cfg?.signalURL ?? '' + const payload = btoa(JSON.stringify({ network: net.network_name, anchor })) + .replace(/\+/g, '-').replace(/\//g, '_') + const url = `${window.location.origin}/#waste:${payload}` + navigator.clipboard?.writeText(url) + } + function handleLogout() { const clearId = window.confirm('Also clear your identity keypair? (Cannot be undone — export a backup first if you want to keep it.)') logout(clearId) @@ -82,7 +93,12 @@ export function Sidebar() {
- Networks +
+ Networks + {activeNetworkId && ( + + )} +
{networks.map(n => (