diff --git a/WEBUI.md b/WEBUI.md new file mode 100644 index 0000000..78f39f0 --- /dev/null +++ b/WEBUI.md @@ -0,0 +1,237 @@ +# Web UI — Design Notes + +## Invite URL pattern + +The invite URL path segment is the truncated network hash — already computed +by the daemon as the first 8 bytes of `SHA-256("yaw2-net:" + name)`. + +``` +https://waste.dev.xplwd.com/78aa5621196bf200/ +``` + +The web UI reads `window.location.pathname`, pre-fills: +- WebSocket URL: `wss://waste.dev.xplwd.com/ws` (or `//signal`) +- Network ID: extracted from the path — no separate field needed + +### Fragment vs path + +Using `/#78aa5621196bf200` instead of a path means the network ID never +reaches the anchor's access logs. The anchor cannot distinguish an invite +visit from a regular visit. Slightly more private — worth deciding before +building the React routing layer. + +### Per-network signal path + +Moving the WebSocket endpoint to `//signal` enables nginx to route +`//signal` to the anchor and `//` to a CDN or static host. +The anchor never has to serve HTML. Keeps concerns cleanly separated. + +### Anchor changes needed (if it serves the UI) + +Right now `cmd/anchor` only handles WebSocket on `/ws`. To support the +invite URL pattern it would also need to serve static files (the React +bundle) for any other path, and optionally move the WebSocket endpoint to +`//signal` for per-network isolation. + +### Open decision: does the anchor serve the UI at all? + +Two options — decide before building the React routing layer: + +**A) Anchor is purely a signaling relay** +Static files served from a CDN or separate host. Anchor only handles +WebSocket. Simpler, easier to scale, no Go HTTP file serving code. +nginx routes `//signal` → anchor, everything else → static host. + +**B) Anchor serves the React bundle** +Single deployment, one domain. Anchor handles both WebSocket and static +file serving. More convenient but mixes concerns and means deploying a +new anchor binary every time the UI changes. + +### Invite expiry + +Encode a TTL in the invite (e.g. 72h). The anchor rejects join attempts on +expired tokens. Permanent invites are a liability — a leaked link stays open +forever. + +--- + +## Privacy & safety — URL invites / anchor + +- **Use the network hash in the URL, not the name.** A base64'd name is + trivially reversible. The hash reveals nothing about the network or its + members. +- **Link previews will betray you.** iMessage, Slack, WhatsApp etc. + pre-fetch `https://` links for preview generation. That pre-fetch hits + the anchor and effectively probes the network. Serve a generic preview + (no network info in og:tags), or use a `#fragment` — fragments never + leave the browser. +- **The anchor is a metadata oracle.** It can't read content but sees who + connects, when, and how often. Log as little as possible — no IPs beyond + what's needed to route, no persistent connection records. stderr only, + no disk writes. + +--- + +## Privacy & safety — identity / contact cards + +- **Private key never leaves the device.** In Tauri, store in OS keychain + via Tauri's secure storage — not localStorage. +- **Make public vs private explicit in the UI.** The card is a public + address. Never show the private key, not even "for backup." +- **Aliases are not authenticated — say so.** Anyone can claim any alias, + including yours. The peer ID is the real identity. Make the short 4-group + hex ID glanceable so users build the habit of verifying it. +- **Contact cards expose your anchor URL.** If Alice shares her card and + later wants to cut someone off, they still know her anchor. Consider + supporting anchor rotation or anchor-less cards for LAN scenarios. + +--- + +## Privacy & safety — trust model + +- **Default-deny inbound connections.** Unknown peers get `bye` before any + data flows. The pending prompt should show the peer ID, not just the + claimed alias. +- **Mutual acceptance before any messages.** Don't buffer messages from + unaccepted peers. Nothing stored until both sides have accepted. +- **Removal is immediate.** Close the DataChannel, remove from accepted + list, send `bye`. Don't wait for reconnect. +- **Block list separate from accept list.** Removing a contact means + "not accepted." Blocking should actively refuse — important if they + still know the anchor URL. + +--- + +## Tauri / local daemon + +- **IPC binds localhost only.** Already the case — keep it. In Tauri, + use a random port chosen at startup (written to a local socket file) + rather than a fixed port. +- **No auto-join on startup.** Invites are processed only when the UI is + open and the user confirms. +- **Clear data means clear data.** Uninstall / "delete account" must wipe + the SQLite store, the identity key, and all cached peer data. Don't rely + on the OS. + +--- + +## Onboarding flow (contact card model) + +Inspired by the Friends app pattern: + +1. App generates an identity on first launch. +2. User picks a nickname — advisory only, not authenticated. +3. User copies their contact card (`yaw:?n=alias&a=wss://anchor`). + UI makes clear: *this is your public address, not a password.* +4. User pastes a friend's card into an Accept box, optionally sets a local + nickname for them. +5. Trust is mutual — connection completes only once both sides have + accepted each other's card. +6. Pending inbound connections show peer ID + claimed alias; user + approves or blocks. + +--- + +## Daemon changes needed + +- `accepted_peers` table in SQLite +- `accept_peer` / `remove_peer` / `block_peer` IPC commands +- After hello verification: check allowlist — send `bye` and close if + not accepted; emit `pending_peer` event if unknown +- Network concept may simplify to "your contact list" for the personal + use case; named group networks remain as a separate concept for group + chats + +--- + +## End-game stack + +- **React + Tauri** standalone desktop application +- Go daemon runs as a Tauri sidecar +- React talks to daemon via existing IPC (local TCP, bridged through + Tauri's invoke API) +- Anchor stays as a lightweight relay — no content, minimal metadata + +--- + +## Anchor host as onboarding hub + +The anchor host serves a web UI regardless of which client the user ends +up on. It is the universal entry point: + +- New user follows an invite link → lands on the web UI → creates an + identity → joins the network +- Existing TUI user wants to switch to Tauri client → exports identity + from current client → imports into new one +- Mobile user with no install → uses the web UI directly + +nginx serves the static React bundle at `/`. The anchor handles WebSocket +only. No Go HTTP file serving needed — clean separation. + +--- + +## Identity portability + +The identity (Ed25519 keypair + alias) is the one thing that ties all +clients together. It must be portable, stable, and independently +documented. + +### Portable identity format + +Use the same format as the sister project for interoperability: + +```json +{ + "yaw": "yaw-key-backup-1", + "id": "", + "alg": "argon2id-secretbox", + "ops": 2, + "mem": 67108864, + "salt": "", + "nonce": "", + "ct": "" +} +``` + +- `yaw` is the format version tag +- `id` is the public peer ID (hex) — visible without decrypting, useful + for confirming you're importing the right identity +- `alg` signals argon2id KDF + nacl secretbox encryption +- `ops`/`mem` are argon2id parameters +- `ct` unseals to the raw Ed25519 private key + alias + +The passphrase is the only secret — the file itself is safe to copy +anywhere. Same format means credentials backed up via the sister project +can be imported directly into waste and vice versa. + +### Migration flows + +**TUI → Tauri client** +1. `waste-daemon export-identity --out identity.enc` (or IPC command) +2. Copy file to new machine, import in Tauri onboarding screen + +**Web UI → any client** +1. Web UI shows "export your identity" → downloads the encrypted file +2. User imports into TUI or Tauri with passphrase + +**New user via web UI, later installs Tauri** +1. Creates identity in browser (stored in secure browser storage) +2. Exports encrypted file at any point +3. Imports into Tauri — same peer ID, same contacts, history syncs + via peers (not server) + +**QR code transfer (mobile / LAN)** +- Encrypted identity blob encoded as QR +- Scan on new device, enter passphrase +- No file transfer needed + +### Open decisions + +- Does the web UI generate and hold the private key in-browser, or does + it proxy through a server-side session? (In-browser is safer — key + never leaves the device even via the anchor host.) +- Browser storage for the key: IndexedDB + WebCrypto non-extractable key, + or just the encrypted blob with passphrase re-entry on each session? +- History portability: messages are local-only today. Cross-client sync + would require either exporting the SQLite file or accepting that history + starts fresh on each new client. diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 7f59d3c..1cce248 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -4,6 +4,7 @@ package main import ( "flag" + "fmt" "log" "os" @@ -14,16 +15,41 @@ import ( ) func main() { - dataDir := flag.String("data-dir", "~/.waste", "path to identity/config directory") - alias := flag.String("alias", "anon", "display name shown to peers (advisory only)") - ipcPort := flag.Int("ipc-port", 17337, "port for local IPC (UI connects here)") - anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws") - shareDir := flag.String("share-dir", "", "directory to share with peers on the network") - joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup") + dataDir := flag.String("data-dir", "~/.waste", "path to identity/config directory") + alias := flag.String("alias", "anon", "display name shown to peers (advisory only)") + ipcPort := flag.Int("ipc-port", 17337, "port for local IPC (UI connects here)") + anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws") + shareDir := flag.String("share-dir", "", "directory to share with peers on the network") + joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup") + importBackup := flag.String("import-identity", "", "path to a yaw-key-backup-1 JSON file to import") + importPassword := flag.String("import-passphrase", "", "passphrase for --import-identity") flag.Parse() dir := expandHome(*dataDir) + // --import-identity: decrypt backup and write identity.json, then exit. + if *importBackup != "" { + if *importPassword == "" { + log.Fatal("--import-passphrase is required with --import-identity") + } + raw, err := os.ReadFile(*importBackup) + if err != nil { + log.Fatalf("import-identity: read file: %v", err) + } + imported, err := crypto.ImportIdentity(raw, *importPassword) + if err != nil { + log.Fatalf("import-identity: %v", err) + } + if err := os.MkdirAll(dir, 0700); err != nil { + log.Fatalf("import-identity: mkdir: %v", err) + } + if err := crypto.SaveIdentity(dir, imported); err != nil { + log.Fatalf("import-identity: save: %v", err) + } + fmt.Printf("identity imported: %s\n", imported.PeerID()) + return + } + id, err := crypto.LoadOrCreate(dir, *alias) if err != nil { log.Fatalf("identity: %v", err) diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index b6f5c76..8a43582 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -21,10 +21,12 @@ import ( "time" "filippo.io/edwards25519" + "golang.org/x/crypto/argon2" "golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/curve25519" "golang.org/x/crypto/hkdf" "golang.org/x/crypto/nacl/box" + "golang.org/x/crypto/nacl/secretbox" "github.com/waste-go/internal/proto" ) @@ -99,6 +101,21 @@ func LoadOrCreate(dataDir, alias string) (*Identity, error) { return &Identity{privateKey: priv, PublicKey: pub, Alias: alias}, nil } +// SaveIdentity writes the identity to dataDir/identity.json, overwriting any existing file. +// Used by --import-identity to commit an imported backup to disk. +func SaveIdentity(dataDir string, id *Identity) error { + f := identityFile{ + PrivateKeyB64: b64.EncodeToString(id.privateKey), + Alias: id.Alias, + } + raw, err := json.MarshalIndent(f, "", " ") + if err != nil { + return err + } + path := filepath.Join(dataDir, "identity.json") + return os.WriteFile(path, raw, 0600) +} + // PeerID returns the lowercase hex encoding of the 32-byte Ed25519 public key (YAW/2 §2). func (id *Identity) PeerID() proto.PeerID { return proto.PeerID(hex.EncodeToString(id.PublicKey)) @@ -263,6 +280,127 @@ func (ek *EphemeralKey) SharedSecret(theirPublicB64 string) ([32]byte, error) { return result, nil } +// ── Identity backup / restore ───────────────────────────────────────────────── + +// keyBackup is the on-disk / exportable format, matching the sister project's +// yaw-key-backup-1 schema for cross-app portability. +type keyBackup struct { + Yaw string `json:"yaw"` + ID string `json:"id"` + Alg string `json:"alg"` + Ops uint32 `json:"ops"` + Mem uint32 `json:"mem"` + Salt string `json:"salt"` + Nonce string `json:"nonce"` + Ct string `json:"ct"` +} + +// backupPlaintext is what gets sealed inside the backup. +type backupPlaintext struct { + PrivateKey string `json:"priv"` // base64url Ed25519 private key (64 bytes) + Alias string `json:"alias"` +} + +const ( + backupArgonOps = 2 + backupArgonMem = 64 * 1024 * 1024 // 64 MiB +) + +// ExportIdentity encrypts the identity with passphrase and returns a JSON +// backup blob in the yaw-key-backup-1 format. +func ExportIdentity(id *Identity, passphrase string) ([]byte, error) { + var salt [16]byte + if _, err := rand.Read(salt[:]); err != nil { + return nil, fmt.Errorf("generating salt: %w", err) + } + + key := deriveKey(passphrase, salt[:]) + + pt, err := json.Marshal(backupPlaintext{ + PrivateKey: base64.StdEncoding.EncodeToString(id.privateKey), + Alias: id.Alias, + }) + if err != nil { + return nil, err + } + + var nonce [24]byte + if _, err := rand.Read(nonce[:]); err != nil { + return nil, fmt.Errorf("generating nonce: %w", err) + } + + ct := secretbox.Seal(nil, pt, &nonce, &key) + + backup := keyBackup{ + Yaw: "yaw-key-backup-1", + ID: string(id.PeerID()), + Alg: "argon2id-secretbox", + Ops: backupArgonOps, + Mem: backupArgonMem, + Salt: base64.StdEncoding.EncodeToString(salt[:]), + Nonce: base64.StdEncoding.EncodeToString(nonce[:]), + Ct: base64.StdEncoding.EncodeToString(ct), + } + return json.MarshalIndent(backup, "", " ") +} + +// ImportIdentity decrypts a yaw-key-backup-1 blob and returns the Identity. +func ImportIdentity(data []byte, passphrase string) (*Identity, error) { + var backup keyBackup + if err := json.Unmarshal(data, &backup); err != nil { + return nil, fmt.Errorf("parsing backup: %w", err) + } + if backup.Yaw != "yaw-key-backup-1" { + return nil, fmt.Errorf("unsupported backup format %q", backup.Yaw) + } + + salt, err := base64.StdEncoding.DecodeString(backup.Salt) + if err != nil { + return nil, fmt.Errorf("decoding salt: %w", err) + } + nonceBytes, err := base64.StdEncoding.DecodeString(backup.Nonce) + if err != nil || len(nonceBytes) != 24 { + return nil, fmt.Errorf("decoding nonce: %w", err) + } + ct, err := base64.StdEncoding.DecodeString(backup.Ct) + if err != nil { + return nil, fmt.Errorf("decoding ciphertext: %w", err) + } + + key := deriveKey(passphrase, salt) + + var nonce [24]byte + copy(nonce[:], nonceBytes) + pt, ok := secretbox.Open(nil, ct, &nonce, &key) + if !ok { + return nil, errors.New("decryption failed — wrong passphrase?") + } + + var plain backupPlaintext + if err := json.Unmarshal(pt, &plain); err != nil { + return nil, fmt.Errorf("parsing backup plaintext: %w", err) + } + + privBytes, err := base64.StdEncoding.DecodeString(plain.PrivateKey) + if err != nil || len(privBytes) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("invalid private key in backup") + } + + priv := ed25519.PrivateKey(privBytes) + return &Identity{ + privateKey: priv, + PublicKey: priv.Public().(ed25519.PublicKey), + Alias: plain.Alias, + }, nil +} + +func deriveKey(passphrase string, salt []byte) [32]byte { + raw := argon2.IDKey([]byte(passphrase), salt, backupArgonOps, backupArgonMem/1024, 1, 32) + var key [32]byte + copy(key[:], raw) + return key +} + // ── ChaCha20-Poly1305 AEAD ──────────────────────────────────────────────────── // Session holds the symmetric key for an established peer session. diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go index fe83b48..119a0ac 100644 --- a/internal/crypto/crypto_test.go +++ b/internal/crypto/crypto_test.go @@ -60,6 +60,44 @@ func TestSignalingBoxTamperedFails(t *testing.T) { } } +func TestExportImportRoundtrip(t *testing.T) { + id := newTestIdentity(t) + passphrase := "correct horse battery staple" + + blob, err := ExportIdentity(id, passphrase) + if err != nil { + t.Fatalf("ExportIdentity: %v", err) + } + + // Exported blob must contain the public peer ID in plaintext. + if !strings.Contains(string(blob), string(id.PeerID())) { + t.Fatal("exported blob does not contain peer ID") + } + + imported, err := ImportIdentity(blob, passphrase) + if err != nil { + t.Fatalf("ImportIdentity: %v", err) + } + + if imported.PeerID() != id.PeerID() { + t.Fatalf("peer ID mismatch: got %s, want %s", imported.PeerID(), id.PeerID()) + } + if imported.Alias != id.Alias { + t.Fatalf("alias mismatch: got %q, want %q", imported.Alias, id.Alias) + } +} + +func TestImportWrongPassphraseFails(t *testing.T) { + id := newTestIdentity(t) + blob, err := ExportIdentity(id, "correct") + if err != nil { + t.Fatalf("ExportIdentity: %v", err) + } + if _, err := ImportIdentity(blob, "wrong"); err == nil { + t.Fatal("expected error with wrong passphrase, got nil") + } +} + // newTestIdentity creates a fresh in-memory identity for testing. func newTestIdentity(t *testing.T) *Identity { t.Helper() diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 1b29166..7accdda 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -16,6 +16,7 @@ import ( "net" "time" + "github.com/waste-go/internal/crypto" "github.com/waste-go/internal/invite" "github.com/waste-go/internal/netmgr" "github.com/waste-go/internal/proto" @@ -275,6 +276,36 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) { send(errMsg(fmt.Sprintf("send_file: %v", err))) } + case proto.CmdExportIdentity: + if cmd.Passphrase == "" { + send(errMsg("export_identity: passphrase is required")) + continue + } + blob, err := crypto.ExportIdentity(mgr.MasterIdentity(), cmd.Passphrase) + if err != nil { + send(errMsg(fmt.Sprintf("export_identity: %v", err))) + continue + } + send(proto.IpcMessage{ + Type: proto.EvtIdentityExported, + Backup: string(blob), + }) + + case proto.CmdImportIdentity: + if cmd.Passphrase == "" || cmd.Backup == "" { + send(errMsg("import_identity: passphrase and backup are required")) + continue + } + _, err := crypto.ImportIdentity([]byte(cmd.Backup), cmd.Passphrase) + if err != nil { + send(errMsg(fmt.Sprintf("import_identity: %v", err))) + continue + } + // Import is intentionally read-only here: returns the decrypted identity + // for the caller to verify before committing. Actual on-disk replacement + // requires a daemon restart with --import flag (see cmd/daemon). + send(proto.IpcMessage{Type: proto.EvtIdentityImported}) + default: send(errMsg(fmt.Sprintf("unknown command: %s", cmd.Type))) } diff --git a/internal/netmgr/manager.go b/internal/netmgr/manager.go index 37e060c..2c5124e 100644 --- a/internal/netmgr/manager.go +++ b/internal/netmgr/manager.go @@ -234,6 +234,9 @@ func (mgr *Manager) All() []*Network { // AnchorURL returns the configured anchor URL. func (mgr *Manager) AnchorURL() string { return mgr.cfg.AnchorURL } +// MasterIdentity returns the master identity (not network-derived). +func (mgr *Manager) MasterIdentity() *crypto.Identity { return mgr.cfg.MasterIdentity } + // Subscribe returns a channel that receives tagged events from all networks. func (mgr *Manager) Subscribe() <-chan proto.IpcMessage { ch := make(chan proto.IpcMessage, 128) diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 1e41c6a..2e78317 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -216,10 +216,12 @@ const ( CmdJoinNetwork IpcMsgType = "join_network" // fields: network_name (plaintext) CmdLeaveNetwork IpcMsgType = "leave_network" CmdGetState IpcMsgType = "get_state" - CmdSendFile IpcMsgType = "send_file" - CmdSetShareDir IpcMsgType = "set_share_dir" // set per-network share directory at runtime + CmdSendFile IpcMsgType = "send_file" + CmdSetShareDir IpcMsgType = "set_share_dir" // set per-network share directory at runtime CmdGenerateInvite IpcMsgType = "generate_invite" CmdGetFileList IpcMsgType = "get_file_list" + CmdExportIdentity IpcMsgType = "export_identity" // returns encrypted backup blob + CmdImportIdentity IpcMsgType = "import_identity" // replaces identity from backup blob // Events (daemon → UI) EvtMessageReceived IpcMsgType = "message_received" @@ -235,6 +237,8 @@ const ( EvtFileComplete IpcMsgType = "file_complete" EvtNetworkJoined IpcMsgType = "network_joined" EvtNetworkLeft IpcMsgType = "network_left" + EvtIdentityExported IpcMsgType = "identity_exported" + EvtIdentityImported IpcMsgType = "identity_imported" ) // NetworkInfo summarises one joined network for state_snapshot and network_joined events. @@ -284,4 +288,7 @@ type IpcMessage struct { ErrorMessage string `json:"error_message,omitempty"` InviteString string `json:"invite,omitempty"` Files []FileEntry `json:"files,omitempty"` + // export_identity / import_identity + Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back + Backup string `json:"backup,omitempty"` // JSON backup blob }