Add invite strings (waste: URI) for peer onboarding

- internal/invite: Encode/Decode waste:<base64json{anchor,network}>
- 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 <invite> flag — decodes anchor URL + network name,
  sets anchor and auto-joins on startup
- tui: --join <invite> 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 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-21 18:56:24 +02:00
parent 5a0bcee8c6
commit abc6bf46ce
7 changed files with 229 additions and 14 deletions

View File

@@ -59,6 +59,33 @@ No DHT needed at small group scale (1050 nodes). Keep it simple:
- Public key = stable identity, not a mutable nickname - 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 - 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-<netHash>.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) ### 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. 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.

View File

@@ -11,6 +11,7 @@ import (
"github.com/waste-go/internal/anchor" "github.com/waste-go/internal/anchor"
"github.com/waste-go/internal/crypto" "github.com/waste-go/internal/crypto"
"github.com/waste-go/internal/invite"
"github.com/waste-go/internal/ipc" "github.com/waste-go/internal/ipc"
"github.com/waste-go/internal/mesh" "github.com/waste-go/internal/mesh"
"github.com/waste-go/internal/proto" "github.com/waste-go/internal/proto"
@@ -22,8 +23,21 @@ func main() {
alias := flag.String("alias", "anon", "display name shown to peers (advisory only)") 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)") 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") 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() 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) dir := expandHome(*dataDir)
id, err := crypto.LoadOrCreate(dir, *alias) id, err := crypto.LoadOrCreate(dir, *alias)
if err != nil { if err != nil {
@@ -51,7 +65,14 @@ func main() {
log.Printf("daemon: left network %q", networkName) 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) log.Fatalf("ipc: %v", err)
} }
} }

View File

