7 Commits

Author SHA1 Message Date
Fredrik Johansson
cb80040ddb chore: gitignore web/public/config.js; fix netHash to use WebCrypto
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 13:21:12 +02:00
Fredrik Johansson
f7047b7bfe fix: align offer/answer tiebreak with yaw2/browser (lower ID offers)
The Go daemon was using higher-ID-offers while the browser adapter and
yaw2 use lower-ID-offers. This meant daemon↔browser pairs where the
daemon had the lower ID would never connect — neither side would offer.

All three comparison sites in anchor/client.go flipped: > 0 → < 0,
<= 0 → >= 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 11:47:13 +02:00
Fredrik Johansson
d529f58ddc Update README: hosting guide, browser vs daemon mode, NPM setup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:57:44 +02:00
Fredrik Johansson
bbd78ac4de Remove hardcoded anchor URL fallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:52:21 +02:00
Fredrik Johansson
de8d3ff70d Remove unsolicited deploy/ and config.example.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:52:00 +02:00
Fredrik Johansson
739c63f6b3 Add deploy/ with nginx config and web deploy script
nginx-waste.conf: serve web/dist/ at root, proxy /ws to anchor (port
8080), SPA fallback for clean invite URLs, immutable cache for assets.

deploy-web.sh: build + rsync to anchor host, skip config.js so the
host's runtime config is not clobbered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:44:46 +02:00
Fredrik Johansson
5bc16daae1 Add standalone browser mode: no daemon required
BrowserAdapter speaks yaw/2.1 signaling + WebRTC protocol entirely in
the browser (libsodium-wrappers for Ed25519/X25519). Identity stored in
localStorage; peers connect via any waste/yaw2 anchor.

When served from a non-localhost origin the app defaults to browser
mode. On localhost the daemon adapter is tried first with a one-click
switch to browser mode. config.js (gitignored, served by anchor) lets
the host pre-inject signalURL and other defaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 00:06:41 +02:00
10 changed files with 1035 additions and 233 deletions

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ launch-tui.sh
*.swp *.swp
/tui /tui
launch-web.sh launch-web.sh
web/public/config.js

376
README.md
View File

