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

View File

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

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"
"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"))

View File

@@ -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"`
}