Add signed invites, hang links, multi-share, and EXTENSIONS.md
- Signed invites: waste: URI gains inviter+sig fields (Ed25519); hello carries the invite so receiving peers can verify against known keys - RequireInvite per-network flag: rejects peers without valid signed invite - Hash-based hang links: #waste:base64 fragment pre-fills join form without server-side leakage of network name - Multi-share: shares.json (daemon) + waste_shares localStorage (browser); IPC add_share/remove_share/list_shares commands - EXTENSIONS.md: addendum documenting all waste-go protocol deviations from YAW/2; all extensions are additive and backward compatible Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
173
EXTENSIONS.md
Normal file
173
EXTENSIONS.md
Normal file
@@ -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": "<hex Ed25519 signature>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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": "<hex pubkey>",
|
||||||
|
"nick": "alice",
|
||||||
|
"caps": ["chat", "file"],
|
||||||
|
"sig": "<DTLS binding 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`.
|
||||||
@@ -121,6 +121,9 @@ Web frontend (React, already built) + Tauri shell for native packaging. The IPC
|
|||||||
| ✅ shipped | Session persistence + logout (browser mode) |
|
| ✅ shipped | Session persistence + logout (browser mode) |
|
||||||
| ✅ shipped | Persistent multi-share config (shares.json + localStorage) |
|
| ✅ shipped | Persistent multi-share config (shares.json + localStorage) |
|
||||||
| ✅ shipped | Subfolder support + directory browser UI in file browser |
|
| ✅ 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 | TURN relay for daemon mode |
|
||||||
| next | TUI room creation + daemon-side room persistence |
|
| next | TUI room creation + daemon-side room persistence |
|
||||||
| next | File transfer resume after disconnection |
|
| next | File transfer resume after disconnection |
|
||||||
|
|||||||
18
README.md
18
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.
|
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
|
## Terminal UI
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case proto.EvtInviteGenerated:
|
case proto.EvtInviteGenerated:
|
||||||
m.invitePopup = evt.InviteString
|
m.invitePopup = evt.InviteGenerated
|
||||||
|
|
||||||
case proto.EvtMessageReceived:
|
case proto.EvtMessageReceived:
|
||||||
if evt.Message != nil {
|
if evt.Message != nil {
|
||||||
|
|||||||
@@ -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.
|
// Sign signs data with our Ed25519 private key. Returns hex-encoded signature.
|
||||||
func (id *Identity) Sign(data []byte) string {
|
func (id *Identity) Sign(data []byte) string {
|
||||||
sig := ed25519.Sign(id.privateKey, data)
|
sig := ed25519.Sign(id.privateKey, data)
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
// 64-char hex SHA-256("yaw2-net:"+name) hash that yaw2 clients pass directly
|
// 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
|
// to the signaling server. A yaw2 client that can parse the base64 JSON can join
|
||||||
// the same network without knowing the plaintext name.
|
// 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
|
package invite
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -24,26 +29,66 @@ type Invite struct {
|
|||||||
Anchor string `json:"anchor"` // WebSocket anchor URL
|
Anchor string `json:"anchor"` // WebSocket anchor URL
|
||||||
Network string `json:"network"` // plaintext network name
|
Network string `json:"network"` // plaintext network name
|
||||||
Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field
|
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) {
|
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 == "" {
|
if anchor == "" {
|
||||||
return "", fmt.Errorf("anchor URL is required")
|
return "", fmt.Errorf("anchor URL is required")
|
||||||
}
|
}
|
||||||
if network == "" {
|
if network == "" {
|
||||||
return "", fmt.Errorf("network name is required")
|
return "", fmt.Errorf("network name is required")
|
||||||
}
|
}
|
||||||
h := sha256.Sum256([]byte("yaw2-net:" + network))
|
inviter := signer.PeerIDHex()
|
||||||
b, err := json.Marshal(Invite{
|
net := NetHash(network)
|
||||||
|
sig := signer.Sign(sigPayload(anchor, network, net, inviter))
|
||||||
|
return marshal(Invite{
|
||||||
Anchor: anchor,
|
Anchor: anchor,
|
||||||
Network: network,
|
Network: network,
|
||||||
Net: hex.EncodeToString(h[:]),
|
Net: net,
|
||||||
|
Inviter: inviter,
|
||||||
|
Sig: sig,
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
return prefix + base64.URLEncoding.EncodeToString(b), nil
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
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.
|
// Decode parses a waste: invite string and returns the Invite.
|
||||||
@@ -66,9 +111,20 @@ func Decode(s string) (Invite, error) {
|
|||||||
return inv, nil
|
return inv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetHash returns the full 64-char hex network hash for the given name
|
// 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.
|
|
||||||
func NetHash(name string) string {
|
func NetHash(name string) string {
|
||||||
h := sha256.Sum256([]byte("yaw2-net:" + name))
|
h := sha256.Sum256([]byte("yaw2-net:" + name))
|
||||||
return hex.EncodeToString(h[:])
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,7 +146,6 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
case cmd.NetworkName != "":
|
case cmd.NetworkName != "":
|
||||||
netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir)
|
netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir)
|
||||||
case len(cmd.NetworkHash) == 64:
|
case len(cmd.NetworkHash) == 64:
|
||||||
// yaw2-compatible: join by full 64-char hex hash (net field)
|
|
||||||
netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir)
|
netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir)
|
||||||
default:
|
default:
|
||||||
send(errMsg("join_network: network_name or network_hash (64 hex chars) required"))
|
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)))
|
send(errMsg(fmt.Sprintf("join_network: %v", err)))
|
||||||
continue
|
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
|
_ = netID
|
||||||
|
|
||||||
case proto.CmdLeaveNetwork:
|
case proto.CmdLeaveNetwork:
|
||||||
@@ -298,7 +304,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
send(errMsg("generate_invite: daemon was started without -anchor flag"))
|
send(errMsg("generate_invite: daemon was started without -anchor flag"))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
inv, err := invite.Encode(mgr.AnchorURL(), n.Name)
|
inv, err := invite.EncodeSigned(mgr.AnchorURL(), n.Name, n.Identity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
send(errMsg(fmt.Sprintf("generate_invite: %v", err)))
|
send(errMsg(fmt.Sprintf("generate_invite: %v", err)))
|
||||||
continue
|
continue
|
||||||
@@ -306,7 +312,7 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
send(proto.IpcMessage{
|
send(proto.IpcMessage{
|
||||||
Type: proto.EvtInviteGenerated,
|
Type: proto.EvtInviteGenerated,
|
||||||
NetworkID: n.ID,
|
NetworkID: n.ID,
|
||||||
InviteString: inv,
|
InviteGenerated: inv,
|
||||||
})
|
})
|
||||||
|
|
||||||
case proto.CmdSetShareDir:
|
case proto.CmdSetShareDir:
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ type Mesh struct {
|
|||||||
Store *store.Store // may be nil if persistence is disabled
|
Store *store.Store // may be nil if persistence is disabled
|
||||||
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
||||||
DownloadDir string // directory where received files are saved
|
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
|
// ScanFiles overrides ScanShareDir when set — allows the manager to inject
|
||||||
// multi-share scanning without the mesh needing to know about shares.json.
|
// multi-share scanning without the mesh needing to know about shares.json.
|
||||||
ScanFiles func() []proto.FileEntry
|
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.
|
// ScanShareDir returns the list of files in the local share directory.
|
||||||
// Returns an empty slice if ShareDir is unset or the directory is empty.
|
// Returns an empty slice if ShareDir is unset or the directory is empty.
|
||||||
func (m *Mesh) ScanShareDir() []proto.FileEntry {
|
func (m *Mesh) ScanShareDir() []proto.FileEntry {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/pion/webrtc/v3"
|
"github.com/pion/webrtc/v3"
|
||||||
|
|
||||||
"github.com/waste-go/internal/crypto"
|
"github.com/waste-go/internal/crypto"
|
||||||
|
"github.com/waste-go/internal/invite"
|
||||||
"github.com/waste-go/internal/proto"
|
"github.com/waste-go/internal/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ func WireDataChannel(
|
|||||||
Nick: id.Alias,
|
Nick: id.Alias,
|
||||||
Caps: []string{"chat", "file"},
|
Caps: []string{"chat", "file"},
|
||||||
Sig: id.Sign(bindBytes),
|
Sig: id.Sign(bindBytes),
|
||||||
|
Invite: m.InviteString,
|
||||||
}
|
}
|
||||||
helloJSON, _ := json.Marshal(hello)
|
helloJSON, _ := json.Marshal(hello)
|
||||||
if err := dc.SendText(string(helloJSON)); err != nil {
|
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)
|
log.Printf("peer: bad hello from %s: %v", from.Short(), err)
|
||||||
return
|
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.
|
// Update alias once we have the verified nick.
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
if conn, ok := m.peers[from]; ok {
|
if conn, ok := m.peers[from]; ok {
|
||||||
|
|||||||
@@ -139,12 +139,16 @@ type FileOffer struct {
|
|||||||
|
|
||||||
// HelloMessage is the first message sent on the "yaw" DataChannel.
|
// HelloMessage is the first message sent on the "yaw" DataChannel.
|
||||||
// The signature binds this identity to the specific DTLS session.
|
// 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 HelloMessage struct {
|
||||||
Type string `json:"type"` // always "hello"
|
Type string `json:"type"` // always "hello"
|
||||||
ID string `json:"id"` // hex pubkey
|
ID string `json:"id"` // hex pubkey
|
||||||
Nick string `json:"nick"` // alias
|
Nick string `json:"nick"` // alias
|
||||||
Caps []string `json:"caps"` // capability list, e.g. ["chat","file"]
|
Caps []string `json:"caps"` // capability list, e.g. ["chat","file"]
|
||||||
Sig string `json:"sig"` // hex ed25519 sig over HelloBindString
|
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:
|
// HelloBindString returns the bytes the hello signature covers:
|
||||||
@@ -279,6 +283,8 @@ type IpcMessage struct {
|
|||||||
NetworkName string `json:"network_name,omitempty"`
|
NetworkName string `json:"network_name,omitempty"`
|
||||||
NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name
|
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
|
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
|
// send_file / set_share_dir / file_complete path
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
@@ -301,7 +307,7 @@ type IpcMessage struct {
|
|||||||
// multi-network: all joined networks (additive)
|
// multi-network: all joined networks (additive)
|
||||||
Networks []NetworkInfo `json:"networks,omitempty"`
|
Networks []NetworkInfo `json:"networks,omitempty"`
|
||||||
ErrorMessage string `json:"error_message,omitempty"`
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
InviteString string `json:"invite,omitempty"`
|
InviteGenerated string `json:"invite,omitempty"`
|
||||||
Files []FileEntry `json:"files,omitempty"`
|
Files []FileEntry `json:"files,omitempty"`
|
||||||
Shares []ShareEntry `json:"shares,omitempty"`
|
Shares []ShareEntry `json:"shares,omitempty"`
|
||||||
ShareNetworks []string `json:"networks,omitempty"` // for add_share command
|
ShareNetworks []string `json:"networks,omitempty"` // for add_share command
|
||||||
|
|||||||
@@ -54,6 +54,17 @@ export function Sidebar() {
|
|||||||
const displayId = localPeer?.id ?? masterId ?? ''
|
const displayId = localPeer?.id ?? masterId ?? ''
|
||||||
const card = displayId ? makeYawCard(displayId, displayAlias) : null
|
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() {
|
function handleLogout() {
|
||||||
const clearId = window.confirm('Also clear your identity keypair? (Cannot be undone — export a backup first if you want to keep it.)')
|
const clearId = window.confirm('Also clear your identity keypair? (Cannot be undone — export a backup first if you want to keep it.)')
|
||||||
logout(clearId)
|
logout(clearId)
|
||||||
@@ -82,7 +93,12 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
|
<div className="sidebar-label-row">
|
||||||
<span className="sidebar-label">Networks</span>
|
<span className="sidebar-label">Networks</span>
|
||||||
|
{activeNetworkId && (
|
||||||
|
<button className="sidebar-add" onClick={copyHangLink} title="Copy hang link (pre-fills join form, no invite required)">🔗</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{networks.map(n => (
|
{networks.map(n => (
|
||||||
<button
|
<button
|
||||||
key={n.network_id}
|
key={n.network_id}
|
||||||
|
|||||||
@@ -14,11 +14,17 @@ interface Props {
|
|||||||
// ?a=<url> anchor URL hint
|
// ?a=<url> anchor URL hint
|
||||||
function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } {
|
function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } {
|
||||||
const p = new URLSearchParams(window.location.search)
|
const p = new URLSearchParams(window.location.search)
|
||||||
const inviteString = p.get('invite') ?? ''
|
let inviteString = p.get('invite') ?? ''
|
||||||
let network = p.get('n') ?? p.get('network') ?? ''
|
let network = p.get('n') ?? p.get('network') ?? ''
|
||||||
let netHash = p.get('net') ?? ''
|
let netHash = p.get('net') ?? ''
|
||||||
let anchor = p.get('a') ?? p.get('anchor') ?? ''
|
let anchor = p.get('a') ?? p.get('anchor') ?? ''
|
||||||
|
|
||||||
|
// Hash-based hang link: https://host/#waste:eyJ... (opaque, not sent to server)
|
||||||
|
const hash = window.location.hash.slice(1) // strip leading #
|
||||||
|
if (!inviteString && hash.startsWith('waste:')) {
|
||||||
|
inviteString = hash
|
||||||
|
}
|
||||||
|
|
||||||
if (inviteString.startsWith('waste:')) {
|
if (inviteString.startsWith('waste:')) {
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
|
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
|
||||||
|
|||||||
Reference in New Issue
Block a user