@@ -9,7 +9,7 @@ friend-to-friend encrypted mesh networking with chat and file sharing. Written i
waste-go/ waste-go/
├── cmd/ ├── cmd/
│ ├── daemon/ The peer process — run one on each friend's machine │ ├── daemon/ The peer process — run one on each friend's machine
│ ├── anchor/ WebSocket signaling server — run this on your Hetzner VPS │ ├── anchor/ WebSocket signaling server — run this on your VPS
│ └── tui/ Bubble Tea terminal UI (connects to a running daemon) │ └── tui/ Bubble Tea terminal UI (connects to a running daemon)
└── internal/ └── internal/
├── proto/ All wire types (shared by daemon and anchor) ├── proto/ All wire types (shared by daemon and anchor)
@@ -19,235 +19,225 @@ waste-go/
└── ipc/ Local JSON API (UI talks to daemon here, port 17337) └── ipc/ Local JSON API (UI talks to daemon here, port 17337)
``` ```
## Prerequisites ---
- Go 1.24+ → https://go.dev/dl/ ## Hosting on a VPS
- VS Code with the Go extension (`golang.go`)
On first open VS Code will prompt you to install `gopls`, `dlv`, and `goimports` — accept all of them. You need two things on the server: the **anchor** (signaling process) and the **web UI** (static files). Both are served through the same domain via Nginx Proxy Manager.
## Getting started ### 1. Build and run the anchor
```bash ```bash
# Fetch dependencies # On your local machine — cross-compile for Linux
go mod tidy GOOS=linux GOARCH=amd64 go build -o bin/waste-anchor ./cmd/anchor
# Build everything (confirms it compiles) # Copy to VPS
go build ./... scp bin/waste-anchor user@your-vps:~/waste-anchor
```
# Terminal 1 — anchor (required for peers to find each other) On the VPS, run the anchor and keep it alive (systemd, screen, whatever you use):
```bash
./waste-anchor -bind 127.0.0.1:8080
```
The anchor listens locally on port 8080 — Nginx Proxy Manager will expose it over TLS.
### 2. Build and upload the web UI
```bash
# On your local machine
cd web
npm install
npm run build
# Produces web/dist/
# Copy to VPS
rsync -az web/dist/ user@your-vps:/var/www/waste-web/
```
Create a `/var/www/waste-web/config.js` on the VPS (not in git — this is host-specific):
```js
window.WASTE_CONFIG = {
signalURL: 'wss://your-domain.com/ws',
}
```
This tells the browser where to connect for signaling. Without it the join form shows a blank signal server field and the user must fill it in manually.
### 3. Nginx Proxy Manager setup
Create one **Proxy Host** for your domain (e.g. `waste.example.com`) with TLS enabled. You need two locations:
**Location 1 — WebSocket signaling (`/ws`)**
- Location: `/ws`
- Forward hostname/IP: `127.0.0.1`
- Forward port: `8080`
- Enable: WebSockets Support
**Location 2 — Web UI (catch-all)**
- Location: `/`
- Choose "Serve Static Files" (or point to a local HTTP server serving `/var/www/waste-web`)
- Enable the SPA fallback so unknown paths return `index.html` — this is required for invite links to work
If NPM doesn't support static file serving directly, run a small static server on a spare port (e.g. `npx serve -s /var/www/waste-web -l 3000`) and proxy `/` to `127.0.0.1:3000`. The key requirements:
- `/ws` → anchor process (WebSocket, keep-alive)
- `/*` → static file server (SPA fallback: return `index.html` for unknown paths)
---
## How it works: daemon vs browser mode
There are two ways to use the web UI.
### Browser mode (for anyone with just a URL)
When the web UI is served from a non-localhost origin it runs entirely in the browser — no daemon, no install. Crypto (Ed25519/X25519) runs via libsodium compiled to WebAssembly. The identity seed is stored in `localStorage` and persists across sessions.
A user visits your domain, enters their name and a network name, and joins. Invite links (`waste:…` or `?n=name&a=wss://…`) pre-fill the join form.
**Identity note:** browser mode uses the master identity directly (same keypair on all networks, compatible with yaw2). The daemon derives a separate keypair per network via HKDF. A browser user and a daemon user on the same network will see each other and can chat — they just appear as different peers even if they're the same person.
### Daemon mode (for users running the daemon locally)
`launch-web.sh` starts the Go daemon and the Vite dev server. The web UI connects to the local daemon over WebSocket IPC (`ws://127.0.0.1:17338`). The daemon handles all crypto and connects to the anchor.
When the web UI is loaded from `localhost`, it defaults to daemon mode. A "Switch to browser mode" button is available in the join screen if the daemon is not running.
---
## Local development
### Prerequisites
- Go 1.24+ — https://go.dev/dl/
- Node.js 20+
### Quick start (three peers in one terminal session)
```bash
# Terminal 1 — local anchor
go run ./cmd/anchor -bind 127.0.0.1:17339 go run ./cmd/anchor -bind 127.0.0.1:17339
# Terminal 2 — peer A # Terminal 2 — peer A
go run ./cmd/daemon -alias alice -data-dir /tmp/waste-alice -ipc-port 17337 -anchor ws://127.0.0.1:17339/ws go run ./cmd/daemon -alias alice -data-dir /tmp/waste-alice -ipc-port 17337 -anchor ws://127.0.0.1:17339/ws
# Terminal 3 — peer B (or use --join with an invite from peer A) # Terminal 3 — peer B
go run ./cmd/daemon -alias bob -data-dir /tmp/waste-bob -ipc-port 17341 -anchor ws://127.0.0.1:17339/ws go run ./cmd/daemon -alias bob -data-dir /tmp/waste-bob -ipc-port 17341 -anchor ws://127.0.0.1:17339/ws
``` ```
Both peers join the same named network via IPC: Join both to a network:
```bash ```bash
# Join peer A to a network called "friends"
echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17337 echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17337
# Join peer B to the same network
echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17341 echo '{"type":"join_network","network_name":"friends"}' | nc 127.0.0.1 17341
# Subscribe to peer A's events (in a separate terminal)
nc 127.0.0.1 17337 &
# Send a message from B
echo '{"type":"send_message","room":"general","body":"hello from bob"}' | nc 127.0.0.1 17341
``` ```
**On Windows** — use PowerShell's built-in TCP client instead of `nc`: ### Web UI (daemon mode)
```powershell
$c = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 17341)
$w = [System.IO.StreamWriter]::new($c.GetStream()); $w.AutoFlush = $true
$w.WriteLine('{"type":"join_network","network_name":"friends"}')
$w.WriteLine('{"type":"send_message","room":"general","body":"hello from bob"}')
# In a separate terminal — subscribe to peer A's events
$r = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 17337)
$reader = [System.IO.StreamReader]::new($r.GetStream())
while ($true) { $reader.ReadLine() }
```
## Deploying the anchor on your Hetzner VPS
```bash ```bash
GOOS=linux GOARCH=amd64 go build -o bin/waste-anchor ./cmd/anchor # Requires a running daemon on port 17337
scp bin/waste-anchor user@your-vps:~/ ./launch-web.sh
# On the VPS (also run coturn in STUN-only mode on port 3478) # Or with a custom alias and network:
./waste-anchor -bind 0.0.0.0:17339 ALIAS=alice NETWORK=friends ./launch-web.sh
``` ```
Then start daemons with `-anchor ws://your-vps-ip:17339/ws` and they'll connect via WebRTC ### Automated test
with ICE (STUN-assisted hole punching) through the anchor for signaling.
## IPC protocol (plain JSON over TCP)
Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`.
**Commands you send:**
```jsonc
{"type":"join_network","network_name":"friends"}
{"type":"leave_network"}
{"type":"send_message","room":"general","body":"hi"}
{"type":"send_message","room":"dm:<peer-hex>","body":"hey","to":"<peer-hex>"}
{"type":"get_state"}
{"type":"get_file_list"} // own share dir
{"type":"get_file_list","peer_id":"<64-hex>"} // remote peer's share dir
{"type":"send_file","peer_id":"<64-hex>","path":"notes.txt"} // offer a file from share dir
{"type":"generate_invite"}
```
**Events the daemon pushes:**
```jsonc
// Sent immediately on connect and in response to get_state
{"type":"state_snapshot","local_peer":{"id":"<64-hex>","alias":"alice","public_key":"<64-hex>","created_at":"..."},"connected_peers":[...],"rooms":["general"]}
// Peer lifecycle
{"type":"peer_connected","peer":{"id":"<64-hex>","alias":"bob",...}}
{"type":"session_ready","peer_id":"<64-hex>","nick":"bob"}
{"type":"peer_disconnected","peer_id":"<64-hex>"}
// Incoming message — mid is a 32-hex dedup token, to is set for DMs
{"type":"message_received","message":{"mid":"<32-hex>","from":"<64-hex>","room":"general","text":"hi","ts":1700000000000}}
// File events
{"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"<32-hex>","name":"notes.txt","size":1024,"sha256":"<64-hex>"}}
{"type":"file_progress","transfer_id":"<32-hex>","bytes_received":65536,"total_bytes":1048576}
{"type":"file_complete","transfer_id":"<32-hex>","path":"/data-dir/downloads-<netid>/notes.txt"}
{"type":"file_list","peer_id":"<64-hex>","files":[{"name":"notes.txt","size_bytes":1024}]}
// Invite generation response
{"type":"invite_generated","invite":"waste:<base64>"}
// Error
{"type":"error","error_message":"..."}
```
## Crypto choices
| Purpose | Algorithm | Notes |
|---|---|---|
| Identity | Ed25519 | Fast, small keys, standard |
| Peer ID | Hex-encoded Ed25519 pubkey | 64 lowercase hex chars (YAW/2 §2) |
| Signaling encryption (2.0) | XSalsa20-Poly1305 (`nacl/box`) | X25519 keys derived from Ed25519 identity (YAW/2 §3) |
| **Signaling encryption (2.1)** | **XSalsa20-Poly1305, ephemeral X25519** | **Per-session keypair; `esk` wiped on close → forward secrecy** |
| Transport | WebRTC DataChannels (DTLS+SCTP) | pion/webrtc — ICE, hole punching included |
| Hashing | SHA-256 | File integrity, network name hashing |
Replaces WASTE's original Blowfish/PCBC (broken cipher mode) + RSA.
### Forward-secret signaling (YAW/2.1)
By default waste-go speaks **YAW/2.1**: before sending an offer each peer generates a fresh
X25519 keypair (`esk`/`epk`), broadcasts its `epk` in a signed `ekey` message, then seals
`offer`/`answer`/`candidate` payloads with the *ephemeral* keys. `esk` is zeroed when the
session ends. Recorded signaling traffic cannot be decrypted even if the long-term Ed25519
keys later leak.
A 2.0 peer ignores the `ekey` message (unknown type → silently dropped) and the offerer
falls back to static-key sealing after a 2 s timeout, so **2.1 ↔ 2.0 sessions work** — the
session just isn't forward-secret. The log line `anchor: 2.0 fallback offer to …` flags this.
> Peer IDs are 64-char lowercase hex (Ed25519 public key). Existing `identity.json` files
> on disk are unaffected — only the over-the-wire representation changed from base64url.
## Onboarding a new peer
Alice is already on the network and wants to add Bob.
**Alice generates an invite** (from the TUI with `Ctrl+I`, or via IPC directly):
```bash
echo '{"type":"generate_invite"}' | nc 127.0.0.1 17337
# → {"type":"invite_generated","invite":"waste:eyJhbmNob3IiOiJ3czovL..."}
```
**Bob starts his daemon using the invite** — the `--join` flag sets the anchor URL and auto-joins the network:
```bash
go run ./cmd/daemon -alias bob -data-dir ~/.waste-bob --join 'waste:eyJhbmNob3IiOiJ3czovL...'
```
**Bob opens the TUI**`--join` also accepts the invite to skip the `-network` flag:
```bash
go run ./cmd/tui --join 'waste:eyJhbmNob3IiOiJ3czovL...'
```
The invite encodes the anchor URL and network name as a `waste:` URI. Share it over Signal, email, or any side channel — the anchor never sees plaintext messages, so the invite leaking to a third party only lets them join the same network (which is by design: same network = mutual trust).
## Terminal UI
Start the daemon first (see Getting started above), then:
```bash
go run ./cmd/tui -network friends
```
Options:
| Flag | Default | Description |
|---|---|---|
| `-network` | *(required unless -join)* | Network name to join on startup |
| `-join` | — | `waste:` invite string — sets the network name automatically |
| `-ipc` | `17337` | Daemon IPC port |
**Layout:**
```
╭─ Rooms ──────╮╭─── #general ────────────────╮╭─ Peers ──────╮
│ ▶ #general ││ 15:04 alice hey everyone ││ ◉ alice (me) │
│ @ bob ││ 15:04 bob hi alice! ││ ● bob │
│ ││ 15:05 charlie the mesh works ││ ● charlie │
╰──────────────╯╰─────────────────────────────╯╰──────────────╯
╭─────────────────────────────────────────────────────────────╮
│ Type a message… │
╰─────────────────────────────────────────────────────────────╯
```
**Key bindings:** `Tab` / `Shift+Tab` — switch rooms · `PgUp` / `PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close invite overlay · `Ctrl+C` — quit
## Testing
A self-contained test script boots anchor + three peers, joins them to a named network, exchanges group messages and DMs, and verifies SQLite persistence:
```bash ```bash
./test-network.sh ./test-network.sh
``` ```
Data lands at `/tmp/waste-test` (wiped on each run). Inspect after a run: Boots anchor + three peers, joins them to a network, sends group messages and DMs, verifies SQLite persistence.
---
## Onboarding a new peer
Alice generates an invite (TUI: `Ctrl+I`, or via IPC):
```bash ```bash
# DB name includes the network ID (first 8 hex chars of sha256("yaw2-net:"+name)) echo '{"type":"generate_invite"}' | nc 127.0.0.1 17337
sqlite3 /tmp/waste-test/alice/messages-<netid>.db # → {"type":"invite_generated","invite":"waste:eyJ..."}
.headers on
SELECT room, from_peer, text, sent_at FROM messages;
SELECT peer_id, alias, last_seen FROM peers;
``` ```
There is also a TUI integration test that boots the same three-peer network and Bob joins using the invite:
launches the Bubble Tea UI as alice:
```bash ```bash
./test-tui.sh go run ./cmd/daemon -alias bob -data-dir ~/.waste-bob --join 'waste:eyJ...'
go run ./cmd/tui --join 'waste:eyJ...'
``` ```
## Roadmap The invite encodes the anchor URL and network name. Sharing it only lets the recipient join the same network — the anchor never sees plaintext messages.
- [x] **Crypto layer** — hex peer IDs, `nacl/box` signaling, Ed25519→X25519 key derivation Invite links also work in the web UI. Share `https://your-domain.com/?invite=waste:eyJ...` and the join form is pre-filled.
- [x] **Proto additions**`mid` dedup field, signaling types, anchor wire types, `hello` message
- [x] **Anchor server** (`cmd/anchor`) — WebSocket signaling server replacing TCP relay ---
- [x] **WebRTC peer connections** — pion/webrtc DataChannels; ICE hole-punching via STUN
- [x] **Anchor client** (`internal/anchor`) — offer/answer/candidate lifecycle, `nacl/box` sealing ## Terminal UI
- [x] **IPC updates**`join_network`/`leave_network`; `session_ready` event; DMs via `to` field
- [x] **Message persistence** — SQLite (`internal/store`); messages and peer alias cache ```bash
- [x] **TUI** — Bubble Tea terminal UI (`cmd/tui`); three-pane layout with room switching and DMs go run ./cmd/tui -network friends
- [x] **File transfer** — chunked binary DataChannel (`f:<xid>`); SHA-256 verified; backpressure; auto-accept ```
- [x] **Forward-secret signaling (YAW/2.1)** — ephemeral X25519 per session; `esk` wiped on close; 2.0 fallback
- [ ] **Native UI** — web frontend with native packaging (Tauri-style) | Flag | Default | Description |
|---|---|---|
| `-network` | *(required unless -join)* | Network name to join on startup |
| `-join` | — | `waste:` invite string |
| `-ipc` | `17337` | Daemon IPC port |
**Key bindings:** `Tab`/`Shift+Tab` — switch rooms · `PgUp`/`PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close overlay · `Ctrl+C` — quit
---
## IPC protocol
Newline-delimited JSON on TCP port 17337 (or WebSocket on 17338).
**Commands:**
```jsonc
{"type":"join_network","network_name":"friends"}
{"type":"send_message","room":"general","body":"hi"}
{"type":"send_message","to":"<64-hex>","body":"hey"} // DM
{"type":"generate_invite"}
{"type":"get_state"}
{"type":"get_file_list"}
{"type":"get_file_list","peer_id":"<64-hex>"}
{"type":"send_file","peer_id":"<64-hex>","path":"notes.txt"}
{"type":"export_identity","passphrase":"..."}
{"type":"import_identity","passphrase":"...","backup":"..."}
```
**Events:**
```jsonc
{"type":"state_snapshot","local_peer":{...},"connected_peers":[...],"master_alias":"alice","master_id":"<64-hex>"}
{"type":"peer_connected","peer":{"id":"<64-hex>","alias":"bob"}}
{"type":"session_ready","peer_id":"<64-hex>","nick":"bob"}
{"type":"peer_disconnected","peer_id":"<64-hex>"}
{"type":"message_received","message":{"mid":"<32-hex>","from":"<64-hex>","room":"general","text":"hi","ts":1700000000000}}
{"type":"network_joined","network_id":"...","network_name":"friends"}
{"type":"invite_generated","invite":"waste:<base64>"}
{"type":"incoming_file","peer_id":"<64-hex>","offer":{"xid":"...","name":"notes.txt","size":1024,"sha256":"..."}}
{"type":"file_complete","transfer_id":"...","path":"/downloads/notes.txt"}
{"type":"identity_exported","backup":"..."}
{"type":"error","error_message":"..."}
```
---
## Crypto
| Purpose | Algorithm |
|---|---|
| Identity | Ed25519 |
| Signaling (2.0) | XSalsa20-Poly1305, X25519 keys derived from Ed25519 |
| Signaling (2.1) | XSalsa20-Poly1305, ephemeral X25519 per session (forward secrecy) |
| Transport | WebRTC DataChannels (DTLS+SCTP via pion/webrtc) |
| File integrity | SHA-256 |
### Forward-secret signaling (YAW/2.1)
Each peer generates a fresh X25519 keypair per session and broadcasts the public half in a signed `ekey` message before sending an offer. The `esk` is zeroed when the session ends. A 2.0 peer ignores `ekey` and the offerer falls back to static-key sealing after 2 s — so 2.1↔2.0 sessions work, just without forward secrecy.

View File

@@ -122,9 +122,8 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
if already { if already {
continue continue
} }
// Use the same lexicographic tiebreak as anchor join to avoid // Lower ID offers (matches yaw2/browser convention).
// both sides trying to offer simultaneously. if strings.Compare(string(id.PeerID()), string(pid)) >= 0 {
if strings.Compare(string(id.PeerID()), string(pid)) <= 0 {
continue continue
} }
go func(pid proto.PeerID) { go func(pid proto.PeerID) {
@@ -170,7 +169,7 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
log.Printf("anchor: joined network, %d peer(s) present", len(msg.Peers)) log.Printf("anchor: joined network, %d peer(s) present", len(msg.Peers))
for _, peerHex := range msg.Peers { for _, peerHex := range msg.Peers {
pid := proto.PeerID(peerHex) pid := proto.PeerID(peerHex)
if strings.Compare(string(id.PeerID()), peerHex) > 0 { if strings.Compare(string(id.PeerID()), peerHex) < 0 {
go func(pid proto.PeerID) { go func(pid proto.PeerID) {
sess, err := startOffer(ctx, pid, id, m, s) sess, err := startOffer(ctx, pid, id, m, s)
if err != nil { if err != nil {
@@ -187,7 +186,7 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
case proto.AnchorPeerJoin: case proto.AnchorPeerJoin:
pid := proto.PeerID(msg.ID) pid := proto.PeerID(msg.ID)
log.Printf("anchor: peer joined: %s", pid.Short()) log.Printf("anchor: peer joined: %s", pid.Short())
if strings.Compare(string(id.PeerID()), msg.ID) > 0 { if strings.Compare(string(id.PeerID()), msg.ID) < 0 {
go func(pid proto.PeerID) { go func(pid proto.PeerID) {
sess, err := startOffer(ctx, pid, id, m, s) sess, err := startOffer(ctx, pid, id, m, s)
if err != nil { if err != nil {

View File

@@ -4,10 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title> <title>waste</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<!-- Optional runtime config (anchor host puts config.js here; silently absent in dev) -->
<script src="/config.js" onerror="void 0"></script>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

16
web/package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "web", "name": "web",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"libsodium-wrappers": "^0.8.4",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"zustand": "^5.0.14" "zustand": "^5.0.14"
@@ -1883,6 +1884,21 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/libsodium": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.8.4.tgz",
"integrity": "sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==",
"license": "ISC"
},
"node_modules/libsodium-wrappers": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.8.4.tgz",
"integrity": "sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==",
"license": "ISC",
"dependencies": {
"libsodium": "^0.8.0"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"libsodium-wrappers": "^0.8.4",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"zustand": "^5.0.14" "zustand": "^5.0.14"

View File

@@ -4,15 +4,21 @@ import { Onboarding } from './pages/Onboarding'
import { Chat } from './pages/Chat' import { Chat } from './pages/Chat'
import './App.css' import './App.css'
// Default to local daemon WS IPC. Override with VITE_DAEMON_WS env var. // When served from the anchor (non-localhost), default to browser mode.
// When running locally, try the daemon first.
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const DAEMON_WS = import.meta.env.VITE_DAEMON_WS ?? 'ws://127.0.0.1:17338' const DAEMON_WS = import.meta.env.VITE_DAEMON_WS ?? 'ws://127.0.0.1:17338'
export default function App() { export default function App() {
const { connect, daemonStatus, localPeer } = useWaste() const { connect, connectBrowser, daemonStatus, localPeer } = useWaste()
useEffect(() => { useEffect(() => {
connect(DAEMON_WS) if (isLocal) {
}, [connect]) connect(DAEMON_WS)
} else {
connectBrowser()
}
}, [connect, connectBrowser])
if (daemonStatus !== 'connected' || !localPeer) { if (daemonStatus !== 'connected' || !localPeer) {
return <Onboarding status={daemonStatus} /> return <Onboarding status={daemonStatus} />

707
web/src/adapter/browser.ts Normal file
View File

@@ -0,0 +1,707 @@
// BrowserAdapter — standalone in-browser waste client.
// Speaks the yaw/2.1 signaling + WebRTC protocol; emits IpcMessage events
// identical to DaemonAdapter so the store and UI need no changes.
//
// No daemon required. Identity is Ed25519, stored as hex seed in localStorage.
// Crypto via libsodium-wrappers.
import sodium from 'libsodium-wrappers'
import type { IpcMessage, PeerInfo } from '../types'
type Listener = (msg: IpcMessage) => void
type Status = 'disconnected' | 'connecting' | 'connected'
const BIND_PREFIX = 'yaw/2 bind'
const EKEY_PREFIX = 'yaw/2.1 ekey'
const FS_TIMEOUT = 2000
const STUN = 'stun:stun.l.google.com:19302'
const enc = (s: string) => new TextEncoder().encode(s)
function concat(...arrs: Uint8Array[]): Uint8Array {
const n = arrs.reduce((a, b) => a + b.length, 0)
const out = new Uint8Array(n); let o = 0
for (const a of arrs) { out.set(a, o); o += a.length }
return out
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
}
async function netHash(name: string): Promise<string> {
const input = enc('yaw2-net:' + name)
// Use native SHA-256 for protocol-compatible network IDs.
if (globalThis.crypto?.subtle) {
const digest = await globalThis.crypto.subtle.digest('SHA-256', input)
return toHex(new Uint8Array(digest))
}
// Fallback for environments where subtle crypto is unavailable.
const sha256 = (sodium as unknown as { crypto_hash_sha256?: (m: Uint8Array) => Uint8Array }).crypto_hash_sha256
if (sha256) return sodium.to_hex(sha256(input))
throw new Error('SHA-256 not available in this browser runtime')
}
// ── Identity ─────────────────────────────────────────────────────────────────
class Identity {
pub: Uint8Array
priv: Uint8Array
id: string
curvePriv: Uint8Array
nick: string
constructor(kp: { publicKey: Uint8Array; privateKey: Uint8Array }) {
this.pub = kp.publicKey
this.priv = kp.privateKey
this.id = sodium.to_hex(this.pub)
this.curvePriv = sodium.crypto_sign_ed25519_sk_to_curve25519(this.priv)
this.nick = localStorage.getItem('waste_nick') || ''
}
static load(): Identity {
const seedHex = localStorage.getItem('waste_seed')
let kp
if (seedHex) {
kp = sodium.crypto_sign_seed_keypair(sodium.from_hex(seedHex))
} else {
kp = sodium.crypto_sign_keypair()
localStorage.setItem('waste_seed', sodium.to_hex(kp.privateKey.slice(0, 32)))
}
return new Identity(kp)
}
setNick(nick: string) {
this.nick = nick.trim().slice(0, 40)
localStorage.setItem('waste_nick', this.nick)
}
sign(data: Uint8Array): Uint8Array {
return sodium.crypto_sign_detached(data, this.priv)
}
static verify(idHex: string, data: Uint8Array, sig: Uint8Array): boolean {
try { return sodium.crypto_sign_verify_detached(sig, data, sodium.from_hex(idHex)) }
catch { return false }
}
seal(recipIdHex: string, plaintext: Uint8Array): string {
const pub = sodium.crypto_sign_ed25519_pk_to_curve25519(sodium.from_hex(recipIdHex))
const nonce = sodium.randombytes_buf(24)
const ct = sodium.crypto_box_easy(plaintext, nonce, pub, this.curvePriv)
return sodium.to_base64(concat(nonce, ct), sodium.base64_variants.ORIGINAL)
}
open(senderIdHex: string, boxB64: string): Uint8Array | null {
const pub = sodium.crypto_sign_ed25519_pk_to_curve25519(sodium.from_hex(senderIdHex))
const box = sodium.from_base64(boxB64, sodium.base64_variants.ORIGINAL)
try { return sodium.crypto_box_open_easy(box.slice(24), box.slice(0, 24), pub, this.curvePriv) }
catch { return null }
}
exportBackup(passphrase: string): object {
const BK_OPS = 2, BK_MEM = 67108864
const b64 = (b: Uint8Array) => sodium.to_base64(b, sodium.base64_variants.ORIGINAL)
const seed = this.priv.slice(0, 32)
const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES)
const key = sodium.crypto_pwhash(
sodium.crypto_secretbox_KEYBYTES, passphrase, salt,
BK_OPS, BK_MEM, sodium.crypto_pwhash_ALG_ARGON2ID13
)
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES)
const ct = sodium.crypto_secretbox_easy(seed, nonce, key)
return {
yaw: 'yaw-key-backup-1', id: this.id, alg: 'argon2id-secretbox',
ops: BK_OPS, mem: BK_MEM, salt: b64(salt), nonce: b64(nonce), ct: b64(ct)
}
}
static importBackup(b: Record<string, unknown>, passphrase: string): Identity {
if (!b || b['yaw'] !== 'yaw-key-backup-1') throw new Error('not a yaw key backup')
const ub = (s: unknown) => sodium.from_base64(s as string, sodium.base64_variants.ORIGINAL)
const key = sodium.crypto_pwhash(
sodium.crypto_secretbox_KEYBYTES, passphrase, ub(b['salt']),
b['ops'] as number | 0, b['mem'] as number | 0, sodium.crypto_pwhash_ALG_ARGON2ID13
)
const seed = sodium.crypto_secretbox_open_easy(ub(b['ct']), ub(b['nonce']), key)
localStorage.setItem('waste_seed', sodium.to_hex(seed))
return new Identity(sodium.crypto_sign_seed_keypair(seed))
}
get short(): string {
return this.id.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()
}
toPeerInfo(networkId = ''): PeerInfo {
return {
id: this.id, alias: this.nick || this.short, public_key: this.id,
created_at: new Date().toISOString(), network_id: networkId
} as PeerInfo & { network_id?: string }
}
}
// ── Signaling ─────────────────────────────────────────────────────────────────
class Signaling {
private ws: WebSocket | null = null
private _closed = false
private _backoff = 1000
private _cbs: {
onFrom?: (from: string, box: string) => void
onJoin?: (id: string) => void
onLeave?: (id: string) => void
onReconnect?: (peers: string[]) => void
} = {}
private url: string
private identity: Identity
private net: string
constructor(url: string, identity: Identity, net: string) {
this.url = url
this.identity = identity
this.net = net
}
connect(
onFrom: (from: string, box: string) => void,
onJoin: (id: string) => void,
onLeave: (id: string) => void,
onReconnect: (peers: string[]) => void,
): Promise<string[]> {
this._cbs = { onFrom, onJoin, onLeave, onReconnect }
return this._open(true)
}
private _open(initial: boolean): Promise<string[]> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(this.url)
this.ws = ws
let joined = false
ws.onerror = () => { if (initial && !joined) reject(new Error('signaling connection failed')) }
ws.onclose = () => { if (!this._closed) this._scheduleReconnect() }
ws.onmessage = (ev) => {
let m: Record<string, unknown>
try { m = JSON.parse(ev.data) } catch { return }
if (m['type'] === 'challenge') {
const nonce = sodium.from_hex(m['nonce'] as string)
const sig = sodium.to_hex(this.identity.sign(concat(nonce, enc(this.net))))
ws.send(JSON.stringify({ type: 'join', id: this.identity.id, net: this.net, sig }))
} else if (m['type'] === 'joined') {
joined = true; this._backoff = 1000
const peers = (m['peers'] as string[]) || []
if (initial) resolve(peers)
else this._cbs.onReconnect?.(peers)
} else if (m['type'] === 'from') {
this._cbs.onFrom?.(m['from'] as string, m['box'] as string)
} else if (m['type'] === 'peer-join') {
this._cbs.onJoin?.(m['id'] as string)
} else if (m['type'] === 'peer-leave') {
this._cbs.onLeave?.(m['id'] as string)
}
}
})
}
private _scheduleReconnect() {
if (this._closed) return
const delay = this._backoff
this._backoff = Math.min(this._backoff * 2, 30000)
setTimeout(() => { if (!this._closed) this._open(false).catch(() => { }) }, delay)
}
sendTo(toId: string, box: string) {
try { this.ws?.send(JSON.stringify({ type: 'to', to: toId, box })) } catch { }
}
close() { this._closed = true; this.ws?.close() }
}
// ── Peer connection (yaw/2.1) ─────────────────────────────────────────────────
type PeerCallback = (event: string, data: Record<string, unknown>) => void
class PeerConn {
pc: RTCPeerConnection
dc: RTCDataChannel | null = null
verified = false
peerAuthed = false
created = Date.now()
private _esk: Uint8Array | null
private _epk: Uint8Array | null
peer_epk: Uint8Array | null = null
private _ekeySent = false
private _offerPending = false
private _offered = false
private identity: Identity
private sig: Signaling
public peerId: string
private on: PeerCallback
private nick: string
constructor(identity: Identity, sig: Signaling, peerId: string, on: PeerCallback, nick: string) {
this.identity = identity
this.sig = sig
this.peerId = peerId
this.on = on
this.nick = nick
this.pc = new RTCPeerConnection({ iceServers: [{ urls: STUN }] })
const kp = sodium.crypto_box_keypair()
this._esk = kp.privateKey
this._epk = kp.publicKey
this.pc.ondatachannel = (ev) => this._wire(ev.channel)
this.pc.onconnectionstatechange = () =>
this.on('status', { peer: this.peerId, state: this.pc.connectionState })
}
private _seal(obj: object, preferEph: boolean): [string, boolean] {
const data = enc(JSON.stringify(obj))
if (preferEph && this.peer_epk && this._esk) {
const nonce = sodium.randombytes_buf(24)
const ct = sodium.crypto_box_easy(data, nonce, this.peer_epk, this._esk)
return [sodium.to_base64(concat(nonce, ct), sodium.base64_variants.ORIGINAL), true]
}
return [this.identity.seal(this.peerId, data), false]
}
private _open(box: string): [Uint8Array | null, boolean] {
if (this.peer_epk && this._esk) {
try {
const raw = sodium.from_base64(box, sodium.base64_variants.ORIGINAL)
return [sodium.crypto_box_open_easy(raw.slice(24), raw.slice(0, 24), this.peer_epk, this._esk), true]
} catch { }
}
return [this.identity.open(this.peerId, box), false]
}
private _sendEkey() {
if (this._ekeySent || !this._epk) return
this._ekeySent = true
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.identity.id),
sodium.from_hex(this.peerId), this._epk)
const msg = {
kind: 'ekey', v: 'yaw/2.1', epk: sodium.to_hex(this._epk),
sig: sodium.to_hex(this.identity.sign(signed))
}
const [box] = this._seal(msg, false)
this.sig.sendTo(this.peerId, box)
}
private async _onEkey(obj: Record<string, unknown>) {
if (this.peer_epk) return
try {
const epkRaw = sodium.from_hex(obj['epk'] as string)
const sig = sodium.from_hex(obj['sig'] as string)
const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.peerId),
sodium.from_hex(this.identity.id), epkRaw)
if (epkRaw.length !== 32 || !Identity.verify(this.peerId, signed, sig)) return
this.peer_epk = epkRaw
} catch { return }
this._sendEkey()
if (this._offerPending) await this._doOffer()
}
async startOffer() {
this._sendEkey()
this._offerPending = true
setTimeout(() => { if (this._offerPending) this._doOffer() }, FS_TIMEOUT)
if (this.peer_epk) await this._doOffer()
}
private async _doOffer() {
if (this._offered) return
this._offered = true; this._offerPending = false
this.dc = this.pc.createDataChannel('yaw')
this._wire(this.dc)
await this.pc.setLocalDescription(await this.pc.createOffer())
await gatherComplete(this.pc)
const [box] = this._seal({ kind: 'offer', sdp: this.pc.localDescription!.sdp }, true)
this.sig.sendTo(this.peerId, box)
}
async onBox(box: string) {
const [plain, usedEph] = this._open(box)
if (!plain) return
this.peerAuthed = true
let obj: Record<string, unknown>
try { obj = JSON.parse(new TextDecoder().decode(plain)) } catch { return }
if (obj['kind'] === 'ekey') {
await this._onEkey(obj)
} else if (obj['kind'] === 'offer') {
await this.pc.setRemoteDescription({ type: 'offer', sdp: obj['sdp'] as string })
await this.pc.setLocalDescription(await this.pc.createAnswer())
await gatherComplete(this.pc)
const [box2] = this._seal({ kind: 'answer', sdp: this.pc.localDescription!.sdp }, usedEph)
this.sig.sendTo(this.peerId, box2)
} else if (obj['kind'] === 'answer') {
await this.pc.setRemoteDescription({ type: 'answer', sdp: obj['sdp'] as string })
}
}
private _wire(channel: RTCDataChannel) {
if (channel.label !== 'yaw') return
this.dc = channel
channel.onopen = () => this._sendHello()
channel.onmessage = (ev) => this._onControl(ev.data)
if (channel.readyState === 'open') this._sendHello()
}
private _sendHello() {
const bind = enc(BIND_PREFIX)
const caps: string[] = []
this._dc({
type: 'hello', id: this.identity.id, nick: this.nick, caps,
sig: sodium.to_hex(this.identity.sign(bind))
})
}
private _onControl(data: string) {
let m: Record<string, unknown>
try { m = JSON.parse(data) } catch { return }
if (m['type'] === 'hello') {
let reason = ''
if (m['id'] !== this.peerId) reason = 'id mismatch'
else if (!this.peerAuthed) reason = 'unauthenticated signaling'
this.verified = !reason
this.on('connected', {
peer: this.peerId, verified: this.verified,
nick: m['nick'] as string || '', caps: m['caps'] || []
})
} else if (m['type'] === 'chat') {
this.on('chat', {
peer: this.peerId, room: m['room'] as string || 'general',
text: m['text'] as string, ts: m['ts'] as number || Date.now()
})
} else if (m['type'] === 'pm') {
this.on('pm', { peer: this.peerId, text: m['text'] as string, ts: m['ts'] as number || Date.now() })
}
}
sendChat(room: string, text: string) {
this._dc({ type: 'chat', room, text, ts: Date.now() })
}
sendPm(text: string) {
this._dc({ type: 'pm', text, ts: Date.now() })
}
private _dc(obj: object) {
if (this.dc?.readyState === 'open') this.dc.send(JSON.stringify(obj))
}
close() { try { this.pc.close() } catch { } }
}
function gatherComplete(pc: RTCPeerConnection): Promise<void> {
if (pc.iceGatheringState === 'complete') return Promise.resolve()
return new Promise((res) => {
const check = () => {
if (pc.iceGatheringState === 'complete') {
pc.removeEventListener('icegatheringstatechange', check)
res()
}
}
pc.addEventListener('icegatheringstatechange', check)
setTimeout(res, 6000)
})
}
// ── BrowserAdapter ────────────────────────────────────────────────────────────
export class BrowserAdapter {
private listeners: Listener[] = []
private identity: Identity | null = null
private sig: Signaling | null = null
private peers: Map<string, PeerConn> = new Map()
private present: Set<string> = new Set()
private networkId = ''
private networkName = ''
status: Status = 'disconnected'
onStatusChange?: (s: Status) => void
private setStatus(s: Status) {
this.status = s
this.onStatusChange?.(s)
}
private emit(msg: IpcMessage) {
this.listeners.forEach(l => l(msg))
}
on(listener: Listener) {
this.listeners.push(listener)
return () => { this.listeners = this.listeners.filter(l => l !== listener) }
}
// Call after connect() to set/update the nick shown to peers
setNick(nick: string) {
this.identity?.setNick(nick)
if (this.identity) {
this.emit({
type: 'state_snapshot',
master_alias: this.identity.nick || this.identity.short,
master_id: this.identity.id,
rooms: ['general'],
networks: this.networkId ? [{
network_id: this.networkId,
network_name: this.networkName,
local_peer: this.identity.toPeerInfo(this.networkId),
}] : [],
})
}
}
async connect() {
await sodium.ready
this.identity = Identity.load()
this.setStatus('connecting')
// Emit identity immediately — UI can show alias before network join
this.emit({
type: 'state_snapshot',
master_alias: this.identity.nick || this.identity.short,
master_id: this.identity.id,
rooms: ['general'],
networks: [],
})
// Mark connected — onboarding shows join form
this.setStatus('connected')
}
disconnect() {
this.sig?.close()
this.sig = null
this.peers.forEach(p => p.close())
this.peers.clear()
this.present.clear()
this.networkId = ''
this.networkName = ''
this.setStatus('disconnected')
}
async joinNetwork(anchorUrl: string, nameOrHash: string, isHash = false) {
if (!this.identity) return
const hash = isHash ? nameOrHash : await netHash(nameOrHash)
this.networkId = hash.slice(0, 16)
this.networkName = isHash ? hash.slice(0, 16) : nameOrHash
this.sig?.close()
this.peers.forEach(p => p.close())
this.peers.clear()
this.present.clear()
const sig = new Signaling(anchorUrl, this.identity, hash)
this.sig = sig
try {
const present = await sig.connect(
(from, box) => this._onFrom(from, box),
(pid) => { this.present.add(pid); this._tryConnect(pid) },
(pid) => {
this.present.delete(pid)
this.peers.get(pid)?.close()
this.peers.delete(pid)
this.emit({ type: 'peer_disconnected', peer_id: pid as unknown as import('../types').PeerID })
},
(peers) => {
this.present = new Set(peers)
peers.forEach(pid => this._tryConnect(pid))
},
)
for (const pid of present) {
this.present.add(pid)
await this._tryConnect(pid)
}
const localPeer = this.identity.toPeerInfo(this.networkId)
this.emit({
type: 'network_joined',
network_id: this.networkId,
network_name: this.networkName,
local_peer: localPeer,
networks: [{ network_id: this.networkId, network_name: this.networkName, local_peer: localPeer }],
})
setInterval(() => { this.present.forEach(pid => this._tryConnect(pid)) }, 4000)
} catch (err) {
this.emit({ type: 'error', error_message: `signaling: ${err}` })
}
}
private async _tryConnect(pid: string) {
if (!this.identity || pid === this.identity.id) return
if (this.identity.id >= pid) return // lower ID answers; higher ID offers
const existing = this.peers.get(pid)
if (existing) {
const open = existing.dc?.readyState === 'open'
const state = existing.pc.connectionState
const alive = state !== 'failed' && state !== 'closed'
const fresh = Date.now() - existing.created < 12000
if (existing.verified || open || (alive && fresh)) return
}
const peer = new PeerConn(this.identity, this.sig!, pid,
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick)
this.peers.set(pid, peer)
await peer.startOffer()
}
private async _onFrom(from: string, box: string) {
if (!this.identity || from === this.identity.id) return
let peer = this.peers.get(from)
if (!peer || peer.pc.connectionState === 'failed' || peer.pc.connectionState === 'closed') {
peer = new PeerConn(this.identity, this.sig!, from,
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick)
this.peers.set(from, peer)
}
await peer.onBox(box)
}
private _onPeerEvent(event: string, data: Record<string, unknown>) {
if (event === 'connected') {
const nick = (data['nick'] as string) || (data['peer'] as string).slice(0, 16)
this.emit({
type: 'peer_connected',
peer: {
id: data['peer'] as string, alias: nick || (data['peer'] as string).slice(0, 8),
public_key: data['peer'] as string, created_at: new Date().toISOString()
},
})
} else if (event === 'chat') {
const ts = (data['ts'] as number) || Date.now()
const mid = `${data['peer']}-${ts}`
this.emit({
type: 'message_received',
network_id: this.networkId,
message: {
mid,
from: data['peer'] as unknown as import('../types').PeerID,
room: (data['room'] as string) || 'general',
text: data['text'] as string,
ts,
},
})
} else if (event === 'pm') {
const ts = (data['ts'] as number) || Date.now()
const from = data['peer'] as string
this.emit({
type: 'message_received',
network_id: this.networkId,
message: {
mid: `${from}-${ts}`,
from: from as unknown as import('../types').PeerID,
room: `dm:${from.slice(0, 8)}`,
text: data['text'] as string,
ts,
},
})
}
}
send(msg: IpcMessage) {
if (!this.identity) return
if (msg.type === 'join_network') {
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } })['WASTE_CONFIG']
const anchorUrl = cfg?.signalURL
|| localStorage.getItem('waste_anchor_url')
|| ''
const isHash = !msg.network_name && !!msg.network_hash
const nameOrHash = msg.network_name || msg.network_hash || ''
if (nameOrHash) this.joinNetwork(anchorUrl, nameOrHash, isHash)
return
}
if (msg.type === 'send_message') {
const room = msg.room || 'general'
const text = msg.body || ''
if (!text) return
const ts = Date.now()
const mid = `local-${ts}`
if (msg.to) {
// DM
const pid = msg.to as string
this.peers.get(pid)?.sendPm(text)
this.emit({
type: 'message_received',
network_id: this.networkId,
message: {
mid, from: this.identity.id as unknown as import('../types').PeerID,
to: msg.to, room: `dm:${pid.slice(0, 8)}`, text, ts
},
})
} else {
// Broadcast
this.peers.forEach(p => p.sendChat(room, text))
this.emit({
type: 'message_received',
network_id: this.networkId,
message: {
mid, from: this.identity.id as unknown as import('../types').PeerID,
room, text, ts
},
})
}
return
}
if (msg.type === 'export_identity') {
if (!msg.passphrase) return
try {
const backup = this.identity.exportBackup(msg.passphrase)
this.emit({ type: 'identity_exported', backup: JSON.stringify(backup, null, 2) })
} catch (err) {
this.emit({ type: 'error', error_message: `export_identity: ${err}` })
}
return
}
if (msg.type === 'import_identity') {
if (!msg.passphrase || !msg.backup) return
try {
const parsed = JSON.parse(msg.backup)
const newId = Identity.importBackup(parsed, msg.passphrase)
this.identity = newId
this.emit({ type: 'identity_imported' })
this.emit({
type: 'state_snapshot',
master_alias: newId.nick || newId.short,
master_id: newId.id,
rooms: ['general'],
networks: [],
})
} catch (err) {
this.emit({ type: 'error', error_message: `import_identity: ${err}` })
}
return
}
if (msg.type === 'generate_invite') {
const anchor = localStorage.getItem('waste_anchor_url') || 'wss://waste.dev.xplwd.com/ws'
const name = this.networkName
if (!name || name === this.networkId) {
this.emit({ type: 'error', error_message: 'generate_invite: joined by hash — no network name available' })
return
}
const h = netHash(name)
const payload = btoa(JSON.stringify({ anchor, network: name, net: h }))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
this.emit({ type: 'invite_generated', invite: `waste:${payload}`, network_id: this.networkId })
return
}
}
}

View File

@@ -10,8 +10,8 @@ interface Props {
// ?invite=waste:<b64> waste: invite string (anchor + network name + net hash) // ?invite=waste:<b64> waste: invite string (anchor + network name + net hash)
// ?n=<name> network name shorthand // ?n=<name> network name shorthand
// ?network=<name> network name // ?network=<name> network name
// ?net=<64hex> yaw2-style full network hash (joined without plaintext name) // ?net=<64hex> yaw2-style full network hash
// ?a=<url> anchor URL hint (informational — daemon controls its anchor) // ?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') ?? '' const inviteString = p.get('invite') ?? ''
@@ -21,7 +21,6 @@ function parseInviteParams(): { network: string; netHash: string; anchor: string
if (inviteString.startsWith('waste:')) { if (inviteString.startsWith('waste:')) {
try { try {
// URL-safe base64 → standard base64
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/'))) const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
network = network || json.network || '' network = network || json.network || ''
netHash = netHash || json.net || '' netHash = netHash || json.net || ''
@@ -29,33 +28,36 @@ function parseInviteParams(): { network: string; netHash: string; anchor: string
} catch { /* ignore bad invite */ } } catch { /* ignore bad invite */ }
} }
// URL path: /<16hex>/ — short network ID from anchor-served URL
const pathMatch = window.location.pathname.match(/\/([0-9a-f]{16})\/?$/i)
if (pathMatch && !netHash && !network) {
// Only the short ID — can't join by short ID alone (need full hash for HKDF).
// Store it as a hint; the user must supply the network name OR a full 64-char hash.
}
return { network, netHash, anchor, inviteString } return { network, netHash, anchor, inviteString }
} }
const DEFAULT_ANCHOR = (() => {
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } })['WASTE_CONFIG']
return cfg?.signalURL ?? localStorage.getItem('waste_anchor_url') ?? ''
})()
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
export function Onboarding({ status }: Props) { export function Onboarding({ status }: Props) {
const { send, masterAlias, masterId, exportedBackup } = useWaste() const { send, connect, connectBrowser, adapterMode, masterAlias, masterId, exportedBackup } = useWaste()
const [network, setNetwork] = useState('') const [network, setNetwork] = useState('')
const [netHash, setNetHash] = useState('') const [netHash, setNetHash] = useState('')
const [inviteString, setInviteString] = useState('') const [inviteString, setInviteString] = useState('')
const [anchorHint, setAnchorHint] = useState('') const [anchorUrl, setAnchorUrl] = useState(DEFAULT_ANCHOR)
const [nick, setNick] = useState(localStorage.getItem('waste_nick') || '')
const [exportPass, setExportPass] = useState('') const [exportPass, setExportPass] = useState('')
const [importJson, setImportJson] = useState('') const [importJson, setImportJson] = useState('')
const [importPass, setImportPass] = useState('') const [importPass, setImportPass] = useState('')
const [importStatus, setImportStatus] = useState('') const [importStatus, setImportStatus] = useState('')
const [showBackup, setShowBackup] = useState(false) const [showBackup, setShowBackup] = useState(false)
const [daemonUrl, setDaemonUrl] = useState(localStorage.getItem('waste_daemon_ws') || 'ws://127.0.0.1:17338')
useEffect(() => { useEffect(() => {
const { network: n, netHash: nh, anchor: a, inviteString: inv } = parseInviteParams() const { network: n, netHash: nh, anchor: a, inviteString: inv } = parseInviteParams()
if (n) setNetwork(n) if (n) setNetwork(n)
if (nh) setNetHash(nh) if (nh) setNetHash(nh)
if (a) setAnchorHint(a) if (a) setAnchorUrl(a)
if (inv) setInviteString(inv) if (inv) setInviteString(inv)
}, []) }, [])
@@ -64,8 +66,14 @@ export function Onboarding({ status }: Props) {
const name = network.trim() const name = network.trim()
const hash = netHash.trim() const hash = netHash.trim()
if (!name && !hash) return if (!name && !hash) return
// In browser mode: persist anchor URL choice and pass it via localStorage
if (adapterMode === 'browser') {
localStorage.setItem('waste_anchor_url', anchorUrl)
if (nick.trim()) localStorage.setItem('waste_nick', nick.trim())
}
if (hash.length === 64 && !name) { if (hash.length === 64 && !name) {
// yaw2-style: join by full network hash without plaintext name
send({ type: 'join_network', network_hash: hash }) send({ type: 'join_network', network_hash: hash })
} else { } else {
send({ type: 'join_network', network_name: name }) send({ type: 'join_network', network_name: name })
@@ -75,7 +83,6 @@ export function Onboarding({ status }: Props) {
function exportIdentity(e: React.FormEvent) { function exportIdentity(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!exportPass.trim()) return if (!exportPass.trim()) return
setExportBlob('')
send({ type: 'export_identity', passphrase: exportPass }) send({ type: 'export_identity', passphrase: exportPass })
} }
@@ -86,6 +93,15 @@ export function Onboarding({ status }: Props) {
send({ type: 'import_identity', backup: importJson, passphrase: importPass }) send({ type: 'import_identity', backup: importJson, passphrase: importPass })
} }
function switchToDaemon() {
localStorage.setItem('waste_daemon_ws', daemonUrl)
connect(daemonUrl)
}
function switchToBrowser() {
connectBrowser()
}
const shortId = masterId ? masterId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim() : null const shortId = masterId ? masterId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim() : null
// ── disconnected / connecting ──────────────────────────────────────────────── // ── disconnected / connecting ────────────────────────────────────────────────
@@ -94,18 +110,16 @@ export function Onboarding({ status }: Props) {
<div className="onboarding"> <div className="onboarding">
<h1>waste</h1> <h1>waste</h1>
{status === 'connecting' ? ( {status === 'connecting' ? (
<p className="status connecting">Connecting to daemon</p> <p className="status connecting">
{adapterMode === 'browser' ? 'Loading crypto…' : 'Connecting to daemon…'}
</p>
) : ( ) : (
<> <>
<p className="status disconnected">Daemon not running</p> <p className="status disconnected">Daemon not running</p>
<p className="onboarding-hint"> <p className="onboarding-hint">Start the daemon to use the web UI:</p>
Start the daemon to use the web UI:
</p>
<pre className="onboarding-code">./launch-web.sh</pre> <pre className="onboarding-code">./launch-web.sh</pre>
<p className="onboarding-hint muted"> <p className="onboarding-hint muted">Or use browser mode (no install required):</p>
Or pass a custom alias and network: <button className="primary" onClick={switchToBrowser}>Use browser mode</button>
</p>
<pre className="onboarding-code">ALIAS=alice NETWORK=friends ./launch-web.sh</pre>
</> </>
)} )}
</div> </div>
@@ -121,14 +135,25 @@ export function Onboarding({ status }: Props) {
<div className="onboarding-identity"> <div className="onboarding-identity">
<span className="alias">{masterAlias}</span> <span className="alias">{masterAlias}</span>
{shortId && <span className="peer-id mono">{shortId}</span>} {shortId && <span className="peer-id mono">{shortId}</span>}
<span className="peer-id" style={{ opacity: 0.4, fontSize: '9px' }}>
{adapterMode === 'browser' ? 'browser mode' : 'daemon mode'}
</span>
</div> </div>
)} )}
<form onSubmit={joinNetwork} className="join-form"> <form onSubmit={joinNetwork} className="join-form">
<label className="join-label">Join a network</label> <label className="join-label">Join a network</label>
{anchorHint && (
<p className="onboarding-hint muted">via {anchorHint}</p> {adapterMode === 'browser' && (
<input
value={nick}
onChange={e => setNick(e.target.value)}
placeholder="your name (optional)"
autoComplete="nickname"
style={{ marginBottom: '0.4rem' }}
/>
)} )}
{netHash && !network ? ( {netHash && !network ? (
<input <input
value={netHash} value={netHash}
@@ -148,6 +173,18 @@ export function Onboarding({ status }: Props) {
autoFocus autoFocus
/> />
)} )}
{adapterMode === 'browser' && (
<input
value={anchorUrl}
onChange={e => setAnchorUrl(e.target.value)}
placeholder="signal server (wss://…)"
autoComplete="url"
className="mono"
style={{ fontSize: '0.78rem' }}
/>
)}
{inviteString && ( {inviteString && (
<p className="onboarding-hint muted mono" style={{ fontSize: '0.68rem', wordBreak: 'break-all' }}> <p className="onboarding-hint muted mono" style={{ fontSize: '0.68rem', wordBreak: 'break-all' }}>
{inviteString.slice(0, 48)} {inviteString.slice(0, 48)}
@@ -158,6 +195,31 @@ export function Onboarding({ status }: Props) {
</button> </button>
</form> </form>
{/* Mode switcher — only shown when on localhost */}
{isLocal && adapterMode !== null && (
<div className="onboarding-section">
{adapterMode === 'daemon' ? (
<button className="toggle-link" onClick={switchToBrowser}>
Switch to browser mode
</button>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
<button className="toggle-link" onClick={() => {}}>
Switch to daemon mode
</button>
<input
value={daemonUrl}
onChange={e => setDaemonUrl(e.target.value)}
placeholder="ws://127.0.0.1:17338"
className="mono"
style={{ fontSize: '0.78rem' }}
/>
<button className="primary" onClick={switchToDaemon}>Connect</button>
</div>
)}
</div>
)}
<div className="onboarding-section"> <div className="onboarding-section">
<button <button
className="toggle-link" className="toggle-link"
@@ -170,7 +232,7 @@ export function Onboarding({ status }: Props) {
<div className="backup-panel"> <div className="backup-panel">
<p className="onboarding-hint"> <p className="onboarding-hint">
Export your identity as an encrypted file. You can import it on Export your identity as an encrypted file. You can import it on
any machine or in the TUI with <code>--import-identity</code>. any device browser or TUI.
</p> </p>
<form onSubmit={exportIdentity} className="backup-form"> <form onSubmit={exportIdentity} className="backup-form">

View File

@@ -1,10 +1,14 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { PeerInfo, NetworkInfo, ChatMessage, FileEntry, IpcMessage } from '../types' import type { PeerInfo, NetworkInfo, ChatMessage, FileEntry, IpcMessage } from '../types'
import { DaemonAdapter } from '../adapter/daemon' import { DaemonAdapter } from '../adapter/daemon'
import { BrowserAdapter } from '../adapter/browser'
type AnyAdapter = DaemonAdapter | BrowserAdapter
interface WasteState { interface WasteState {
// connection // connection
adapter: DaemonAdapter | null adapter: AnyAdapter | null
adapterMode: 'daemon' | 'browser' | null
daemonStatus: 'disconnected' | 'connecting' | 'connected' daemonStatus: 'disconnected' | 'connecting' | 'connected'
// identity // identity
@@ -31,6 +35,7 @@ interface WasteState {
// actions // actions
connect: (url: string) => void connect: (url: string) => void
connectBrowser: () => void
disconnect: () => void disconnect: () => void
send: (msg: IpcMessage) => void send: (msg: IpcMessage) => void
setActiveNetwork: (id: string) => void setActiveNetwork: (id: string) => void
@@ -40,6 +45,7 @@ interface WasteState {
export const useWaste = create<WasteState>((set, get) => ({ export const useWaste = create<WasteState>((set, get) => ({
adapter: null, adapter: null,
adapterMode: null,
daemonStatus: 'disconnected', daemonStatus: 'disconnected',
masterAlias: null, masterAlias: null,
masterId: null, masterId: null,
@@ -57,12 +63,22 @@ export const useWaste = create<WasteState>((set, get) => ({
adapter.onStatusChange = (s) => set({ daemonStatus: s }) adapter.onStatusChange = (s) => set({ daemonStatus: s })
adapter.on((msg) => get().handleEvent(msg)) adapter.on((msg) => get().handleEvent(msg))
adapter.connect() adapter.connect()
set({ adapter }) set({ adapter, adapterMode: 'daemon' })
},
connectBrowser() {
const adapter = new BrowserAdapter()
adapter.onStatusChange = (s) => set({ daemonStatus: s })
adapter.on((msg) => get().handleEvent(msg))
adapter.connect()
set({ adapter, adapterMode: 'browser' })
}, },
disconnect() { disconnect() {
get().adapter?.disconnect() const a = get().adapter
set({ adapter: null, daemonStatus: 'disconnected' }) if (a instanceof DaemonAdapter) a.disconnect()
else if (a instanceof BrowserAdapter) a.disconnect()
set({ adapter: null, adapterMode: null, daemonStatus: 'disconnected' })
}, },
send(msg) { send(msg) {
@@ -93,11 +109,13 @@ export const useWaste = create<WasteState>((set, get) => ({
} }
case 'network_joined': { case 'network_joined': {
set(s => ({ set(s => ({
localPeer: s.localPeer ?? msg.local_peer ?? null,
networks: s.networks.some(n => n.network_id === msg.network_id) networks: s.networks.some(n => n.network_id === msg.network_id)
? s.networks ? s.networks
: [...s.networks, { : [...s.networks, {
network_id: msg.network_id!, network_id: msg.network_id!,
network_name: msg.network_name!, network_name: msg.network_name!,
local_peer: msg.local_peer,
share_dir: msg.share_dir, share_dir: msg.share_dir,
}], }],
activeNetworkId: s.activeNetworkId ?? msg.network_id!, activeNetworkId: s.activeNetworkId ?? msg.network_id!,