@@ -18,6 +18,7 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/waste-go/internal/invite"
"github.com/waste-go/internal/proto" "github.com/waste-go/internal/proto"
) )
@@ -116,6 +117,7 @@ type model struct {
status string status string
errMsg string errMsg string
invitePopup string // non-empty = show invite overlay
} }
func newModel(ipcPort int, network string) model { 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: case msg.Type == tea.KeyCtrlC:
return m, tea.Quit return m, tea.Quit
case msg.Type == tea.KeyEsc: case msg.Type == tea.KeyEsc:
if m.invitePopup != "" {
m.invitePopup = ""
return m, nil
}
return m, tea.Quit 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: case msg.Type == tea.KeyEnter:
m, cmds = m.doSend(cmds) m, cmds = m.doSend(cmds)
case msg.Type == tea.KeyTab: case msg.Type == tea.KeyTab:
@@ -272,6 +282,9 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
m.peerOrder = filterIDs(m.peerOrder, pid) m.peerOrder = filterIDs(m.peerOrder, pid)
} }
case proto.EvtInviteGenerated:
m.invitePopup = evt.InviteString
case proto.EvtMessageReceived: case proto.EvtMessageReceived:
if evt.Message != nil { if evt.Message != nil {
msg := evt.Message msg := evt.Message
@@ -417,11 +430,31 @@ func (m model) View() string {
if m.errMsg != "" { if m.errMsg != "" {
statusLine = styleErr.Render(" ✗ " + m.errMsg) statusLine = styleErr.Render(" ✗ " + m.errMsg)
} else { } 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) 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 { func (m model) renderRooms(boxH int) string {
@@ -566,11 +599,22 @@ func min(a, b int) int {
func main() { func main() {
ipcPort := flag.Int("ipc", 17337, "daemon IPC port") ipcPort := flag.Int("ipc", 17337, "daemon IPC port")
network := flag.String("network", "", "network name to join on startup (required)") network := flag.String("network", "", "network name to join on startup")
joinInvite := flag.String("join", "", "waste: invite string — auto-sets the network name")
flag.Parse() 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 == "" { if *network == "" {
fmt.Fprintln(os.Stderr, "error: -network is required") fmt.Fprintln(os.Stderr, "error: -network or -join is required")
flag.Usage() flag.Usage()
os.Exit(1) os.Exit(1)
} }

54
internal/invite/invite.go Normal file
View File

@@ -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:<url-safe-base64(json)>"
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
}

View File

@@ -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)
}
}
}

View File

@@ -16,6 +16,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/waste-go/internal/invite"
"github.com/waste-go/internal/mesh" "github.com/waste-go/internal/mesh"
"github.com/waste-go/internal/proto" "github.com/waste-go/internal/proto"
) )
@@ -24,8 +25,9 @@ import (
type JoinFunc func(ctx context.Context, networkName string) type JoinFunc func(ctx context.Context, networkName string)
// Run starts the IPC listener. Blocks until the listener fails. // 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). // 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) addr := fmt.Sprintf("127.0.0.1:%d", port)
ln, err := net.Listen("tcp", addr) ln, err := net.Listen("tcp", addr)
if err != nil { if err != nil {
@@ -33,11 +35,12 @@ func Run(m *mesh.Mesh, port int, join JoinFunc) error {
} }
log.Printf("ipc: listening on %s", addr) log.Printf("ipc: listening on %s", addr)
// networkCancel is shared across all clients — any client can join/leave, // networkCancel / networkName are shared across all clients — any client
// and the join persists even after the commanding client disconnects. // can join/leave, and the join persists after the commanding client disconnects.
var ( var (
networkMu sync.Mutex networkMu sync.Mutex
networkCancel context.CancelFunc networkCancel context.CancelFunc
networkName string
) )
doJoin := func(name 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()) ctx, cancel := context.WithCancel(context.Background())
networkCancel = cancel networkCancel = cancel
networkName = name
networkMu.Unlock() networkMu.Unlock()
go join(ctx, name) go join(ctx, name)
} }
@@ -56,21 +60,28 @@ func Run(m *mesh.Mesh, port int, join JoinFunc) error {
if networkCancel != nil { if networkCancel != nil {
networkCancel() networkCancel()
networkCancel = nil networkCancel = nil
networkName = ""
} }
networkMu.Unlock() networkMu.Unlock()
} }
currentNetwork := func() string {
networkMu.Lock()
defer networkMu.Unlock()
return networkName
}
for { for {
conn, err := ln.Accept() conn, err := ln.Accept()
if err != nil { if err != nil {
return fmt.Errorf("ipc accept: %w", err) return fmt.Errorf("ipc accept: %w", err)
} }
log.Printf("ipc: UI client connected from %s", conn.RemoteAddr()) 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() defer conn.Close()
events := m.Subscribe() events := m.Subscribe()
@@ -187,6 +198,23 @@ func handleClient(conn net.Conn, m *mesh.Mesh, doJoin func(string), doLeave func
Rooms: []string{"general"}, 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: case proto.CmdSendFile:
send(errMsg("file transfer not yet implemented")) send(errMsg("file transfer not yet implemented"))

View File

@@ -186,6 +186,7 @@ const (
CmdLeaveNetwork IpcMsgType = "leave_network" CmdLeaveNetwork IpcMsgType = "leave_network"
CmdGetState IpcMsgType = "get_state" CmdGetState IpcMsgType = "get_state"
CmdSendFile IpcMsgType = "send_file" CmdSendFile IpcMsgType = "send_file"
CmdGenerateInvite IpcMsgType = "generate_invite"
// Events (daemon → UI) // Events (daemon → UI)
EvtMessageReceived IpcMsgType = "message_received" EvtMessageReceived IpcMsgType = "message_received"
@@ -196,6 +197,7 @@ const (
EvtFileProgress IpcMsgType = "file_progress" EvtFileProgress IpcMsgType = "file_progress"
EvtStateSnapshot IpcMsgType = "state_snapshot" EvtStateSnapshot IpcMsgType = "state_snapshot"
EvtError IpcMsgType = "error" EvtError IpcMsgType = "error"
EvtInviteGenerated IpcMsgType = "invite_generated"
) )
// IpcMessage covers both commands and events. // IpcMessage covers both commands and events.
@@ -226,4 +228,5 @@ type IpcMessage struct {
ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"` ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"`
Rooms []string `json:"rooms,omitempty"` Rooms []string `json:"rooms,omitempty"`
ErrorMessage string `json:"error_message,omitempty"` ErrorMessage string `json:"error_message,omitempty"`
InviteString string `json:"invite,omitempty"`
} }