From abc6bf46ce61c59c0a31c73101f7a105392447af Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Sun, 21 Jun 2026 18:56:24 +0200 Subject: [PATCH] Add invite strings (waste: URI) for peer onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/invite: Encode/Decode waste: - proto: CmdGenerateInvite + EvtInviteGenerated + InviteString field - ipc: track active network name; handle generate_invite (returns invite string when joined to a network, errors if not joined or no anchor) - daemon: --join flag — decodes anchor URL + network name, sets anchor and auto-joins on startup - tui: --join flag — extracts network name, skips -network requirement; ctrl+i generates invite and shows it full-screen; Esc dismisses the invite overlay - FUTURE.md: document multi-network derived-identity design Co-Authored-By: Claude Sonnet 4.6 --- FUTURE.md | 27 ++++++++++++++++ cmd/daemon/main.go | 23 +++++++++++++- cmd/tui/main.go | 58 ++++++++++++++++++++++++++++++---- internal/invite/invite.go | 54 +++++++++++++++++++++++++++++++ internal/invite/invite_test.go | 38 ++++++++++++++++++++++ internal/ipc/ipc.go | 38 +++++++++++++++++++--- internal/proto/proto.go | 5 ++- 7 files changed, 229 insertions(+), 14 deletions(-) create mode 100644 internal/invite/invite.go create mode 100644 internal/invite/invite_test.go diff --git a/FUTURE.md b/FUTURE.md index 606d835..6dc1c18 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -59,6 +59,33 @@ No DHT needed at small group scale (10–50 nodes). Keep it simple: - Public key = stable identity, not a mutable nickname - No phone number, no central registry — closer to Signal's model than WASTE's original unregistered aliases +### Multi-Network Support + +A single client should be able to participate in multiple networks simultaneously (e.g. "work" and "friends") without leaking that both identities belong to the same person. + +**Privacy constraint:** if the same Ed25519 keypair is used across networks, any peer who is a member of both networks can trivially correlate you. The anchor also sees the same public key across networks. + +**Solution — per-network derived identities:** +- One master Ed25519 seed in `identity.json` +- Per-network keypair = `HKDF(masterSeed, "yaw2-net", networkHash)` +- Same master + same network name = same derived keypair (stable identity within a network) +- Different networks = different peer IDs; correlation is impossible without knowing both network names +- The anchor sees only the derived public key + +**Daemon changes:** +- Replace the single `networkCancel` with a `map[networkID]*networkCtx` +- Each context holds its own: derived identity, mesh, anchor connection, store (`messages-.db`) +- `join_network` returns a `network_id` token used to scope subsequent commands + +**IPC changes (breaking):** +- All commands and events gain a `network_id` field +- `get_state` returns an array of all joined networks +- `join_network` responds with `network_joined` carrying the derived peer ID for that network + +**TUI changes:** +- Top-level network switcher (e.g. `[work] [friends]`) +- Rooms and peers are scoped per network underneath + ### Transport (Long-term) Current transport is TCP with custom framing. QUIC is worth revisiting once the core is solid — it gives multiplexing and better NAT traversal behavior essentially for free. diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index da8ecde..1694b77 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -11,6 +11,7 @@ import ( "github.com/waste-go/internal/anchor" "github.com/waste-go/internal/crypto" + "github.com/waste-go/internal/invite" "github.com/waste-go/internal/ipc" "github.com/waste-go/internal/mesh" "github.com/waste-go/internal/proto" @@ -22,8 +23,21 @@ func main() { 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") + joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup") flag.Parse() + // --join overrides/sets the anchor URL and triggers an auto-join. + var autoJoinNetwork string + if *joinInvite != "" { + inv, err := invite.Decode(*joinInvite) + if err != nil { + log.Fatalf("invalid invite: %v", err) + } + *anchorURL = inv.Anchor + autoJoinNetwork = inv.Network + log.Printf("daemon: invite decoded — anchor=%s network=%s", inv.Anchor, inv.Network) + } + dir := expandHome(*dataDir) id, err := crypto.LoadOrCreate(dir, *alias) if err != nil { @@ -51,7 +65,14 @@ func main() { log.Printf("daemon: left network %q", networkName) } - if err := ipc.Run(m, *ipcPort, joinFn); err != nil { + // Auto-join from --join flag before starting IPC (non-blocking). + if autoJoinNetwork != "" { + ctx, cancel := context.WithCancel(context.Background()) + _ = cancel // lifecycle managed by the joinFn / ipc.Run leave + go joinFn(ctx, autoJoinNetwork) + } + + if err := ipc.Run(m, *ipcPort, *anchorURL, joinFn); err != nil { log.Fatalf("ipc: %v", err) } } diff --git a/cmd/tui/main.go b/cmd/tui/main.go index 1738f3e..c978364 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -18,6 +18,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/waste-go/internal/invite" "github.com/waste-go/internal/proto" ) @@ -114,8 +115,9 @@ type model struct { viewport viewport.Model vpReady bool - status string - errMsg string + status string + errMsg string + invitePopup string // non-empty = show invite overlay } func newModel(ipcPort int, network string) model { @@ -200,7 +202,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case msg.Type == tea.KeyCtrlC: return m, tea.Quit case msg.Type == tea.KeyEsc: + if m.invitePopup != "" { + m.invitePopup = "" + return m, nil + } return m, tea.Quit + case msg.String() == "ctrl+i": + if m.enc != nil { + cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGenerateInvite})) + } case msg.Type == tea.KeyEnter: m, cmds = m.doSend(cmds) case msg.Type == tea.KeyTab: @@ -272,6 +282,9 @@ func (m model) applyEvent(evt proto.IpcMessage) model { m.peerOrder = filterIDs(m.peerOrder, pid) } + case proto.EvtInviteGenerated: + m.invitePopup = evt.InviteString + case proto.EvtMessageReceived: if evt.Message != nil { msg := evt.Message @@ -417,11 +430,31 @@ func (m model) View() string { if m.errMsg != "" { statusLine = styleErr.Render(" ✗ " + m.errMsg) } else { - hint := " tab: rooms · pgup/dn: scroll · ctrl+c: quit" + hint := " tab: rooms · ctrl+i: invite · ctrl+c: quit" statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint) } - return lipgloss.JoinVertical(lipgloss.Left, mainRow, inputBox, statusLine) + view := lipgloss.JoinVertical(lipgloss.Left, mainRow, inputBox, statusLine) + + // ── invite popup (full-screen overlay) ─────────────────────────────────── + if m.invitePopup != "" { + label := styleActive.Render("Invite — share this with anyone you want to add:") + code := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("238")). + Padding(0, 1). + Width(m.width - 6). + Render(m.invitePopup) + hint := styleStatus.Render("Esc to close · ctrl+c to quit") + popup := lipgloss.NewStyle(). + Width(m.width). + Height(m.height). + Align(lipgloss.Center, lipgloss.Center). + Render(lipgloss.JoinVertical(lipgloss.Left, label, "", code, "", hint)) + return popup + } + + return view } func (m model) renderRooms(boxH int) string { @@ -565,12 +598,23 @@ func min(a, b int) int { // ── main ────────────────────────────────────────────────────────────────────── func main() { - ipcPort := flag.Int("ipc", 17337, "daemon IPC port") - network := flag.String("network", "", "network name to join on startup (required)") + ipcPort := flag.Int("ipc", 17337, "daemon IPC port") + network := flag.String("network", "", "network name to join on startup") + joinInvite := flag.String("join", "", "waste: invite string — auto-sets the network name") flag.Parse() + // --join overrides --network. + if *joinInvite != "" { + inv, err := invite.Decode(*joinInvite) + if err != nil { + fmt.Fprintln(os.Stderr, "error: invalid invite:", err) + os.Exit(1) + } + *network = inv.Network + } + if *network == "" { - fmt.Fprintln(os.Stderr, "error: -network is required") + fmt.Fprintln(os.Stderr, "error: -network or -join is required") flag.Usage() os.Exit(1) } diff --git a/internal/invite/invite.go b/internal/invite/invite.go new file mode 100644 index 0000000..aac27b5 --- /dev/null +++ b/internal/invite/invite.go @@ -0,0 +1,54 @@ +// Package invite encodes and decodes waste invite strings. +// An invite carries the anchor URL and network name needed to join a network. +// Format: "waste:" +package invite + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +const prefix = "waste:" + +// Invite holds the information needed to join a network. +type Invite struct { + Anchor string `json:"anchor"` // WebSocket anchor URL + Network string `json:"network"` // plaintext network name +} + +// Encode returns a waste: invite string for the given anchor URL and network name. +func Encode(anchor, network string) (string, error) { + if anchor == "" { + return "", fmt.Errorf("anchor URL is required") + } + if network == "" { + return "", fmt.Errorf("network name is required") + } + b, err := json.Marshal(Invite{Anchor: anchor, Network: network}) + if err != nil { + return "", err + } + return prefix + base64.URLEncoding.EncodeToString(b), nil +} + +// Decode parses a waste: invite string and returns the Invite. +func Decode(s string) (Invite, error) { + s = strings.TrimSpace(s) + if !strings.HasPrefix(s, prefix) { + return Invite{}, fmt.Errorf("not a waste invite (expected 'waste:' prefix)") + } + b, err := base64.URLEncoding.DecodeString(strings.TrimPrefix(s, prefix)) + if err != nil { + return Invite{}, fmt.Errorf("invalid invite: %w", err) + } + var inv Invite + if err := json.Unmarshal(b, &inv); err != nil { + return Invite{}, fmt.Errorf("invalid invite payload: %w", err) + } + if inv.Anchor == "" || inv.Network == "" { + return Invite{}, fmt.Errorf("invite is missing anchor or network name") + } + return inv, nil +} diff --git a/internal/invite/invite_test.go b/internal/invite/invite_test.go new file mode 100644 index 0000000..036faea --- /dev/null +++ b/internal/invite/invite_test.go @@ -0,0 +1,38 @@ +package invite + +import "testing" + +func TestRoundTrip(t *testing.T) { + s, err := Encode("ws://my-vps:17339/ws", "friends") + if err != nil { + t.Fatalf("Encode: %v", err) + } + if len(s) < 7 || s[:6] != "waste:" { + t.Fatalf("expected waste: prefix, got %q", s) + } + + inv, err := Decode(s) + if err != nil { + t.Fatalf("Decode: %v", err) + } + if inv.Anchor != "ws://my-vps:17339/ws" { + t.Fatalf("anchor = %q", inv.Anchor) + } + if inv.Network != "friends" { + t.Fatalf("network = %q", inv.Network) + } +} + +func TestDecodeInvalid(t *testing.T) { + cases := []string{ + "", + "notaninvite", + "waste:!!!", + "waste:" + "eyJhbmNob3IiOiIifQ==", // missing network + } + for _, c := range cases { + if _, err := Decode(c); err == nil { + t.Errorf("Decode(%q) expected error, got nil", c) + } + } +} diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index c593944..f1413e2 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -16,6 +16,7 @@ import ( "time" "github.com/google/uuid" + "github.com/waste-go/internal/invite" "github.com/waste-go/internal/mesh" "github.com/waste-go/internal/proto" ) @@ -24,8 +25,9 @@ import ( type JoinFunc func(ctx context.Context, networkName string) // Run starts the IPC listener. Blocks until the listener fails. +// anchorURL is the configured anchor WebSocket URL (used for invite generation). // Network join/leave state is daemon-scoped (shared across all IPC clients). -func Run(m *mesh.Mesh, port int, join JoinFunc) error { +func Run(m *mesh.Mesh, port int, anchorURL string, join JoinFunc) error { addr := fmt.Sprintf("127.0.0.1:%d", port) ln, err := net.Listen("tcp", addr) if err != nil { @@ -33,11 +35,12 @@ func Run(m *mesh.Mesh, port int, join JoinFunc) error { } log.Printf("ipc: listening on %s", addr) - // networkCancel is shared across all clients — any client can join/leave, - // and the join persists even after the commanding client disconnects. + // networkCancel / networkName are shared across all clients — any client + // can join/leave, and the join persists after the commanding client disconnects. var ( networkMu sync.Mutex networkCancel context.CancelFunc + networkName string ) doJoin := func(name string) { @@ -47,6 +50,7 @@ func Run(m *mesh.Mesh, port int, join JoinFunc) error { } ctx, cancel := context.WithCancel(context.Background()) networkCancel = cancel + networkName = name networkMu.Unlock() go join(ctx, name) } @@ -56,21 +60,28 @@ func Run(m *mesh.Mesh, port int, join JoinFunc) error { if networkCancel != nil { networkCancel() networkCancel = nil + networkName = "" } networkMu.Unlock() } + currentNetwork := func() string { + networkMu.Lock() + defer networkMu.Unlock() + return networkName + } + for { conn, err := ln.Accept() if err != nil { return fmt.Errorf("ipc accept: %w", err) } log.Printf("ipc: UI client connected from %s", conn.RemoteAddr()) - go handleClient(conn, m, doJoin, doLeave) + go handleClient(conn, m, anchorURL, currentNetwork, doJoin, doLeave) } } -func handleClient(conn net.Conn, m *mesh.Mesh, doJoin func(string), doLeave func()) { +func handleClient(conn net.Conn, m *mesh.Mesh, anchorURL string, currentNetwork func() string, doJoin func(string), doLeave func()) { defer conn.Close() events := m.Subscribe() @@ -187,6 +198,23 @@ func handleClient(conn net.Conn, m *mesh.Mesh, doJoin func(string), doLeave func Rooms: []string{"general"}, }) + case proto.CmdGenerateInvite: + net := currentNetwork() + if net == "" { + send(errMsg("generate_invite: not currently joined to a network")) + continue + } + if anchorURL == "" { + send(errMsg("generate_invite: daemon was started without -anchor flag")) + continue + } + inv, err := invite.Encode(anchorURL, net) + if err != nil { + send(errMsg(fmt.Sprintf("generate_invite: %v", err))) + continue + } + send(proto.IpcMessage{Type: proto.EvtInviteGenerated, InviteString: inv}) + case proto.CmdSendFile: send(errMsg("file transfer not yet implemented")) diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 1773237..a5f6c38 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -185,7 +185,8 @@ const ( CmdJoinNetwork IpcMsgType = "join_network" // fields: network_name (plaintext) CmdLeaveNetwork IpcMsgType = "leave_network" CmdGetState IpcMsgType = "get_state" - CmdSendFile IpcMsgType = "send_file" + CmdSendFile IpcMsgType = "send_file" + CmdGenerateInvite IpcMsgType = "generate_invite" // Events (daemon → UI) EvtMessageReceived IpcMsgType = "message_received" @@ -196,6 +197,7 @@ const ( EvtFileProgress IpcMsgType = "file_progress" EvtStateSnapshot IpcMsgType = "state_snapshot" EvtError IpcMsgType = "error" + EvtInviteGenerated IpcMsgType = "invite_generated" ) // IpcMessage covers both commands and events. @@ -226,4 +228,5 @@ type IpcMessage struct { ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"` Rooms []string `json:"rooms,omitempty"` ErrorMessage string `json:"error_message,omitempty"` + InviteString string `json:"invite,omitempty"` }