Files
waste-go/EXTENSIONS.md
Fredrik Johansson f425e0bb8e
All checks were successful
Build / Build & release (push) Successful in 13m40s
fix: stop leaking TURN secret to browser clients
WASTE_CONFIG.turnSecret was shipped in plaintext config.js and used to
compute coturn HMAC credentials client-side in browser.ts. Anyone reading
the PWA's JS could read the secret and mint unlimited long-lived TURN
credentials, turning the relay into an open proxy.

The anchor now mints short-lived (1h) credentials server-side via a new
GET /turn-credentials endpoint (-turn-secret flag), mirroring what the
daemon already does. The browser fetches credentials instead of holding
the secret. Daemon mode was unaffected (already server-side).

Docs updated to drop turnSecret from config.js examples, document the
new nginx route, and instruct anyone with an old config.js to rotate
the coturn secret since it was previously exposed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 18:48:59 +02:00

12 KiB

waste-go Protocol Extensions

These are additive extensions to YAW/2 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:

{
  "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:

{
  "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.


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

{"type":"add_share","path":"/home/alice/Music"}                                // global
{"type":"add_share","path":"/home/alice/Docs","network_ids":["abc123"]}       // scoped
{"type":"remove_share","path":"/home/alice/Music"}
{"type":"list_shares"}

New IPC event

{"type":"shares_list","shares":[{"path":"...","networks":["*"]}]}

Persistence

shares.json in the data directory (next to identity.json). Each entry:

{ "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 + daemon mode)
Affects: ICE server configuration only, no wire changes

The browser adapter reads WASTE_CONFIG.turnURL and fetches short-lived credentials from the anchor's GET /turn-credentials endpoint (derived from WASTE_CONFIG.signalURL, or overridden via WASTE_CONFIG.turnCredentialsURL). The anchor computes the credential using HMAC-SHA1 of the username (coturn use-auth-secret scheme) — the shared secret itself is never sent to the browser. Daemon mode does the equivalent computation locally, since the daemon already holds -turn-secret server-side.

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.


EXT-006 — File Transfer Resume

Status: implemented (daemon mode)
Affects: file-accept wire message (additive field)

Motivation

A transfer interrupted mid-stream (peer disconnect, network drop) can be continued from where it left off on the next offer of the same file, avoiding a full re-download.

Protocol change

file-accept gains one optional field:

{ "type": "file-accept", "xid": "...", "resume_offset": 65536 }

resume_offset is the number of bytes the receiver already has on disk. When non-zero, the sender seeks to that byte position before streaming. Peers that don't understand this field ignore it and send from the start — the receiver detects this by comparing incoming data to expected offset and will still verify the final SHA-256, but the partial bytes from the interrupted session will be overwritten (YAW/2-only interop degrades gracefully to a full re-download, not corruption).

Receiver behaviour

  1. On file-offer, the receiver scans its download directory for a .tmp.meta sidecar whose sha256 matches the offer.
  2. If found, the corresponding .tmp file's size is the resume offset. This is sent back in file-accept.
  3. On DC open, the receiver opens the existing .tmp in append mode and re-hashes its existing bytes to restore the SHA-256 state.
  4. On DC close with all bytes received, SHA-256 is verified. Success → sidecar removed, file renamed to final path. Hash mismatch → both files removed.
  5. On DC close with fewer bytes than expected (interrupted again) → both files kept for the next resume attempt.

Sidecar format

Each in-progress .tmp file has a corresponding .tmp.meta JSON sidecar:

{ "name": "archive.zip", "sha256": "abc...", "from": "<peer-id>", "size": 1048576 }

The sidecar is written when the transfer starts and removed on completion or corruption. Interrupted transfers keep the sidecar indefinitely.


EXT-007 — P2P Message History Gossip

Status: implemented (daemon mode) Affects: peer-to-peer wire (two new message types); IPC (new event)

Motivation

When a peer joins a network for the first time (or reconnects after an absence), they have no history. This extension lets them request recent messages from an existing peer over the already-established encrypted DataChannel, without involving the anchor.

Wire messages

history_request

Sent by the newly-connected peer to the first peer whose hello is verified. One request per room.

{
  "type": "history_request",
  "room":  "general",
  "since": 1700000000000,
  "limit": 200
}
Field Type Description
room string Room to request history for.
since int64 (ms) Only return messages with ts > since. 0 = return up to limit most recent.
limit int (max 500) Maximum messages to return. Responder may return fewer.

history_chunk

{
  "type": "history_chunk",
  "room": "general",
  "history": [
    { "mid": "...", "from": "<peer-id>", "from_alias": "alice", "text": "hello", "ts": 1700000001000 }
  ],
  "history_done": true
}
Field Type Description
history array Messages, oldest-first.
history_done bool Always true (single-chunk response).

Deduplication

mid is the deduplication key. The store uses INSERT OR IGNORE on mid, so receiving a message twice (live or via gossip) is a no-op. Messages without a mid are assigned one at receive time and are not gossipped.

Behaviour

  • The receiver sends one history_request per known room immediately after hello verification with the first peer it connects to. Requesting only the first peer avoids fan-out amplification.
  • The responder queries its SQLite store and replies with a single history_chunk. limit is capped at 500 server-side. Rate-limited to one request per (peer, room) per 60 seconds.
  • Received history messages are saved to the local store (INSERT OR IGNORE) and emitted as history_loaded IPC events so the UI can display them.

IPC event

{ "type": "history_loaded", "room": "general", "messages": [...] }

Emitted once per room after a history_chunk is fully processed. The UI should render these messages with a visual separator from live messages.


EXT-008 — Message Reactions

Wire message (PeerMessage)

{
  "type": "reaction",
  "reaction_mid": "<32-hex mid of the target message>",
  "reaction_emoji": "👍"
}

Sent on the normal mesh DataChannel (same as chat). No signing beyond the existing channel-level encryption.

Semantics

  • A reaction is idempotent: the same (mid, emoji, from_peer) triple is stored with INSERT OR IGNORE — receiving a duplicate is a no-op.
  • There is no "un-react" wire message. Toggling off a reaction in the UI is a local-only operation in the current implementation.
  • reaction_mid must reference a message that exists in the local store; unknown mids are silently ignored.

Storage

SQLite table added as a migration:

CREATE TABLE IF NOT EXISTS reactions (
  mid        TEXT NOT NULL,
  emoji      TEXT NOT NULL,
  from_peer  TEXT NOT NULL,
  reacted_at DATETIME NOT NULL,
  PRIMARY KEY (mid, emoji, from_peer)
)

IPC

Command — send a reaction (daemon and browser mode):

{ "type": "send_reaction", "network_id": "...", "reaction_mid": "<hex>", "reaction_emoji": "👍" }

Event — reaction received or replayed from history:

{ "type": "reaction", "network_id": "...", "peer_id": "<64-hex>", "reaction_mid": "<hex>", "reaction_emoji": "👍" }

Stored reactions are replayed as reaction IPC events when history is loaded (sendStoredHistory), so the UI always sees reactions alongside their messages.

History replay

When sendStoredHistory sends a history_chunk, it also queries ReactionsForRoom and emits one reaction event per stored reaction so clients receive the full reaction state on reconnect.

Browser mode

browser.ts mirrors the daemon behaviour independently:

  • PeerConn.sendReaction(mid, emoji) broadcasts { type: "reaction", reaction_mid, reaction_emoji } over the DataChannel.
  • Incoming reaction wire frames are dispatched as reaction IPC events.
  • BrowserAdapter.send() handles send_reaction commands and both broadcasts to all peers and emits a local reaction event.
  • sendChat includes the mid in the wire frame so reactions can reference it correctly across peers.