From 623934e2fb761d4d2d59071d2f4af8cd7400bc48 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:19:56 +1200 Subject: [PATCH 1/5] security: redact the pending enrollment ticket in diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit redactConfig masked the agent token, agentID, and cert fingerprint but not Agent.EnrollTicket, so a pending single-use tkt_ credential rode verbatim into config.redacted.toml — precisely the failing-to-pair window when a user grabs a bundle. Mask it and add it to the leak test's secret list (the test passed blind because it never set the field). Claude-Session: https://claude.ai/code/session_014EY1emYd9vPqNhwW4mb4Jy --- app/tools.go | 6 ++++++ app/tools_test.go | 3 +++ 2 files changed, 9 insertions(+) diff --git a/app/tools.go b/app/tools.go index dcf932c..1553672 100644 --- a/app/tools.go +++ b/app/tools.go @@ -156,6 +156,12 @@ func redactConfig(cfg *config.Config) config.Config { if r.Agent.CertFingerprint != "" { r.Agent.CertFingerprint = secret } + if r.Agent.EnrollTicket != "" { + // A pending single-use enrollment ticket is a live credential until the + // gateway confirms enrollment — exactly the failing-to-pair window in which + // a user grabs a bundle. Never let it ride along. + r.Agent.EnrollTicket = secret + } if r.Agent.GatewayHost != "" { r.Agent.GatewayHost = host } diff --git a/app/tools_test.go b/app/tools_test.go index ad4ee92..f15d3e2 100644 --- a/app/tools_test.go +++ b/app/tools_test.go @@ -18,6 +18,7 @@ func sampleConfig() *config.Config { cfg.Agent.Token = "AGENTTOKENSECRET" cfg.Agent.GatewayHost = "gw.secret.example.com" cfg.Agent.CertFingerprint = "sha256:deadbeefsecret" + cfg.Agent.EnrollTicket = "tkt_ENROLLTICKETSECRET" cfg.Agent.Tunnels = []config.Tunnel{{ID: "t1", Name: "mc", LocalAddr: "10.9.8.7:25565"}} cfg.Gateway.Token = "GWTOKENSECRET" cfg.Gateway.PublicHost = "public.secret.example.com" @@ -35,8 +36,10 @@ func TestRedactConfigMasksEverySecret(t *testing.T) { "AGENTIDSECRET", "AGENTTOKENSECRET", "gw.secret.example.com", "deadbeefsecret", "10.9.8.7", "GWTOKENSECRET", "public.secret.example.com", "192.168.50.1", "127.0.0.99", + "ENROLLTICKETSECRET", } blob := r.Agent.AgentID + r.Agent.Token + r.Agent.GatewayHost + r.Agent.CertFingerprint + + r.Agent.EnrollTicket + r.Gateway.Token + r.Gateway.PublicHost + r.Gateway.BindAddr + r.Metrics.PrometheusAddr for _, t2 := range r.Agent.Tunnels { blob += t2.LocalAddr From d6a016d5b9107b9fd9635dd419200c4d468bceb5 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:20:08 +1200 Subject: [PATCH 2/5] gateway: push only in-scope tunnels on reconnect so a narrowed scope shows no phantom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pushConfigOnConnect reconciled the scope-filtered subset (never a bind bypass) but hashed and pushed the full stored set. If a scope was narrowed after a tunnel was adopted, the agent was handed a tunnel the gateway won't bind — a phantom "live" tunnel with no listener, against the "config drift never appears" promise. Push validSpecs for both the hash and the payload; in the common (unchanged-scope) case valid == stored, so nothing changes. Covered by TestGatewayConfigScopeNarrowingHidesTunnel. Claude-Session: https://claude.ai/code/session_014EY1emYd9vPqNhwW4mb4Jy --- internal/e2e/gatewayconfig_test.go | 47 ++++++++++++++++++++++++++++++ internal/gateway/gateway.go | 18 ++++++++---- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/internal/e2e/gatewayconfig_test.go b/internal/e2e/gatewayconfig_test.go index ba20f26..3cd22e5 100644 --- a/internal/e2e/gatewayconfig_test.go +++ b/internal/e2e/gatewayconfig_test.go @@ -127,3 +127,50 @@ func TestGatewayConfigDriftOnReconnect(t *testing.T) { t.Fatalf("reconnect must not bump the generation: %+v", agents) } } + +// TestGatewayConfigScopeNarrowingHidesTunnel: narrowing an agent's scope after a +// tunnel was adopted must not leave the agent showing a phantom tunnel. On the next +// reconnect the gateway reconciles and pushes only the in-scope set, so the +// now-out-of-scope tunnel is neither bound here nor learned by the agent. (gateway-config) +func TestGatewayConfigScopeNarrowingHidesTunnel(t *testing.T) { + echoA, closeA := echoServer(t) + defer closeA() + h := newHarnessWith(t, echoA, harnessOpts{enroll: true}) + h.waitPublicPort() + rec := waitConfigGen(t, h.gw, 1) + + // Adopt a second tunnel B alongside A (A pinned to its resolved port so it does + // not flap through the promote). + echoB, closeB := echoServer(t) + defer closeB() + portA, ok := h.agent.TunnelPublicPort(h.tunnelID) + if !ok { + t.Fatal("tunnel A has no resolved port before promote") + } + tunnelB := config.NewID() + h.agent.ApplyTunnels([]config.Tunnel{ + {ID: h.tunnelID, Name: "test", Type: config.TunnelTCP, LocalAddr: echoA, PublicPort: portA, Enabled: true}, + {ID: tunnelB, Name: "b", Type: config.TunnelTCP, LocalAddr: echoB, PublicPort: 0, Enabled: true}, + }) + waitConfigGen(t, h.gw, 2) + waitPortForTunnel(t, h.agent, tunnelB) + + // Narrow the agent's scope to tunnel A only, then reconnect it. + if !h.gw.SetAgentScope(rec.AgentID, gateway.Scope{TunnelIDs: []string{h.tunnelID}}) { + t.Fatal("SetAgentScope reported the agent was not found") + } + h.stopAgent() + h.startAgent() + + // A comes back live; B must be neither pushed to the agent nor bound. + h.waitPublicPort() + roundTrip(t, fmt.Sprintf("127.0.0.1:%d", portA), []byte("in-scope tunnel survives")) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if _, ok := h.agent.TunnelPublicPort(tunnelB); !ok { + return // B correctly excluded from the pushed set + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("out-of-scope tunnel B was still pushed to the agent after scope narrowing") +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index b203140..40c7caf 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -283,8 +283,8 @@ func (g *Gateway) RevokeAgent(agentID string) bool { } ok := g.agents.Revoke(agentID) if a := g.act(); a != nil { - // One actor hop: read the live session and evict it in the same fn. (Calling - // a.session here would nest a.do inside a.do and deadlock.) + // One actor hop: read the live session and evict it in the same fn. Looking it + // up through an accessor that itself calls a.do would nest a.do and deadlock. a.do(func() { if s := a.agents[agentID]; s != nil { a.evict(s, "agent revoked") @@ -1369,16 +1369,22 @@ func (g *Gateway) pushConfigOnConnect(sess *agentSession) { if !ok || gen == 0 { return // bootstrap: await the agent's seed propose_config } - g.act().reconcile(sess, g.validSpecs(sess, stored), g.cfg.Gateway.BindAddr, g.cfg.Gateway.PortAllowlist, g.handleClient) - hash := control.HashTunnels(stored) + // Push exactly the set the current scope permits binding, not the raw stored + // set: if the scope was narrowed after a tunnel was adopted, that tunnel is no + // longer bound here, and pushing it would show the agent a phantom "live" tunnel + // with no listener behind it. In the common (unchanged-scope) case valid==stored, + // so the hash and payload are identical and nothing changes. + valid := g.validSpecs(sess, stored) + g.act().reconcile(sess, valid, g.cfg.Gateway.BindAddr, g.cfg.Gateway.PortAllowlist, g.handleClient) + hash := control.HashTunnels(valid) if sess.reportedConfigHash == hash && sess.agentConfigGen.Load() == gen { return // agent is already in sync; listeners are (re)bound, nothing to push } - if err := sess.writeControl(control.TypePushConfig, control.PushConfig{Generation: gen, Hash: hash, Tunnels: stored}); err != nil { + if err := sess.writeControl(control.TypePushConfig, control.PushConfig{Generation: gen, Hash: hash, Tunnels: valid}); err != nil { sess.logger.Warn("push_config on connect failed", "err", err) return } - sess.logger.Info("pushed authoritative config", "generation", gen, "tunnels", len(stored)) + sess.logger.Info("pushed authoritative config", "generation", gen, "tunnels", len(valid)) } // adoptProposal handles a propose_config from an enrolled gateway-config agent: a From 96af605baf1e7a8e5b4c14d650f4fd86f99b25ba Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:20:19 +1200 Subject: [PATCH 3/5] lint: clear the golangci gate before its first CI run Three pre-existing findings would fail the gate on first push (it has never run): - unused: (*actor).session(agentID) had no callers (RevokeAgent inlines the lookup to avoid nesting a.do); remove the dead accessor. - SA4000: the HashTunnels determinism check compared identical expressions (HashTunnels(a) != HashTunnels(a)); store the two calls before comparing, same intent. - misspell: "alltime" in proxyforward_alltime_bytes_total is a Prometheus metric-name token (hyphens are invalid there); add a documented ignore-rule. Claude-Session: https://claude.ai/code/session_014EY1emYd9vPqNhwW4mb4Jy --- .golangci.yml | 5 +++++ internal/control/control_test.go | 2 +- internal/gateway/actor.go | 7 ------- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 4dbf049..1e0b71f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -19,6 +19,11 @@ linters: # comments are the spec and its explicitness is deliberate; those checks # would fight it for no correctness gain. checks: ["all", "-QF*", "-ST*"] + misspell: + # "alltime" is a Prometheus metric-name token (proxyforward_alltime_bytes_total). + # Metric names cannot contain hyphens, so "all-time" is not a valid alternative. + ignore-rules: + - alltime exclusions: presets: diff --git a/internal/control/control_test.go b/internal/control/control_test.go index 6b5c5de..e618c6f 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -189,7 +189,7 @@ func TestHashTunnels(t *testing.T) { t.Fatal("empty and populated sets collide") } // Deterministic across calls (no map iteration order leaking in). - if HashTunnels(a) != HashTunnels(a) { + if h1, h2 := HashTunnels(a), HashTunnels(a); h1 != h2 { t.Fatal("hash is not deterministic") } } diff --git a/internal/gateway/actor.go b/internal/gateway/actor.go index f7eb7d1..98700e2 100644 --- a/internal/gateway/actor.go +++ b/internal/gateway/actor.go @@ -621,13 +621,6 @@ func (a *actor) sessions() []*agentSession { return out } -// session returns the live session for one agentID, or nil. -func (a *actor) session(agentID string) *agentSession { - var sess *agentSession - a.do(func() { sess = a.agents[agentID] }) - return sess -} - // TunnelSnapshot is one registered tunnel's live state, for status surfaces. type TunnelSnapshot struct { AgentID string // the agent that registered this tunnel From 0e1ba733ac6c291e31d04b4e942aa4551bff7e7e Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:20:33 +1200 Subject: [PATCH 4/5] ui: consume the pxf:// deep link into pairing; reusable codes no longer expire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend parsed, forwarded, stashed, and emitted a clicked pxf:// link, but no React code drained it — the link surfaced the window and died there. App now takes the pending deep link on mount and subscribes to pxf:deeplink, opening the wizard straight onto the agent paste step with the code prefilled (confirm-to-connect, never auto). Also: the wizard passed ttl 600 even for a reusable code, so a code labelled "until you revoke it" silently expired in 10 minutes — reusable now maps to ttl 0. devmock gains a &deeplink=1 axis for headless verification. Claude-Session: https://claude.ai/code/session_014EY1emYd9vPqNhwW4mb4Jy --- frontend/src/App.tsx | 18 +++++++++++++++++- frontend/src/devmock.ts | 9 ++++++--- frontend/src/screens/Wizard.tsx | 14 ++++++++++++-- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index becc279..5eb3284 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,8 @@ import {Spinner} from './components/ui' import {UIStatus, useTick} from './state' import {prefersReduced} from './motion' import {resetBands} from './rubberband' +import {TakePendingDeepLink} from '../wailsjs/go/app/App' +import {EventsOn} from '../wailsjs/runtime/runtime' const supportsVT = typeof (document as Document & {startViewTransition?: unknown}).startViewTransition === 'function' @@ -117,7 +119,21 @@ export default function App() { useEffect(() => { if (backendWizard) setWizardHold(true) }, [backendWizard]) + + // A clicked pxf:// pairing invite arrives from the OS via the backend: stashed for + // a cold launch (drained once) or emitted on a warm one. Either way, open the wizard + // straight onto the agent paste step with the code prefilled. The user still reviews + // and confirms — a clicked link never auto-connects. + const [deepLink, setDeepLink] = useState('') + useEffect(() => { + let done = false + TakePendingDeepLink().then(url => { if (!done && url) { setDeepLink(url); setWizardHold(true) } }).catch(() => {}) + const off = EventsOn('pxf:deeplink', (url: string) => { setDeepLink(url); setWizardHold(true) }) + return () => { done = true; off() } + }, []) + const finishWizard = () => { + setDeepLink('') // a later manual reopen starts at role selection, not a stale link const doc = document as Document & {startViewTransition?: (cb: () => void) => void} if (!prefersReduced() && doc.startViewTransition) { // Glaze the handover: the ambient glow flares and a glare sweep crosses @@ -142,7 +158,7 @@ export default function App() { if (backendWizard || wizardHold) { return ( }> - + ) } diff --git a/frontend/src/devmock.ts b/frontend/src/devmock.ts index ab4628d..3310ac3 100644 --- a/frontend/src/devmock.ts +++ b/frontend/src/devmock.ts @@ -58,6 +58,7 @@ export function installDevMock() { const axisGeo = params.get('geo') || '' // off | empty | error | pending const axisFleet = params.get('fleet') || '' // '' (single agent) | multi | old const axisConflict = params.get('conflict') || '' // '' | port | clone | both + const axisDeepLink = params.get('deeplink') === '1' // simulate a clicked pxf:// invite const fx = params.get('fx') if (fx) document.documentElement.dataset.fx = fx // &fx=high | &fx=low @@ -949,9 +950,11 @@ export function installDevMock() { return undefined })() : Promise.reject(new Error(`agent ${agentId} not found (it may have already disconnected or been removed)`))), - // No OS deep link in browser dev; the real app pulls this once on mount to open - // straight into pairing when launched via a clicked pxf:// link. - TakePendingDeepLink: () => ok(''), + // The real app pulls this once on mount to open straight into pairing when + // launched via a clicked pxf:// link. In browser dev &deeplink=1 simulates one. + TakePendingDeepLink: () => ok(axisDeepLink + ? 'pxf://play.example.com:8474/v1/pair/tkt_0123456789abcdef0123456789abcdef#sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + : ''), Version: () => ok('0.1.0-dev'), LogsSince: (seq: number) => ok(axisAttached ? [] : logs.filter(l => l.seq > seq)), TestReachability: () => new Promise(r => setTimeout(() => r('Reachable: play.example.com:25565 answered in 38ms — players can connect.'), 700)), diff --git a/frontend/src/screens/Wizard.tsx b/frontend/src/screens/Wizard.tsx index 4dc3dd3..77d7716 100644 --- a/frontend/src/screens/Wizard.tsx +++ b/frontend/src/screens/Wizard.tsx @@ -16,7 +16,7 @@ type Kind = 'gateway' | 'agent' * before handing over to the console. Designed so a non-technical user can * finish in under a minute; the pairing code is the only thing that moves * between machines. */ -export function Wizard({status, onDone}: {status: UIStatus | null; onDone: () => void}) { +export function Wizard({status, onDone, deepLink}: {status: UIStatus | null; onDone: () => void; deepLink?: string}) { const [act, setAct] = useState('role') const [kind, setKind] = useState('gateway') const [err, setErr] = useState('') @@ -36,6 +36,15 @@ export function Wizard({status, onDone}: {status: UIStatus | null; onDone: () => .catch(() => {}) }, []) + // A pxf:// deep link (a clicked pairing invite) drops the user straight onto the + // agent paste step with the code prefilled — parsed inline below, still + // confirm-to-connect. A pairing code always means "make this machine the agent". + useEffect(() => { + if (deepLink && deepLink.startsWith('pxf://')) { + setErr(''); setKind('agent'); setPairing(deepLink); setAct('agent') + } + }, [deepLink]) + // Preview a role's ambient hue on hover — the whole backdrop leans in. const preview = (role: '' | Kind) => { document.documentElement.dataset.role = role || 'unset' @@ -198,7 +207,8 @@ function GatewayLive({status, controlPort, onDone}: { let cancelled = false setBusy(true); setCode(''); setErr('') const poll = (n: number) => { - IssuePairingCode(reusable, 600, [], []).then(c => { if (!cancelled) { setCode(c); setBusy(false) } }) + // Single-use expires in 10 min; a reusable code lives until it is revoked (ttl 0). + IssuePairingCode(reusable, reusable ? 0 : 600, [], []).then(c => { if (!cancelled) { setCode(c); setBusy(false) } }) .catch(e => { if (!cancelled) { if (n < 20) setTimeout(() => poll(n + 1), 250); else { setErr(String(e)); setBusy(false) } } }) } poll(0) From a745be577cfad5d5c92651d3cbc980a23c628237 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:20:45 +1200 Subject: [PATCH 5/5] docs: per-agent identity/enrollment/pairing/revocation; rewrite residual-risk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 9 of the identity work. architecture.md gains identity/enrollment/pairing rows in "The numbers" and a deep-dive section (Ed25519 identity, proof-of-possession auth, enrollment tickets, AgentStore, validators, revocation, scope, the pxf:// scheme + click-to-pair, the version axes, and an honest "deferred" list). CLAUDE.md's stale residual-risk paragraph is rewritten — per-agent identity + revocation shipped; the shared token is now only a migration fallback with a narrowed, path-scoped risk — and "revoked" joins the fatal-auth list. polish-backlog records the backend-ahead-of-GUI agent-management surfaces. Also drops a stale (CapEnroll) comment (identity is field-driven, not a capability) and adds gateway_agents.json to doccheck's runtime-file allowlist. Citations land green. Claude-Session: https://claude.ai/code/session_014EY1emYd9vPqNhwW4mb4Jy --- CLAUDE.md | 25 ++++--- docs/agent/architecture.md | 100 ++++++++++++++++++++++++++++ docs/agent/polish-backlog.md | 37 ++++++++++ internal/control/control.go | 8 ++- internal/doccheck/citations_test.go | 1 + 5 files changed, 159 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ec49b00..1b7943c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,20 +75,27 @@ Each entry: the rule, why, and the symbol that embodies it today. Numbers live i dies; failed auth rate-limits per IP, fail2ban-style — successes never count (`limits.go authLimiter`). Public conns gate globally and per-IP (`limits.go connGate`; defaults in `config.go`). -- One shared gateway token admits **many** agents, told apart by self-asserted - `agentID`: a matching agentID **supersedes** (reconnect), a distinct one is admitted - **alongside**. Supersede is anti-flap dampened so an ID collision degrades to a slow - contest, not a loop (`actor.go admit`, `noteSupersede`). Residual risk that ships - (shared token + self-asserted ID + FCFS ports + no per-agent revocation): a - token-holder can supersede or port-squat any agent, recoverable only by rotating the - shared token — the mitigation (per-agent tokens/revocation) is deferred. +- **Per-agent identity is the trust model.** Each agent proves possession of a long-term + Ed25519 key the gateway allowlists; its derived `agentID` (`agt_…`) is unforgeable, + individually **scoped** (ports/tunnels) and **revocable** — revocation evicts the live + session and makes the next connect fatal `ErrCodeRevoked`. Agents join via a single-use + enrollment ticket in the pairing code (`gateway/auth.go identityValidator`, `agentstore.go`, + `link/cred.go`; mechanics in `docs/agent/architecture.md`). A matching agentID + **supersedes** (reconnect), a distinct one is admitted **alongside**; supersede is + anti-flap dampened so an ID collision degrades to a slow contest, not a loop + (`actor.go admit`, `noteSupersede`). +- The legacy **shared token** survives only as a migration fallback, accepted while + `Gateway.AcceptSharedToken` is on (default for now). A shared-token agent self-asserts its + `agentID`, so the old residual risk — supersede or port-squat, recoverable only by rotating + the token — persists **for that path only**; enrolled agents are protected by their key and + by revocation, and disabling `AcceptSharedToken` once every agent is enrolled closes it. - The IPC pipe ACL admits Administrators, SYSTEM, and the interactive user only (`ipc/server_windows.go pipeSecurity`). - Diagnostics bundles redact every secret, host, IP, and identity; peer IPs become stable sha256 pseudonyms (`app/tools.go`, leak-tested in `app/tools_test.go`). Anything new that exports data must pass the same no-leak test style. -- Fatal auth errors (`bad_token`, `agent_conflict`, `version`) stop the agent instead - of retry-hammering the gateway (fatal classification in `agent.go Run`), and +- Fatal auth errors (`bad_token`, `agent_conflict`, `version`, `revoked`) stop the agent + instead of retry-hammering the gateway (fatal classification in `agent.go isFatal`), and surface in the UI via `EngineFatal` on the tick. ### Liveness & lifecycle diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index a6359b6..cd9e896 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -108,6 +108,11 @@ persisted to config. | Pipe | 5 s request / 2 min idle timeouts; ACL BA+SY+IU | `ipc/server_windows.go` | | Cert | ECDSA P-256, 20-year validity (trust = pin, not expiry) | `link/cert.go:86` | | Key exchange | X25519MLKEM768 (PQ hybrid, Go default; `CurvePreferences` unset) | `link/cert.go`, `link/pq_test.go` | +| Agent identity | Ed25519 (`agent_identity.key`, PKCS#8 PEM, `0600`); `agentID` = `agt_` + 8-char Crockford base32 of sha256(pubkey)[:5] = 40 bits | `link/cred.go LoadOrCreateIdentity fingerprint` | +| Enrollment ticket | `tkt_` + 128-bit nonce; single-use default, reusable optional; TTL caller-set, 0 = never (UI single-use default 10 min) | `link/cred.go NewEnrollTicket`, `gateway/agentstore.go IssueEnrollment` | +| Agent allowlist | `gateway_agents.json` (`0600`, atomic write + AV-retry): identity + scope + desired config per agent | `gateway/agentstore.go AgentStore` | +| Pairing code | `pxf://host:port/v1/pair/#sha256:<64hex>`, ≤ 512 B before parse | `link/pairing.go ParsePairingCode` | +| Agent auth | Ed25519 proof-of-possession over `proxyforward-agent-auth-v1` + gateway cert FP — no bearer token in steady state | `link/cred.go AgentAuthMessage` | | Perf floor | ≥20 MiB/s, worst cross-stream RTT ≤500 ms (64 MiB loopback burst); per-transport twins `TestBurstThroughputPerConn` / `TestBurstThroughputQUIC` | `e2e_test.go:716,719` | | Blur ladder | control 10, Signal Glass 20, card frost 30, chrome 36, island 40, float 48, pop 56 px | `tokens.css` | | Switch geometry | 40×22 track, 1px rim + 2px seat → 16px knob (7px radius), 18px travel; ×`--ui-scale` | `tokens.css`, `ui.tsx Switch` | @@ -180,6 +185,101 @@ change) only once a later rung succeeds — the "UDP blocked" tell; if every run link is simply down, so nothing is cooled. The rung that connects is reported to the GUI as `ActiveTransport` (tick `Status.Transport`) so a user can see what auto settled on. +## Per-agent identity, enrollment & revocation (`internal/link/cred.go`, `internal/gateway/agentstore.go`, `internal/gateway/auth.go`) + +The trust root is still the gateway's pinned self-signed cert (`link/cert.go`); layered on +top is a per-agent cryptographic identity so agents are told apart, scoped, and revoked +individually rather than sharing one bearer token. + +**Identity.** On first run the agent generates a long-term Ed25519 keypair and persists the +PKCS#8 private key `0600` beside its config (`link/cred.go LoadOrCreateIdentity`); the private +half never leaves the machine, and a corrupt/non-Ed25519 file is a fatal, actionable error, +never a silent regeneration (which would orphan the allowlist entry). The **canonical +identity is the raw public key** — the gateway allowlist is keyed by it. The human-facing +`agentID` is *derived*: `agt_` + an 8-char Crockford-base32 fingerprint (no confusable +`i/l/o/u`) of the first 40 bits of sha256(pubkey) (`link/cred.go AgentID fingerprint`). +Derived, so it is stable (the same machine always re-derives it; re-pairing never dupes) and +unforgeable (bound to a private key nobody else holds). The ID grammar is +`_`: `gw_` over the cert DER, `agt_` over the pubkey, `tnl_` a slug of the +tunnel name with a `-2` collision suffix (`link/cred.go GatewayID TunnelID`). A freely-editable +nickname layers on top as display sugar. + +**Steady-state auth is proof-of-possession, not a bearer token.** In the hello the agent +sends `AgentPubKey` plus an Ed25519 `AgentSig` over `AgentAuthMessage` = the constant +`proxyforward-agent-auth-v1` joined to the *pinned gateway cert fingerprint* (`link/cred.go +AgentAuthMessage SignAgentAuth`). The gateway verifies the signature, checks the pubkey is +allowlisted and not revoked (`gateway/auth.go identityValidator`), and admits — no extra +round-trip, and the agent still speaks first, so hello frames to a legacy gateway stay +byte-identical (all new fields `omitempty`). Binding to the cert fingerprint (rather than the +originally-specced per-session TLS exporter) means a signature made for one gateway can never +be replayed to another; same-gateway replay resistance rests on TLS 1.3 confidentiality — +only the real gateway or the agent itself ever sees the signature and either already holds the +private key — so the signature is static per (agent, gateway) pair and the identical message +works over both TCP and QUIC. The bearer token now survives only as the enrollment ticket. + +**Enrollment.** The gateway mints a single-use ticket `tkt_` + 128-bit nonce (`link/cred.go +NewEnrollTicket`) and embeds it in a pairing code. On first contact the agent replays it in +`Hello.EnrollTicket`; the gateway validates-and-consumes it under one lock (a spent single-use +ticket is refused — `ErrTicketConsumed`), records the pubkey, derives and stores the +`agentID`, and returns it in `HelloOK.AssignedAgentID` alongside `GatewayID`. Single-use is +the default; a **reusable** ticket (enrolls many agents until revoked) and an optional expiry +(zero = never) are the flagged alternatives. Enrollment is **field-driven, not a capability** +— acted on before capability negotiation, so there is deliberately no `CapEnroll`. + +**AgentStore.** The allowlist and outstanding tickets persist to `gateway_agents.json` (`0600`, +single writer, atomic temp+rename with the AV-retry of `setup.atomicWrite`) — deliberately +*not* in `analytics.db`, which is role-blind history (`gateway/agentstore.go AgentStore +LoadAgentStore`). Each record carries identity, nickname, scope, and the gateway-authoritative +desired tunnel set (see "Control-plane message flow"). + +**Validators.** A `compositeValidator` tries the identity path first, then — only while +`Gateway.AcceptSharedToken` is on (a migration default) — falls back to the legacy shared token +(`gateway/auth.go compositeValidator sharedTokenValidator`; token and fingerprint still compare +in constant time). Every accept path (TCP control, QUIC, per-conn data) funnels through the one +`Validator.Validate` seam, so identity is enforced uniformly. + +**Revocation.** `Gateway.RevokeAgent` removes the pubkey from the allowlist and evicts any live +session at once; the next connect is a fatal `ErrCodeRevoked`, which the agent classifies as +fatal (`agent.go isFatal`) and stops on rather than retry-hammering — surfaced in the GUI via +`EngineFatal`. Regression: e2e `TestEnrollAndRevoke`. + +**Scope.** A ticket/record carries `Scope{Ports, TunnelIDs}` (empty = unrestricted), enforced +at bind in `validateSpec` — an out-of-scope port is `ErrCodePortNotAllowed`, an out-of-scope +tunnel `ErrCodeBadTunnel` — on *both* the register path and the gateway-config push/adopt path, +so a scoped agent can never bind outside its grant by any route (`gateway/agentstore.go Scope`; +`gateway/gateway.go validSpecs`). Regressions: `TestGatewayConfigScopeNarrowingHidesTunnel`, +`gateway/scope_test.go`. + +**Pairing scheme & click-to-pair.** The pairing code is `pxf://host:port/v1/pair/#sha256:` +(`link/pairing.go PairingCode`). `pxf` is a permanent brand *and* the OS deep-link scheme +(`wails.json` registers it via the NSIS macros); the `/v1/` segment versions the code's *shape* +independently of the wire `ProtocolVersion`; the `/pair/` segment is a role/kind marker so a +wrong-kind link fails loudly instead of half-parsing (`link/pairing.go tokenFromV1Path`). The +parser caps input at 512 bytes before parsing and validates host, port, and the 64-hex +fingerprint; it is fuzzed (`link/pairing_fuzz_test.go FuzzParsePairingCode`). A clicked `pxf://` +link reaches the app as `os.Args` on a cold launch or via single-instance forwarding on a warm +one (`main.go deepLinkArg`, `app/app.go HandleDeepLink TakePendingDeepLink`); the frontend drains +it on mount and opens the wizard straight onto the agent paste step with the code prefilled — +confirm-to-connect, never auto-connect (`frontend/src/App.tsx`, `frontend/src/screens/Wizard.tsx`). + +**Version axes** (independent, so any one evolves alone): + +| Axis | Where | Bumps when | +|---|---|---| +| Scheme | `pxf` | Never (permanent brand) | +| Pairing format | the `/v1/` path segment | The code's shape changes | +| Protocol | `Hello.ProtocolVersion` (stays 1) | The hello exchange itself breaks | +| Config authority | `Hello.ConfigGeneration` (per-agent, monotonic) | The gateway adopts a new desired set | +| Crypto suite | TLS-negotiated (`X25519MLKEM768` today) | An algorithm is added/retired | + +**Deferred (honest state).** Per-agent **key rotation** ("old key signs new") and PQ +*signatures* are roadmap, not shipped (the KEM is already PQ; forging Ed25519 needs a quantum +computer at attack time, so there is no harvest-now risk). There is no on-disk `config_version` +schema field yet — there are no config migrations and the local config dir is disposable. The +`pf1://` scheme was dropped outright rather than kept as a compat parser (pre-release, no codes +in the wild). Several agent-management surfaces are backend-complete but not yet fully exposed in +the GUI — tracked in `docs/agent/polish-backlog.md`. + ## Analytics data model (`internal/analytics/schema.go`) - `rrd(tier,t,…)` — persisted image of stats tiers 2–4 (28 OHLC/gauge columns; −1 = diff --git a/docs/agent/polish-backlog.md b/docs/agent/polish-backlog.md index d7df9cf..97e8aa7 100644 --- a/docs/agent/polish-backlog.md +++ b/docs/agent/polish-backlog.md @@ -163,3 +163,40 @@ Ordered by user-visible impact. "Fix" describes the smallest on-system change. `*_other.go` stubs honest, but `ipc.Serve` returns `ErrUnsupported` off Windows (`internal/ipc/stub_other.go`), so the engine never starts. Shipping them means a real unix-socket IPC port — an `overhaul`-skill change, not a stub fix. + +## Agent-management surfacing (backend ahead of the GUI) + +The per-agent identity/enrollment/config work landed backend-first; these surfaces are +implemented and tested in Go but only partly exposed. None is a bug — each is an unbuilt +GUI affordance, so they belong here, not in the "Reality check" (nothing is *oversold*). + +26. **Enrollment issuance is under-surfaced** — `IssuePairingCode` (mode + scope + ttl) is + reachable only from the first-run wizard's reusable toggle (`frontend/src/screens/Wizard.tsx`), + with ttl and scope fixed. The Overview pairing strip still hands out the *legacy + shared-token* code (`frontend/src/screens/Overview.tsx`), and the Agents screen has no + issue-code control — so the headline per-agent-enrollment path isn't the default one. + Fix: an issue-code control (mode/scope/ttl) on the Agents screen, and switch Overview's + strip to `IssuePairingCode`. +27. **Port-conflict cards are informational, not one-click** — the gateway auto-reassigns a + clashing public port and logs it, but the roster's conflict cards + (`frontend/src/screens/Agents.tsx ConflictCards`) only offer "View agent"; the planned + one-click "take the port / evict the other" needs a gateway reclaim op. Auto-reassign + already avoids the hard failure, so this is a convenience affordance. Fix: a reclaim + engine op + a card action. +28. **No per-agent "Update" nudge** — `AgentView` (`gateway.go AgentView`) carries no agent + app version, so a version-skewed agent can't be flagged in the roster. Fix: thread the + agent's reported version onto `AgentView` and render a card badge. +29. **Config overlay/promote has no GUI** — gateway-authoritative sync works at the wire + level (push/propose/adopt), but no App method or view shows a pending local edit and a + "propose to gateway" action. The gateway reconciles silently, so nothing is broken; the + L2 overlay view is simply unbuilt. Fix: surface the pending overlay + a promote button + when the agent's set diverges. +30. **Per-agent transport pin absent** — transport (auto / quic / per-conn / mux) is one + global agent setting; per-agent management (`frontend/src/screens/Agents.tsx`) can't pin + a single agent's transport. Low priority. Fix: a per-agent override if ever needed. +31. **`link.TunnelID` minter is unused in production** — the typed `tnl_` + `-2`-suffix + minter (`link/cred.go TunnelID`, tested by `TestTunnelID`) is not on the real + tunnel-create path, which still mints random hex (`config/config.go NewID`). Cross-agent + tunnel-ID uniqueness isn't required (per-agent namespacing + port auto-reassign handle + clashes), so it's a latent primitive, not a bug. Fix: adopt `tnl_` IDs on create, or drop + the unused minter. diff --git a/internal/control/control.go b/internal/control/control.go index e5f631b..67dd115 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -182,9 +182,11 @@ type Hello struct { LocalIPs []string `json:"localIps,omitempty"` // AgentPubKey is the agent's long-term Ed25519 public key; AgentSig is its // signature over this connection's TLS channel binding (proof of possession). - // Together they authenticate the agent per-identity against the gateway's - // allowlist (CapEnroll). Both omitempty: a legacy agent that only knows the - // shared token sends neither and the gateway falls back to the token. + // Together they authenticate an already-enrolled agent per-identity against + // the gateway's allowlist; first-contact enrollment rides EnrollTicket below. + // Identity is field-driven, not a capability (acted on before negotiation). + // Both omitempty: a legacy agent that only knows the shared token sends + // neither and the gateway falls back to the token. AgentPubKey []byte `json:"agentPubKey,omitempty"` AgentSig []byte `json:"agentSig,omitempty"` // EnrollTicket is a pairing ticket present only on a first-contact enrollment diff --git a/internal/doccheck/citations_test.go b/internal/doccheck/citations_test.go index c1a9a23..7ad5df5 100644 --- a/internal/doccheck/citations_test.go +++ b/internal/doccheck/citations_test.go @@ -42,6 +42,7 @@ var ignoredFiles = map[string]bool{ "stats.json": true, "stats.redacted.json": true, "config.toml": true, + "gateway_agents.json": true, // the gateway's per-agent allowlist, written at runtime } // Placeholder test names used in prose ("go test -run TestX ...").