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 | 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 |
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<span className="sidebar-label">Networks</span>
|
||||
<div className="sidebar-label-row">
|
||||
<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 => (
|
||||
<button
|
||||
key={n.network_id}
|
||||
|
||||
@@ -14,11 +14,17 @@ interface Props {
|
||||
// ?a=<url> anchor URL hint
|
||||
function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } {
|
||||
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 netHash = p.get('net') ?? ''
|
||||
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:')) {
|
||||
try {
|
||||
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
|
||||
|
||||
Reference in New Issue
Block a user