From 8e0ff77e14c4d8e047f8d2e0172bedb1744ca149 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:47:03 -0400 Subject: [PATCH 01/11] fix(sync): detect zombie relay controls and gate publication on end-to-end probes JSON control keepalive (DO-answered, unlike edge-terminated native pings), relay self-probe of our own /connect/ after control open and every bridge validation, honest relay endpoint publication (drop stale retention on verified failure), truthful sync status + doctor verdicts, and zombie telemetry logs. Co-Authored-By: Claude Fable 5 --- apps/ade-cli/src/adeRpcServer.ts | 3 + apps/ade-cli/src/cli.test.ts | 65 ++- apps/ade-cli/src/cli.ts | 91 ++++ apps/ade-cli/src/multiProjectRpcServer.ts | 13 + .../accountMachinePublisherService.test.ts | 88 ++++ .../account/accountMachinePublisherService.ts | 17 +- .../sync/syncLoopbackCollision.test.ts | 6 + .../services/sync/syncRelaySelfProbe.test.ts | 95 ++++ .../src/services/sync/syncRelaySelfProbe.ts | 136 +++++ apps/ade-cli/src/services/sync/syncService.ts | 46 +- .../sync/syncTunnelClientService.test.ts | 344 ++++++++++++- .../services/sync/syncTunnelClientService.ts | 486 +++++++++++++++++- 12 files changed, 1355 insertions(+), 35 deletions(-) create mode 100644 apps/ade-cli/src/services/sync/syncRelaySelfProbe.test.ts create mode 100644 apps/ade-cli/src/services/sync/syncRelaySelfProbe.ts diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index a333d1103..4ecbae3f2 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -5203,6 +5203,9 @@ export function createAdeRpcRequestHandler(args: { forceTransferReadiness: params.forceTransferReadiness === true, }); } + if (method === "sync.runSelfProbe") { + return await syncService.runSelfProbe(); + } if (method === "sync.refreshDiscovery") { return await syncService.refreshDiscovery(); } diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 1e3316b7c..47265800a 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -1141,6 +1141,9 @@ describe("ADE CLI", () => { lastControlError: "Relay control closed (1011): upstream restart", lastControlOpenAt: "2026-07-16T11:58:00.000Z", lastBridgeValidationAt: "2026-07-16T11:57:30.000Z", + relayEndToEndVerifiedAt: new Date(Date.now() - 60_000).toISOString(), + relayEndToEndFailure: null, + relayEndToEndRoundTripMs: 37, }, accountDirectory: { state: "http_error", @@ -1171,6 +1174,7 @@ describe("ADE CLI", () => { expect(output).toContain("Relay control closed (1011): upstream restart"); expect(output).toContain("2026-07-16T11:58:00.000Z"); expect(output).toContain("2026-07-16T11:57:30.000Z"); + expect(output).toMatch(/relay end-to-end\s+verified 1 minute ago \(37ms\)/); expect(output).toContain("account directory"); expect(output).toContain("http_error · 2 reachable endpoints · HTTP 401"); expect(output).toContain("The account directory returned HTTP 401: invalid issuer"); @@ -1183,7 +1187,10 @@ describe("ADE CLI", () => { it("formats the authoritative relay blocker without mistaking historical control errors for one", () => { const plan = expectExecutePlan(buildCliPlan(["sync", "status"])); - const formatRelay = (skipReason: string | null) => formatOutput({ + const formatRelay = ( + skipReason: string | null, + relayEndToEndFailure: string | null = null, + ) => formatOutput({ mode: "brain", role: "brain", runtimeRole: "host", @@ -1198,6 +1205,9 @@ describe("ADE CLI", () => { relayBridgeValidated: true, skipReason, lastControlError: "Relay control closed (1012): historical restart", + relayEndToEndVerifiedAt: relayEndToEndFailure ? null : new Date().toISOString(), + relayEndToEndFailure, + relayEndToEndRoundTripMs: relayEndToEndFailure ? null : 21, }, accountDirectory: { state: "published", @@ -1213,6 +1223,11 @@ describe("ADE CLI", () => { const recovered = formatRelay(null); expect(recovered).toMatch(/relay\s+reachable/); expect(recovered).toContain("Relay control closed (1012): historical restart"); + + const failed = formatRelay(null, "Relay self-probe closed before ready (4501): host offline."); + expect(failed).toMatch( + /relay end-to-end\s+FAILED: Relay self-probe closed before ready \(4501\): host offline\./, + ); }); it("formats sync web pairing info from sync status", () => { @@ -4774,6 +4789,11 @@ describe("ADE CLI", () => { params: { includeTransferReadiness: false }, optional: true, }); + expect(plan.steps).toContainEqual({ + key: "relaySelfProbe", + method: "sync.runSelfProbe", + optional: true, + }); const summary = summarizeExecution({ plan, connection: { @@ -4807,6 +4827,10 @@ describe("ADE CLI", () => { }, }, }, + relaySelfProbe: { + ok: false, + detail: "Relay self-probe closed before ready (4501): host offline.", + }, }, } as any) as Record; @@ -4822,6 +4846,11 @@ describe("ADE CLI", () => { ]); expect(summary.sync.message).toContain("404 Not Found"); expect(summary.sync.message).toContain("HTTP 401: token expired"); + expect(summary.relaySelfProbe).toMatchObject({ + ready: false, + status: "warning", + message: expect.stringContaining("FAILED"), + }); const output = formatOutput(summary, { projectRoot, workspaceRoot: projectRoot, @@ -4835,6 +4864,40 @@ describe("ADE CLI", () => { }, "doctor"); expect(output).toContain("Sync route failure"); expect(output).toContain("listener"); + expect(output).toContain("relay self-probe"); + expect(output).toContain("host offline"); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("marks the doctor Relay self-probe skipped when no brain is running", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-doctor-no-brain-")); + fs.mkdirSync(path.join(projectRoot, ".ade"), { recursive: true }); + try { + const summary = summarizeExecution({ + plan: expectExecutePlan(buildCliPlan(["doctor"])), + connection: { + mode: "headless", + projectRoot, + workspaceRoot: projectRoot, + socketPath: path.join(projectRoot, ".ade", "ade.sock"), + }, + values: { + rpcActions: { actions: [{}] }, + actions: { actions: [{}] }, + relaySelfProbe: { + ok: false, + detail: "Relay self-probe skipped because the control socket is not connected.", + }, + }, + } as any) as Record; + + expect(summary.relaySelfProbe).toEqual(expect.objectContaining({ + ready: true, + status: "unavailable", + message: "Skipped — no running ADE brain.", + })); } finally { fs.rmSync(projectRoot, { recursive: true, force: true }); } diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 48593934b..3ba82df61 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -11766,6 +11766,11 @@ function buildCliPlan( params: { includeTransferReadiness: false }, optional: true, }, + { + key: "relaySelfProbe", + method: "sync.runSelfProbe", + optional: true, + }, ], }; } @@ -12518,6 +12523,49 @@ function checkSyncReadiness(value: unknown): ReadinessCheck & { }; } +function checkRelaySelfProbeReadiness( + value: unknown, + brainRunning: boolean, +): ReadinessCheck { + if (!brainRunning) { + return { + ready: true, + status: "unavailable", + message: "Skipped — no running ADE brain.", + }; + } + if (!isRecord(value)) { + return { + ready: false, + status: "unavailable", + message: "Relay self-probe verdict is unavailable.", + }; + } + const detail = asString(value.detail) + ?? asString(isRecord(value.error) ? value.error.message : value.error) + ?? "Relay self-probe returned no detail."; + if (value.ok === true) { + return { + ready: true, + status: "ready", + message: detail, + }; + } + if (/skipped/i.test(detail)) { + return { + ready: true, + status: "unavailable", + message: detail, + }; + } + return { + ready: false, + status: "warning", + message: `FAILED: ${detail}`, + nextAction: "Run 'ade sync status --text' and resolve the Relay failure.", + }; +} + function requireAdeLayout(): { resolveAdeLayout: (projectRoot: string) => { secretsDir: string }; } { @@ -12576,6 +12624,10 @@ function buildReadinessSnapshot(args: { path: checkPathReadiness(), storage: checkStorageReadiness(connection.projectRoot), sync: checkSyncReadiness(values.syncStatus), + relaySelfProbe: checkRelaySelfProbeReadiness( + values.relaySelfProbe, + connection.mode !== "headless", + ), }; const recommendations = Object.entries(checks) .filter(([, check]) => check.nextAction) @@ -12652,6 +12704,7 @@ function buildReadinessSnapshot(args: { path: checks.path, storage: checks.storage, sync: checks.sync, + relaySelfProbe: checks.relaySelfProbe, auth: { localProjectAccess: projectInitialized && actions.length > 0, providerSecretsExposed: false, @@ -16050,6 +16103,25 @@ function isSyncWebPairingCliOutput(value: unknown): value is SyncWebPairingCliOu ); } +function relativeTime(value: string): string { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return value; + const deltaMs = Date.now() - timestamp; + const future = deltaMs < 0; + const seconds = Math.max(0, Math.round(Math.abs(deltaMs) / 1_000)); + if (seconds < 5) return "just now"; + const units: Array<[number, string]> = [ + [86_400, "day"], + [3_600, "hour"], + [60, "minute"], + [1, "second"], + ]; + const [unitSeconds, label] = units.find(([size]) => seconds >= size) ?? units.at(-1)!; + const amount = Math.max(1, Math.floor(seconds / unitSeconds)); + const phrase = `${amount} ${label}${amount === 1 ? "" : "s"}`; + return future ? `in ${phrase}` : `${phrase} ago`; +} + function formatSyncStatus(value: unknown): string { const snapshot = isRecord(value) ? value : {}; const routeHealth = isRecord(snapshot.routeHealth) ? snapshot.routeHealth : {}; @@ -16105,6 +16177,19 @@ function formatSyncStatus(value: unknown): string { relayState = asString(relay.lastControlError) ?? (relay.enabled === true ? "not reachable" : "disabled"); } + const relayEndToEndFailure = asString(relay.relayEndToEndFailure); + const relayEndToEndVerifiedAt = asString(relay.relayEndToEndVerifiedAt); + const relayEndToEndRoundTripMs = typeof relay.relayEndToEndRoundTripMs === "number" + && Number.isFinite(relay.relayEndToEndRoundTripMs) + ? Math.max(0, Math.round(relay.relayEndToEndRoundTripMs)) + : null; + const relayEndToEndState = relayEndToEndFailure + ? `FAILED: ${relayEndToEndFailure}` + : relayEndToEndVerifiedAt + ? `verified ${relativeTime(relayEndToEndVerifiedAt)}${ + relayEndToEndRoundTripMs == null ? "" : ` (${relayEndToEndRoundTripMs}ms)` + }` + : "not yet verified"; const transferReadiness = isRecord(snapshot.transferReadiness) ? snapshot.transferReadiness : null; @@ -16136,6 +16221,7 @@ function formatSyncStatus(value: unknown): string { ["relay control error", relay.lastControlError], ["relay control opened", relay.lastControlOpenAt], ["relay bridge validated", relay.lastBridgeValidationAt], + ["relay end-to-end", relayEndToEndState], ["account directory", accountParts.join(" · ")], ["directory reason", skipReason], ["directory attempt", lastAttempt], @@ -17785,6 +17871,10 @@ function formatTextOutput( isRecord(value) && isRecord(value.storage) ? value.storage : {}; const sync = isRecord(value) && isRecord(value.sync) ? value.sync : {}; + const relaySelfProbe = + isRecord(value) && isRecord(value.relaySelfProbe) + ? value.relaySelfProbe + : {}; const recommendations = isRecord(value) && Array.isArray(value.recommendations) ? value.recommendations @@ -17809,6 +17899,7 @@ function formatTextOutput( ["path", pathStatus.message], ["storage", storage.message], ["sync", sync.message], + ["relay self-probe", relaySelfProbe.message], ["recommendation", isRecord(value) ? value.recommendation : null], ]), ...(recommendations.length diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 09b16385c..8be4ac3f5 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -123,6 +123,7 @@ const RUNTIME_METHODS = new Set([ "runtimeEvents.unsubscribe", "sync.switchHost", "sync.getStatus", + "sync.runSelfProbe", "sync.refreshDiscovery", "sync.listDevices", "sync.updateLocalDevice", @@ -1296,6 +1297,18 @@ export function createMultiProjectRpcRequestHandler( }); } + if (method === "sync.runSelfProbe") { + const scope = await scopeRegistry.resolveActiveSyncHost(); + const syncService = scope?.runtime.syncService ?? null; + if (!syncService) { + return { + ok: false, + detail: "Relay self-probe skipped because no active sync host is available.", + }; + } + return await syncService.runSelfProbe(); + } + if (method === "sync.refreshDiscovery") { return await (await getSyncService()).refreshDiscovery(); } diff --git a/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts index 05a83b3cc..bc4342856 100644 --- a/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts +++ b/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts @@ -73,6 +73,9 @@ function snapshot( enabled: false, relayControlConnected: false, relayBridgeValidated: false, + relayEndToEndVerifiedAt: null, + relayEndToEndFailure: null, + relayEndToEndRoundTripMs: null, lastFailureAt: null, skipReason: null, lastControlError: null, @@ -111,6 +114,9 @@ function routeSnapshot( enabled: true, relayControlConnected: true, relayBridgeValidated: true, + relayEndToEndVerifiedAt: "2026-07-16T00:00:01.000Z", + relayEndToEndFailure: null, + relayEndToEndRoundTripMs: 42, lastFailureAt: null, skipReason: null, lastControlError: null, @@ -489,6 +495,68 @@ describe("account machine publisher health", () => { service.dispose(); }); + it("retains the directory Relay endpoint while end-to-end verification is pending at startup", async () => { + const current = routeSnapshot(); + current.routeHealth.relay.relayEndToEndVerifiedAt = null; + const fetchImpl = vi.fn(async ( + _input: string | URL | Request, + _init?: RequestInit, + ) => new Response(null, { status: 204 })); + const service = createAccountMachinePublisherService({ + getAccessToken: async () => "account-token", + getAccountStatus: () => ({ + signedIn: true, + userId: "owner-a", + sessionReadState: "available" as const, + }), + getSnapshot: async () => current, + getMachineKey: () => "machine-studio", + directoryBaseUrl: () => "https://directory.example", + fetchImpl, + }); + + await service.publishNow(); + + const registration = JSON.parse(String(fetchImpl.mock.calls[0]?.[1]?.body)); + expect(registration.retainRelayEndpoints).toBe(true); + expect(registration.reachableEndpoints).not.toContainEqual( + expect.objectContaining({ kind: "relay" }), + ); + service.dispose(); + }); + + it("drops a stale Relay endpoint and retention hint after end-to-end verification fails", async () => { + const current = routeSnapshot(); + const fetchImpl = vi.fn(async ( + _input: string | URL | Request, + _init?: RequestInit, + ) => new Response(null, { status: 204 })); + const service = createAccountMachinePublisherService({ + getAccessToken: async () => "account-token", + getAccountStatus: () => ({ + signedIn: true, + userId: "owner-a", + sessionReadState: "available" as const, + }), + getSnapshot: async () => current, + getMachineKey: () => "machine-studio", + directoryBaseUrl: () => "https://directory.example", + fetchImpl, + }); + + await service.publishNow(); + current.routeHealth.relay.relayEndToEndVerifiedAt = null; + current.routeHealth.relay.relayEndToEndFailure = "Relay self-probe closed before ready (4501): host offline."; + await service.publishNow(); + + const registration = JSON.parse(String(fetchImpl.mock.calls[1]?.[1]?.body)); + expect(registration.retainRelayEndpoints).toBeUndefined(); + expect(registration.reachableEndpoints).not.toContainEqual( + expect.objectContaining({ kind: "relay" }), + ); + service.dispose(); + }); + it("does not retain a verified Relay route across account-owner changes", async () => { let accountOwnerId = "owner-a"; const current = routeSnapshot(); @@ -775,6 +843,26 @@ describe("account machine registration publisher", () => { }); }); + it("publishes Relay only after end-to-end verification", () => { + const pending = routeSnapshot(); + pending.routeHealth.relay.relayEndToEndVerifiedAt = null; + expect(buildAccountMachineRegistration({ + machineKey: "machine-studio", + snapshot: pending, + })?.reachableEndpoints).not.toContainEqual( + expect.objectContaining({ kind: "relay" }), + ); + + const verified = routeSnapshot(); + expect(buildAccountMachineRegistration({ + machineKey: "machine-studio", + snapshot: verified, + })?.reachableEndpoints).toContainEqual({ + kind: "relay", + url: "wss://relay.example/connect/machine-studio", + }); + }); + it("fails closed for viewers and omits unhealthy or spoofed routes", () => { expect(buildAccountMachineRegistration({ machineKey: "machine-studio", diff --git a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts index 58301929a..b830fbe88 100644 --- a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts +++ b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts @@ -58,7 +58,13 @@ export type AccountMachineRegistrationSnapshot = Pick< SyncRoleSnapshot, "role" | "runtimeRole" | "runtimeName" | "pairingConnectInfo" > & { - routeHealth: Pick; + routeHealth: Pick & { + relay: SyncRouteHealth["relay"] & { + relayEndToEndVerifiedAt?: string | null; + relayEndToEndFailure?: string | null; + relayEndToEndRoundTripMs?: number | null; + }; + }; }; type PublisherAccountStatus = Pick & @@ -183,6 +189,8 @@ export function buildAccountMachineRegistration(args: { && args.snapshot.routeHealth.listener.loopbackAdeValidated && args.snapshot.routeHealth.relay.relayControlConnected && args.snapshot.routeHealth.relay.relayBridgeValidated + && Boolean(args.snapshot.routeHealth.relay.relayEndToEndVerifiedAt) + && args.snapshot.routeHealth.relay.relayEndToEndFailure == null && args.snapshot.routeHealth.relay.skipReason == null ) { const url = validatedRelayUrl(host, machineKey); @@ -217,6 +225,8 @@ function relayPublishStateSignature( return JSON.stringify({ relayControlConnected: snapshot.routeHealth.relay.relayControlConnected, relayBridgeValidated: snapshot.routeHealth.relay.relayBridgeValidated, + relayEndToEndVerifiedAt: snapshot.routeHealth.relay.relayEndToEndVerifiedAt ?? null, + relayEndToEndFailure: snapshot.routeHealth.relay.relayEndToEndFailure ?? null, reachableEndpoints, }); } @@ -488,7 +498,11 @@ export function createAccountMachinePublisherService(options: { // only a route this publisher successfully registered. const relayTemporarilyUnavailable = snapshot.routeHealth.relay.enabled === true && relayEndpoints(observedRegistration).length === 0; + const relayVerificationFailed = Boolean( + snapshot.routeHealth.relay.relayEndToEndFailure, + ); const canRetainRelay = relayTemporarilyUnavailable + && !relayVerificationFailed && lastPublishedRelayState?.machineKey === machineKey && lastPublishedRelayState.accountOwnerId === accountOwnerId; const registrationWithRetainedRelay = canRetainRelay @@ -502,6 +516,7 @@ export function createAccountMachinePublisherService(options: { // empty. Older directory deployments safely ignore the extra property and // still benefit from the process-local retained route above. const registration: AccountMachineRegistration = relayTemporarilyUnavailable + && !relayVerificationFailed ? { ...registrationWithRetainedRelay, retainRelayEndpoints: true } : registrationWithRetainedRelay; const reachableEndpointCount = registration.reachableEndpoints.length; diff --git a/apps/ade-cli/src/services/sync/syncLoopbackCollision.test.ts b/apps/ade-cli/src/services/sync/syncLoopbackCollision.test.ts index 4a25bed7c..f3bedadeb 100644 --- a/apps/ade-cli/src/services/sync/syncLoopbackCollision.test.ts +++ b/apps/ade-cli/src/services/sync/syncLoopbackCollision.test.ts @@ -313,6 +313,9 @@ describe("sync loopback collision recovery", () => { lastFailureAt: null, lastControlOpenAt: null, lastBridgeValidationAt: null, + relayEndToEndVerifiedAt: null, + relayEndToEndFailure: null, + relayEndToEndRoundTripMs: null, relayUrl: "https://relay.test.ade", machineKey: "a".repeat(32), }; @@ -377,6 +380,9 @@ describe("sync loopback collision recovery", () => { lastFailureAt: "2026-07-22T12:00:00.000Z", lastControlOpenAt: "2026-07-22T11:59:00.000Z", lastBridgeValidationAt: "2026-07-22T11:59:30.000Z", + relayEndToEndVerifiedAt: "2026-07-22T11:59:45.000Z", + relayEndToEndFailure: null, + relayEndToEndRoundTripMs: 42, relayUrl: "https://relay.test.ade", machineKey: "a".repeat(32), }; diff --git a/apps/ade-cli/src/services/sync/syncRelaySelfProbe.test.ts b/apps/ade-cli/src/services/sync/syncRelaySelfProbe.test.ts new file mode 100644 index 000000000..b81afe057 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncRelaySelfProbe.test.ts @@ -0,0 +1,95 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import { WebSocket } from "ws"; +import { + probeRelayEndToEnd, + RELAY_SELF_PROBE_TIMEOUT_MS, +} from "./syncRelaySelfProbe"; + +class ProbeWebSocket extends EventEmitter { + readyState: number = WebSocket.CONNECTING; + readonly closes: Array<{ code: number; reason: string }> = []; + terminated = false; + + open(): void { + this.readyState = WebSocket.OPEN; + this.emit("open"); + } + + receive(value: unknown): void { + this.emit("message", Buffer.from(JSON.stringify(value)), false); + } + + close(code = 1000, reason = ""): void { + this.closes.push({ code, reason }); + this.readyState = WebSocket.CLOSED; + this.emit("close", code, Buffer.from(reason)); + } + + terminate(): void { + this.terminated = true; + this.readyState = WebSocket.CLOSED; + this.emit("close", 1006, Buffer.alloc(0)); + } +} + +describe("probeRelayEndToEnd", () => { + it("waits for accepted then ready and closes without sending a frame", async () => { + const socket = new ProbeWebSocket(); + const createWebSocket = vi.fn(() => socket as unknown as WebSocket); + const probing = probeRelayEndToEnd({ + relayWsBase: "wss://relay.example/", + machineKey: "machine-key", + createWebSocket, + }); + + socket.open(); + socket.receive({ t: "accepted", v: 2 }); + socket.receive({ t: "ready", v: 2 }); + + await expect(probing).resolves.toMatchObject({ ok: true }); + expect(createWebSocket).toHaveBeenCalledWith( + "wss://relay.example/connect/machine-key?ready=2", + ); + expect(socket.closes).toEqual([ + { code: 1000, reason: "self probe complete" }, + ]); + }); + + it("folds an early close code and reason into the failure", async () => { + const socket = new ProbeWebSocket(); + const probing = probeRelayEndToEnd({ + relayWsBase: "wss://relay.example", + machineKey: "machine-key", + createWebSocket: () => socket as unknown as WebSocket, + }); + + socket.open(); + socket.receive({ t: "accepted", v: 2 }); + socket.close(4501, "host offline"); + + await expect(probing).resolves.toEqual({ + ok: false, + reason: "Relay self-probe closed before ready (4501): host offline.", + }); + }); + + it("times out and terminates the probe socket", async () => { + vi.useFakeTimers(); + const socket = new ProbeWebSocket(); + const probing = probeRelayEndToEnd({ + relayWsBase: "wss://relay.example", + machineKey: "machine-key", + createWebSocket: () => socket as unknown as WebSocket, + }); + + await vi.advanceTimersByTimeAsync(RELAY_SELF_PROBE_TIMEOUT_MS); + + await expect(probing).resolves.toEqual({ + ok: false, + reason: `Relay self-probe timed out after ${RELAY_SELF_PROBE_TIMEOUT_MS}ms.`, + }); + expect(socket.terminated).toBe(true); + vi.useRealTimers(); + }); +}); diff --git a/apps/ade-cli/src/services/sync/syncRelaySelfProbe.ts b/apps/ade-cli/src/services/sync/syncRelaySelfProbe.ts new file mode 100644 index 000000000..f49fc6fbc --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncRelaySelfProbe.ts @@ -0,0 +1,136 @@ +import { WebSocket, type RawData } from "ws"; + +export const RELAY_SELF_PROBE_TIMEOUT_MS = 15_000; +const RELAY_READY_VERSION = 2; + +export type RelaySelfProbeResult = + | { ok: true; roundTripMs: number } + | { ok: false; reason: string }; + +type RelaySelfProbeArgs = { + relayWsBase: string; + machineKey: string; + timeoutMs?: number; + createWebSocket?: (url: string) => WebSocket; +}; + +function rawText(raw: RawData): string { + if (typeof raw === "string") return raw; + if (Buffer.isBuffer(raw)) return raw.toString("utf8"); + if (Array.isArray(raw)) return Buffer.concat(raw).toString("utf8"); + return Buffer.from(raw as ArrayBuffer).toString("utf8"); +} + +function expectedFrame(raw: RawData, type: "accepted" | "ready"): boolean { + let parsed: unknown; + try { + parsed = JSON.parse(rawText(raw)); + } catch { + return false; + } + return Boolean( + parsed + && typeof parsed === "object" + && (parsed as { t?: unknown }).t === type + && (parsed as { v?: unknown }).v === RELAY_READY_VERSION, + ); +} + +function closeDetail(code: number, rawReason: Buffer): string { + const reason = rawReason.toString("utf8").trim(); + return reason ? ` (${code}): ${reason}` : ` (${code})`; +} + +export function probeRelayEndToEnd( + args: RelaySelfProbeArgs, +): Promise { + const timeoutMs = Math.max(1, Math.floor(args.timeoutMs ?? RELAY_SELF_PROBE_TIMEOUT_MS)); + const relayWsBase = args.relayWsBase.replace(/\/+$/, ""); + const url = `${relayWsBase}/connect/${args.machineKey}?ready=2`; + const startedAt = Date.now(); + + return new Promise((resolve) => { + let socket: WebSocket; + try { + socket = args.createWebSocket?.(url) ?? new WebSocket(url); + } catch (error) { + resolve({ + ok: false, + reason: `Relay self-probe could not open a WebSocket: ${error instanceof Error ? error.message : String(error)}`, + }); + return; + } + + let settled = false; + let accepted = false; + const finish = (result: RelaySelfProbeResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.off("message", onMessage); + socket.off("close", onClose); + socket.off("error", onError); + resolve(result); + if (!result.ok) { + try { + socket.terminate(); + } catch { + // already closed + } + } + }; + const onMessage = (raw: RawData, isBinary: boolean): void => { + if (isBinary) { + finish({ + ok: false, + reason: accepted + ? "Relay self-probe received a binary frame before ready." + : "Relay self-probe received a binary first frame.", + }); + return; + } + if (!accepted) { + if (!expectedFrame(raw, "accepted")) { + finish({ ok: false, reason: "Relay self-probe received an invalid first frame; expected accepted v2." }); + return; + } + accepted = true; + return; + } + if (!expectedFrame(raw, "ready")) { + finish({ ok: false, reason: "Relay self-probe received an invalid second frame; expected ready v2." }); + return; + } + const roundTripMs = Math.max(0, Date.now() - startedAt); + finish({ ok: true, roundTripMs }); + try { + socket.close(1000, "self probe complete"); + } catch { + // The successful readiness verdict is already complete. + } + }; + const onClose = (code: number, reason: Buffer): void => { + finish({ + ok: false, + reason: `Relay self-probe closed before ${accepted ? "ready" : "accepted"}${closeDetail(code, reason)}.`, + }); + }; + const onError = (error: Error): void => { + finish({ + ok: false, + reason: `Relay self-probe WebSocket error: ${error.message}`, + }); + }; + const timer = setTimeout(() => { + finish({ + ok: false, + reason: `Relay self-probe timed out after ${timeoutMs}ms.`, + }); + }, timeoutMs); + timer.unref?.(); + + socket.on("message", onMessage); + socket.once("close", onClose); + socket.once("error", onError); + }); +} diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index 6d149584e..973a75785 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -175,7 +175,9 @@ type SyncServiceArgs = { * absent a store is created under the pairing state dir. */ cloudRelayStore?: SyncCloudRelayStore; - syncTunnelClientService?: Pick | null; + syncTunnelClientService?: Pick + & Partial> + | null; projectCatalogProvider?: SyncProjectCatalogProvider; rosterProvider?: SyncRosterProvider; foreignChatProvider?: SyncForeignChatTranscriptResolver; @@ -1393,6 +1395,20 @@ export function createSyncService(args: SyncServiceArgs) { "Account-directory publisher health is unavailable.", ); } + const relayRouteHealth = { + enabled: relayEnabled, + relayControlConnected, + relayBridgeValidated, + lastFailureAt: tunnelStatus?.lastFailureAt + ?? (relayEnabled && relayReason ? rawListenerValidation.lastFailureAt : null), + skipReason: relayReason, + lastControlError: tunnelStatus?.lastControlError ?? null, + lastControlOpenAt: tunnelStatus?.lastControlOpenAt ?? null, + lastBridgeValidationAt: tunnelStatus?.lastBridgeValidationAt ?? null, + relayEndToEndVerifiedAt: tunnelStatus?.relayEndToEndVerifiedAt ?? null, + relayEndToEndFailure: tunnelStatus?.relayEndToEndFailure ?? null, + relayEndToEndRoundTripMs: tunnelStatus?.relayEndToEndRoundTripMs ?? null, + }; const routeHealth: SyncRouteHealth = { listener: { listenerBound, @@ -1414,17 +1430,7 @@ export function createSyncService(args: SyncServiceArgs) { reason: tailscaleReason, lastSuccessAt: tailscaleReachable ? tailnetDiscovery.updatedAt : null, }, - relay: { - enabled: relayEnabled, - relayControlConnected, - relayBridgeValidated, - lastFailureAt: tunnelStatus?.lastFailureAt - ?? (relayEnabled && relayReason ? rawListenerValidation.lastFailureAt : null), - skipReason: relayReason, - lastControlError: tunnelStatus?.lastControlError ?? null, - lastControlOpenAt: tunnelStatus?.lastControlOpenAt ?? null, - lastBridgeValidationAt: tunnelStatus?.lastBridgeValidationAt ?? null, - }, + relay: relayRouteHealth, accountDirectory, }; return { @@ -1652,7 +1658,7 @@ export function createSyncService(args: SyncServiceArgs) { const tunnelStatus = args.syncTunnelClientService?.getStatus() ?? null; const accountSignedIn = isRelayAccountSignedIn() && (tunnelStatus?.accountLeaseValid ?? true); - return { + const status = { relayWssUrl: cloudRelayStore.getRelayWssUrl(), machineKey: config.machineKey, relayUrl: cloudRelayStore.getRelayUrl(), @@ -1662,11 +1668,25 @@ export function createSyncService(args: SyncServiceArgs) { lastFailureAt: tunnelStatus?.lastFailureAt ?? null, lastControlOpenAt: tunnelStatus?.lastControlOpenAt ?? null, lastBridgeValidationAt: tunnelStatus?.lastBridgeValidationAt ?? null, + relayEndToEndVerifiedAt: tunnelStatus?.relayEndToEndVerifiedAt ?? null, + relayEndToEndFailure: tunnelStatus?.relayEndToEndFailure ?? null, + relayEndToEndRoundTripMs: tunnelStatus?.relayEndToEndRoundTripMs ?? null, lastControlError: tunnelStatus?.lastControlError ?? null, lastError: accountSignedIn ? tunnelStatus?.lastError ?? null : "Sign in to ADE to use ADE Relay.", }; + return status; + }, + + async runSelfProbe(): Promise<{ ok: boolean; detail: string }> { + if (!args.syncTunnelClientService?.runSelfProbe) { + return { + ok: false, + detail: "Relay self-probe skipped because the tunnel client is unavailable.", + }; + } + return await args.syncTunnelClientService.runSelfProbe(); }, async setActiveLanePresence(laneIds: string[]): Promise { diff --git a/apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts b/apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts index 41f14e77c..19a7eb7e1 100644 --- a/apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts +++ b/apps/ade-cli/src/services/sync/syncTunnelClientService.test.ts @@ -5,6 +5,9 @@ import { WebSocket, WebSocketServer } from "ws"; import { BRIDGE_VALIDATION_LEASE_MS, CONNECT_DEADLINE_MS, + CONTROL_JSON_INITIAL_PING_DELAY_MS, + CONTROL_JSON_PING_INTERVAL_MS, + CONTROL_JSON_PONG_DEADLINE_MS, CONTROL_PING_INTERVAL_MS, CONTROL_PONG_DEADLINE_MS, computeBackoffMs, @@ -18,6 +21,7 @@ import { RELAY_CLOSE_FORWARD_FAILED, RELAY_CLOSE_HOST_UNAVAILABLE, RELAY_READY_VERSION, + RELAY_SELF_PROBE_DEBOUNCE_MS, } from "./syncTunnelClientService"; import type { SyncLoopbackProbeResult } from "./syncLoopbackProbe"; import type { SyncCloudRelayStore } from "./syncCloudRelayStore"; @@ -68,6 +72,7 @@ class StubWebSocket extends EventEmitter { readonly sent: string[] = []; readonly closes: Array<{ code: number; reason: string }> = []; sendCallbackError: Error | null = null; + terminateCalls = 0; constructor(readonly url: string) { super(); @@ -114,6 +119,7 @@ class StubWebSocket extends EventEmitter { } terminate(): void { + this.terminateCalls += 1; this.close(1006, "terminated"); } } @@ -162,9 +168,9 @@ describe("parseControlMessage", () => { }))).toBeNull(); }); - it("rejects JSON ping/pong and junk", () => { + it("recognizes JSON pong without weakening strict open parsing", () => { expect(parseControlMessage(JSON.stringify({ t: "ping" }))).toBeNull(); - expect(parseControlMessage(JSON.stringify({ t: "pong" }))).toBeNull(); + expect(parseControlMessage(JSON.stringify({ t: "pong" }))).toEqual({ t: "pong" }); expect(parseControlMessage("not json")).toBeNull(); expect(parseControlMessage(JSON.stringify({ t: "other" }))).toBeNull(); }); @@ -277,8 +283,14 @@ describe("createSyncTunnelClientService", () => { const address = relay.address(); const relayPort = typeof address === "object" && address ? address.port : 0; const connections: string[] = []; - relay.on("connection", (_socket, request) => { - connections.push(request.url ?? ""); + relay.on("connection", (socket, request) => { + const url = request.url ?? ""; + connections.push(url); + if (url.startsWith("/connect/")) { + // Answer the end-to-end self-probe like a ready-v2 relay would. + socket.send(JSON.stringify({ t: "accepted", v: 2 })); + socket.send(JSON.stringify({ t: "ready", v: 2 })); + } }); const originalFetch = globalThis.fetch; globalThis.fetch = async () => new Response(null, { status: 204 }); @@ -310,6 +322,9 @@ describe("createSyncTunnelClientService", () => { validatedPort: syncPort, }); }); + await vi.waitFor(() => { + expect(service.getStatus().relayEndToEndVerifiedAt).not.toBeNull(); + }, { timeout: 10_000 }); const tunnelStatus = service.getStatus(); const listenerStatus = listener.getLoopbackValidationStatus(); @@ -354,14 +369,18 @@ describe("createSyncTunnelClientService", () => { lastControlError: tunnelStatus.lastControlError, lastControlOpenAt: tunnelStatus.lastControlOpenAt, lastBridgeValidationAt: tunnelStatus.lastBridgeValidationAt, + relayEndToEndVerifiedAt: tunnelStatus.relayEndToEndVerifiedAt, + relayEndToEndFailure: tunnelStatus.relayEndToEndFailure, + relayEndToEndRoundTripMs: tunnelStatus.relayEndToEndRoundTripMs, }, }, } satisfies AccountMachineRegistrationSnapshot; expect(buildAccountMachineRegistration({ machineKey, snapshot })?.reachableEndpoints).toEqual([ { kind: "relay", url: relayUrl }, ]); - // The Relay never sent an external {t:"open"}; only its control socket exists. - expect(connections).toHaveLength(1); + // Control socket plus the end-to-end self-probe's client connection. + expect(connections).toHaveLength(2); + expect(connections.some((url) => url.startsWith(`/connect/${machineKey}`))).toBe(true); expect(connections[0]).not.toContain("/pipe/"); } finally { await service.dispose(); @@ -2243,4 +2262,317 @@ describe("createSyncTunnelClientService", () => { await new Promise((resolve) => relay.close(() => resolve())); } }); + + it("sends JSON control pings after open and on the interval, and pong clears each deadline", async () => { + vi.useFakeTimers(); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(null, { status: 204 }); + const sockets: StubWebSocket[] = []; + const service = createSyncTunnelClientService({ + getSyncPort: () => null, + getRelayBridgeProof: () => null, + configStore: fakeStore(), + controlJsonPingIntervalMs: 5_000, + controlJsonPongDeadlineMs: 500, + createWebSocket: (url) => { + const socket = new StubWebSocket(url); + sockets.push(socket); + return socket as unknown as WebSocket; + }, + }); + + try { + expect(CONTROL_JSON_PING_INTERVAL_MS).toBe(180_000); + expect(CONTROL_JSON_PONG_DEADLINE_MS).toBe(30_000); + await service.start(); + const control = sockets[0]!; + control.open(); + + await vi.advanceTimersByTimeAsync(CONTROL_JSON_INITIAL_PING_DELAY_MS); + expect(control.sent).toEqual([JSON.stringify({ t: "ping" })]); + control.receive(JSON.stringify({ t: "pong" })); + await vi.advanceTimersByTimeAsync(500); + expect(control.terminateCalls).toBe(0); + + await vi.advanceTimersByTimeAsync(3_500); + expect(control.sent).toEqual([ + JSON.stringify({ t: "ping" }), + JSON.stringify({ t: "ping" }), + ]); + control.receive(JSON.stringify({ t: "pong" })); + await vi.advanceTimersByTimeAsync(500); + expect(control.terminateCalls).toBe(0); + } finally { + await service.dispose(); + globalThis.fetch = originalFetch; + vi.useRealTimers(); + } + }); + + it("terminates and reconnects a zombie control that misses its JSON pong", async () => { + vi.useFakeTimers(); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(null, { status: 204 }); + const sockets: StubWebSocket[] = []; + const logger = { warn: vi.fn() }; + const service = createSyncTunnelClientService({ + logger, + getSyncPort: () => null, + getRelayBridgeProof: () => null, + configStore: fakeStore(), + controlJsonPingIntervalMs: 5_000, + controlJsonPongDeadlineMs: 100, + reconnectBackoffMs: () => 0, + createWebSocket: (url) => { + const socket = new StubWebSocket(url); + sockets.push(socket); + return socket as unknown as WebSocket; + }, + }); + + try { + await service.start(); + const control = sockets[0]!; + control.open(); + await vi.advanceTimersByTimeAsync(CONTROL_JSON_INITIAL_PING_DELAY_MS + 100); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(1); + + expect(control.terminateCalls).toBe(1); + expect(sockets).toHaveLength(2); + expect(service.getStatus().lastControlError) + .toBe("relay control unreachable at relay (zombie socket)"); + expect(service.getStatus().relayEndToEndFailure) + .toBe("relay control unreachable at relay (zombie socket)"); + expect(logger.warn).toHaveBeenCalledWith( + "sync_tunnel.zombie_control_detected", + expect.objectContaining({ + machineKey: "a".repeat(32), + source: "json-ping", + transportMode: "epoch", + }), + ); + } finally { + await service.dispose(); + globalThis.fetch = originalFetch; + vi.useRealTimers(); + } + }); + + it("does not stack JSON pong deadlines and tears timers down on socket replacement", async () => { + vi.useFakeTimers(); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(null, { status: 204 }); + const sockets: StubWebSocket[] = []; + const service = createSyncTunnelClientService({ + getSyncPort: () => null, + getRelayBridgeProof: () => null, + configStore: fakeStore(), + controlJsonPingIntervalMs: 1_100, + controlJsonPongDeadlineMs: 10_000, + reconnectBackoffMs: () => 0, + createWebSocket: (url) => { + const socket = new StubWebSocket(url); + sockets.push(socket); + return socket as unknown as WebSocket; + }, + }); + + try { + await service.start(); + const first = sockets[0]!; + first.open(); + await vi.advanceTimersByTimeAsync(2_200); + expect(first.sent.filter((raw) => raw === JSON.stringify({ t: "ping" }))).toHaveLength(1); + + first.remoteClose(4505, "replaced"); + await vi.advanceTimersByTimeAsync(0); + const second = sockets[1]!; + second.open(); + await vi.advanceTimersByTimeAsync(CONTROL_JSON_INITIAL_PING_DELAY_MS); + + expect(first.terminateCalls).toBe(0); + expect(second.sent).toContain(JSON.stringify({ t: "ping" })); + second.receive(JSON.stringify({ t: "pong" })); + await vi.advanceTimersByTimeAsync(10_000); + expect(first.terminateCalls).toBe(0); + expect(second.terminateCalls).toBe(0); + } finally { + await service.dispose(); + globalThis.fetch = originalFetch; + vi.useRealTimers(); + } + }); + + it("records a successful automatic self-probe for the current control generation", async () => { + vi.useFakeTimers(); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(null, { status: 204 }); + const sockets: StubWebSocket[] = []; + const expectedNonce = "c".repeat(32); + const service = createSyncTunnelClientService({ + getSyncPort: () => 8787, + getExpectedLoopbackNonce: () => expectedNonce, + getRelayBridgeProof: () => "e".repeat(43), + configStore: fakeStore(), + loopbackProbe: async (port) => ({ + ok: true, + port, + statusCode: 426, + statusMessage: "Upgrade Required", + markerValue: expectedNonce, + checkedAt: new Date().toISOString(), + reason: null, + }), + createWebSocket: (url) => { + const socket = new StubWebSocket(url); + sockets.push(socket); + return socket as unknown as WebSocket; + }, + }); + + try { + await service.start(); + const control = sockets[0]!; + control.open(); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(CONTROL_JSON_INITIAL_PING_DELAY_MS); + control.receive(JSON.stringify({ t: "pong" })); + await vi.advanceTimersByTimeAsync( + RELAY_SELF_PROBE_DEBOUNCE_MS - CONTROL_JSON_INITIAL_PING_DELAY_MS, + ); + const probe = sockets.find((socket) => socket.url.includes("?ready=2")); + expect(probe).toBeTruthy(); + probe!.receive(JSON.stringify({ t: "accepted", v: 2 })); + probe!.receive(JSON.stringify({ t: "ready", v: 2 })); + await Promise.resolve(); + + expect(service.getStatus()).toMatchObject({ + relayEndToEndFailure: null, + relayEndToEndRoundTripMs: expect.any(Number), + }); + expect(service.getStatus().relayEndToEndVerifiedAt).toEqual(expect.any(String)); + expect(control.terminateCalls).toBe(0); + } finally { + await service.dispose(); + globalThis.fetch = originalFetch; + vi.useRealTimers(); + } + }); + + it("fails publication state closed and reconnects when the self-probe cannot reach ready", async () => { + vi.useFakeTimers(); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(null, { status: 204 }); + const sockets: StubWebSocket[] = []; + const expectedNonce = "c".repeat(32); + const logger = { warn: vi.fn() }; + const onPublicationStateChanged = vi.fn(); + const service = createSyncTunnelClientService({ + logger, + getSyncPort: () => 8787, + getExpectedLoopbackNonce: () => expectedNonce, + getRelayBridgeProof: () => "e".repeat(43), + configStore: fakeStore(), + loopbackProbe: async (port) => ({ + ok: true, + port, + statusCode: 426, + statusMessage: "Upgrade Required", + markerValue: expectedNonce, + checkedAt: new Date().toISOString(), + reason: null, + }), + onPublicationStateChanged, + reconnectBackoffMs: () => 0, + createWebSocket: (url) => { + const socket = new StubWebSocket(url); + sockets.push(socket); + return socket as unknown as WebSocket; + }, + }); + + try { + await service.start(); + const control = sockets[0]!; + control.open(); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(RELAY_SELF_PROBE_DEBOUNCE_MS); + const probe = sockets.find((socket) => socket.url.includes("?ready=2")); + expect(probe).toBeTruthy(); + probe!.receive(JSON.stringify({ t: "accepted", v: 2 })); + probe!.remoteClose(4501, "host offline"); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(0); + + expect(service.getStatus()).toMatchObject({ + relayEndToEndVerifiedAt: null, + relayEndToEndFailure: expect.stringContaining("host offline"), + }); + expect(control.terminateCalls).toBe(1); + expect(sockets.filter((socket) => socket.url.includes("/host/"))).toHaveLength(2); + expect(onPublicationStateChanged).toHaveBeenCalledWith("route-state-changed"); + expect(logger.warn).toHaveBeenCalledWith( + "sync_tunnel.self_probe_failed", + expect.objectContaining({ reasonClass: "closed_before_ready" }), + ); + expect(logger.warn).toHaveBeenCalledWith( + "sync_tunnel.zombie_control_detected", + expect.objectContaining({ source: "self-probe" }), + ); + } finally { + await service.dispose(); + globalThis.fetch = originalFetch; + vi.useRealTimers(); + } + }); + + it("returns the on-demand self-probe verdict", async () => { + vi.useFakeTimers(); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(null, { status: 204 }); + const sockets: StubWebSocket[] = []; + const expectedNonce = "c".repeat(32); + const service = createSyncTunnelClientService({ + getSyncPort: () => 8787, + getExpectedLoopbackNonce: () => expectedNonce, + getRelayBridgeProof: () => "e".repeat(43), + configStore: fakeStore(), + loopbackProbe: async (port) => ({ + ok: true, + port, + statusCode: 426, + statusMessage: "Upgrade Required", + markerValue: expectedNonce, + checkedAt: new Date().toISOString(), + reason: null, + }), + createWebSocket: (url) => { + const socket = new StubWebSocket(url); + sockets.push(socket); + return socket as unknown as WebSocket; + }, + }); + + try { + await service.start(); + sockets[0]!.open(); + await Promise.resolve(); + const result = service.runSelfProbe(); + await vi.advanceTimersByTimeAsync(RELAY_SELF_PROBE_DEBOUNCE_MS); + const probe = sockets.find((socket) => socket.url.includes("?ready=2")); + expect(probe).toBeTruthy(); + probe!.receive(JSON.stringify({ t: "accepted", v: 2 })); + probe!.receive(JSON.stringify({ t: "ready", v: 2 })); + + await expect(result).resolves.toEqual({ + ok: true, + detail: expect.stringMatching(/^Relay end-to-end verified in \d+ms\.$/), + }); + } finally { + await service.dispose(); + globalThis.fetch = originalFetch; + vi.useRealTimers(); + } + }); }); diff --git a/apps/ade-cli/src/services/sync/syncTunnelClientService.ts b/apps/ade-cli/src/services/sync/syncTunnelClientService.ts index ad6cb4d5d..e3a7017d2 100644 --- a/apps/ade-cli/src/services/sync/syncTunnelClientService.ts +++ b/apps/ade-cli/src/services/sync/syncTunnelClientService.ts @@ -14,6 +14,10 @@ import { probeAdeLoopbackListener, type SyncLoopbackProbeResult, } from "./syncLoopbackProbe"; +import { + probeRelayEndToEnd, + type RelaySelfProbeResult, +} from "./syncRelaySelfProbe"; import { SYNC_RELAY_BRIDGE_PROOF_HEADER } from "./sharedSyncListener"; type Logger = { @@ -36,6 +40,9 @@ export type SyncTunnelClientStatus = { lastFailureAt: string | null; lastControlOpenAt: string | null; lastBridgeValidationAt: string | null; + relayEndToEndVerifiedAt: string | null; + relayEndToEndFailure: string | null; + relayEndToEndRoundTripMs: number | null; relayUrl: string; machineKey: string; }; @@ -45,6 +52,8 @@ export type SyncTunnelClientService = { stop(): Promise; /** Re-probe the active shared listener without opening a Relay pipe. */ validateCurrentBridge(): Promise; + /** Dial Relay exactly like a ready-v2 controller and verify bridge readiness. */ + runSelfProbe(): Promise<{ ok: boolean; detail: string }>; getStatus(): SyncTunnelClientStatus; dispose(): Promise; }; @@ -73,6 +82,8 @@ type SyncTunnelClientArgs = { /** Test seams; production uses the exported protocol-liveness defaults. */ controlPingIntervalMs?: number; controlPongDeadlineMs?: number; + controlJsonPingIntervalMs?: number; + controlJsonPongDeadlineMs?: number; controlReadyStableMs?: number; reconnectBackoffMs?: (attempt: number) => number; createWebSocket?: ( @@ -90,6 +101,9 @@ const BACKOFF_CAP_MS = 60_000; const ACCOUNT_STATUS_POLL_MS = 1_000; export const CONTROL_PING_INTERVAL_MS = 30_000; export const CONTROL_PONG_DEADLINE_MS = 10_000; +export const CONTROL_JSON_PING_INTERVAL_MS = 180_000; +export const CONTROL_JSON_PONG_DEADLINE_MS = 30_000; +export const CONTROL_JSON_INITIAL_PING_DELAY_MS = 1_000; export const RELAY_CLOSE_PARTNER_CLOSED = 4000; export const RELAY_CLOSE_HOST_UNAVAILABLE = 4501; export const RELAY_CLOSE_BRIDGE_REJECTED = 4507; @@ -98,6 +112,7 @@ export const RELAY_SIGN_IN_REQUIRED_MESSAGE = "Sign in to ADE to use ADE Relay." export const BRIDGE_VALIDATION_LEASE_MS = 2_000; export const CONTROL_READY_STABLE_MS = 5_000; export const RELAY_READY_VERSION = 2; +export const RELAY_SELF_PROBE_DEBOUNCE_MS = 2_000; const MAX_UNEXPECTED_RESPONSE_BODY_BYTES = 512; const MAX_CONFIRMED_CONFLICT_ROTATIONS = 1; @@ -134,6 +149,15 @@ type PendingBridgeOpen = { reject: (code: number, reason: string) => void; }; +type BridgeOpenFailureSource = "bridge-open" | "self-probe"; + +type RelaySelfProbeState = { + verifiedAtMs: number | null; + lastFailure: string | null; + lastFailureAtMs: number | null; + inFlight: boolean; +}; + /** * Exponential backoff with full jitter, capped at 60s. Exposed for tests so the * reconnect schedule is verifiable without waiting on real timers. @@ -143,13 +167,17 @@ export function computeBackoffMs(attempt: number, random: () => number = Math.ra return Math.floor(random() * ceiling); } -export type ControlMessage = { +type ControlOpenMessage = { t: "open"; id: string; epoch?: string; readyVersion?: typeof RELAY_READY_VERSION; }; +export type ControlMessage = ControlOpenMessage | { + t: "pong"; +}; + /** Parses a host control frame; returns null for anything unrecognized. */ export function parseControlMessage(raw: string): ControlMessage | null { let parsed: unknown; @@ -178,6 +206,7 @@ export function parseControlMessage(raw: string): ControlMessage | null { ...(readyVersion === RELAY_READY_VERSION ? { readyVersion } : {}), }; } + if (t === "pong") return { t: "pong" }; return null; } @@ -232,6 +261,7 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT const log = args.logger ?? {}; let control: WebSocket | null = null; let stopControlLiveness: (() => void) | null = null; + let stopControlJsonKeepalive: (() => void) | null = null; let controlReadyTimer: NodeJS.Timeout | null = null; let reconnectTimer: NodeJS.Timeout | null = null; let accountStatusTimer: NodeJS.Timeout | null = null; @@ -245,7 +275,11 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT let connected = false; let lastError: string | null = null; let lastControlError: string | null = null; - let bridgeOpenFailure: { key: string; reason: string } | null = null; + let bridgeOpenFailure: { + key: string; + reason: string; + source: BridgeOpenFailureSource; + } | null = null; let validatedPort: number | null = null; let validatedLoopbackNonce: string | null = null; let lastFailureAt: string | null = null; @@ -268,10 +302,34 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT let transportNegotiationKey: string | null = null; let epochFallbackUsed = false; let epochControlEstablished = false; + let controlOpenedAtMs: number | null = null; + let selfProbeBridgeKey: string | null = null; + let selfProbeRoundTripMs: number | null = null; + let selfProbe: RelaySelfProbeState = { + verifiedAtMs: null, + lastFailure: null, + lastFailureAtMs: null, + inFlight: false, + }; + let selfProbeDebounceTimer: NodeJS.Timeout | null = null; + let selfProbeScheduled: { + key: string; + promise: Promise; + resolve: (result: RelaySelfProbeResult) => void; + } | null = null; + let selfProbeInFlight: { + key: string; + promise: Promise; + } | null = null; + let selfProbeSocket: WebSocket | null = null; const tunnels = new Set(); const pendingBridgeOpens = new Set(); const controlSocketStates = new WeakMap(); const loopbackProbe = args.loopbackProbe ?? probeAdeLoopbackListener; + let requestSelfProbe: ( + key: string, + options?: { debounce?: boolean }, + ) => Promise; const recordFailure = (reason: string): void => { lastError = reason; @@ -293,13 +351,59 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT }); }; - const clearBridgeOpenFailure = (expectedKey?: string): void => { - if (!bridgeOpenFailure) return; - if (expectedKey && bridgeOpenFailure.key !== expectedKey) return; + const clearScheduledSelfProbe = (reason: string): void => { + if (selfProbeDebounceTimer) { + clearTimeout(selfProbeDebounceTimer); + selfProbeDebounceTimer = null; + } + const scheduled = selfProbeScheduled; + selfProbeScheduled = null; + scheduled?.resolve({ ok: false, reason }); + }; + + const resetSelfProbeForControlGeneration = (): void => { + clearScheduledSelfProbe("Relay bridge identity changed before self-probe."); + const probeSocket = selfProbeSocket; + selfProbeSocket = null; + if (probeSocket) { + try { + probeSocket.terminate(); + } catch { + // already closed + } + } + selfProbeBridgeKey = null; + selfProbeRoundTripMs = null; + selfProbe = { + verifiedAtMs: null, + // A failed relay verdict remains authoritative until a later generation + // proves the route healthy; otherwise reconnect would look like benign + // startup and the directory could retain the stale endpoint. + lastFailure: selfProbe.lastFailure, + lastFailureAtMs: selfProbe.lastFailureAtMs, + inFlight: false, + }; + }; + + const clearBridgeOpenFailure = ( + expectedKey?: string, + expectedSource?: BridgeOpenFailureSource, + ): boolean => { + if (!bridgeOpenFailure) return false; + if (expectedKey && bridgeOpenFailure.key !== expectedKey) return false; + if (expectedSource && bridgeOpenFailure.source !== expectedSource) return false; const previousFailure = bridgeOpenFailure; bridgeOpenFailure = null; if (lastError === previousFailure.reason) lastError = null; requestPublicationStatePublish("route-state-changed"); + if ( + previousFailure.source === "bridge-open" + && validatedBridgeKey === previousFailure.key + && control?.readyState === WebSocket.OPEN + ) { + void requestSelfProbe(previousFailure.key); + } + return true; }; const clearBridgeValidation = (): void => { @@ -319,7 +423,8 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT controlGeneration += 1; clearControlReadyTimer(); clearBridgeValidation(); - clearBridgeOpenFailure(); + bridgeOpenFailure = null; + resetSelfProbeForControlGeneration(); }; const recordControlFailure = (reason: string): void => { @@ -526,6 +631,8 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT const socketControlGeneration = controlGeneration; control = socket; let socketLivenessStop: (() => void) | null = null; + let socketJsonKeepaliveStop: (() => void) | null = null; + let acceptJsonPong: (() => void) | null = null; const activateLegacyFallback = (reason: string): boolean => { if ( socketTransportMode !== "epoch" @@ -566,6 +673,7 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT clearReconnect(); connected = true; lastError = null; + controlOpenedAtMs = Date.now(); lastControlOpenAt = new Date().toISOString(); socketLivenessStop = armControlLiveness( socket, @@ -583,6 +691,52 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT args.controlPongDeadlineMs ?? CONTROL_PONG_DEADLINE_MS, ); stopControlLiveness = socketLivenessStop; + const jsonKeepalive = armControlJsonKeepalive( + socket, + () => { + if (control !== socket) return; + const reason = "relay control unreachable at relay (zombie socket)"; + socketState.failureReason = reason; + recordControlFailure(reason); + const currentBridgeKey = bridgeValidationIdentity().key; + const verificationStateChanged = selfProbe.lastFailure !== reason + || selfProbe.verifiedAtMs != null + || selfProbeBridgeKey !== currentBridgeKey; + selfProbeBridgeKey = currentBridgeKey; + selfProbeRoundTripMs = null; + selfProbe = { + verifiedAtMs: null, + lastFailure: reason, + lastFailureAtMs: Date.now(), + inFlight: false, + }; + if (verificationStateChanged) { + requestPublicationStatePublish("route-state-changed"); + } + const { machineKey } = identity(); + log.warn?.("sync_tunnel.zombie_control_detected", { + machineKey, + controlAgeMs: controlOpenedAtMs == null + ? null + : Math.max(0, Date.now() - controlOpenedAtMs), + transportMode: socketTransportMode, + source: "json-ping", + }); + // Product analytics accepts only centrally allowlisted events and + // is intentionally not widened from this service layer; this + // incident remains available through the structured lifecycle log. + try { + socket.terminate(); + } catch { + // already dead + } + }, + args.controlJsonPingIntervalMs ?? CONTROL_JSON_PING_INTERVAL_MS, + args.controlJsonPongDeadlineMs ?? CONTROL_JSON_PONG_DEADLINE_MS, + ); + socketJsonKeepaliveStop = jsonKeepalive.stop; + acceptJsonPong = jsonKeepalive.acceptPong; + stopControlJsonKeepalive = socketJsonKeepaliveStop; log.info?.("sync_tunnel.control_open", { machineKey: id.machineKey, openedAt: lastControlOpenAt, @@ -599,6 +753,7 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT publishRotatedIdentityIfReady(); const readyIdentity = bridgeValidationIdentity(); if (validatedBridgeKey !== readyIdentity.key) return; + void requestSelfProbe(readyIdentity.key); clearControlReadyTimer(); const markStable = (): void => { controlReadyTimer = null; @@ -629,6 +784,10 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT socket.on("message", (raw: RawData) => { if (control !== socket) return; const message = parseControlMessage(rawToText(raw)); + if (message?.t === "pong") { + acceptJsonPong?.(); + return; + } if (message?.t !== "open") return; if (socketTransportMode === "epoch" && message.epoch !== controlEpoch) { sendControlReject( @@ -704,6 +863,11 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT socket.on("close", (code: number, rawReason: Buffer) => { socketLivenessStop?.(); if (stopControlLiveness === socketLivenessStop) stopControlLiveness = null; + socketJsonKeepaliveStop?.(); + if (stopControlJsonKeepalive === socketJsonKeepaliveStop) { + stopControlJsonKeepalive = null; + } + acceptJsonPong = null; const reason = rawReason.toString("utf8").trim(); const wasCurrent = control === socket; log.info?.("sync_tunnel.control_close", { @@ -715,6 +879,7 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT if (!wasCurrent) return; connected = false; + controlOpenedAtMs = null; control = null; advanceControlGeneration(); for (const pendingOpen of [...pendingBridgeOpens]) { @@ -737,7 +902,7 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT const closeReason = reason ? `Relay control closed (${code}): ${reason}` : `Relay control closed (${code}).`; - if (reason || !socketState.failureReason) { + if (!socketState.failureReason) { socketState.failureReason = closeReason; recordControlFailure(closeReason); } @@ -800,6 +965,10 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT lastBridgeValidationAt = result.checkedAt; log.debug?.("sync_tunnel.bridge_validated", { port }); publishRotatedIdentityIfReady(); + // The bridge can validate after the control opened (listener came up + // later); the end-to-end probe must follow every validation, not only + // the control-open path, or Relay publication stays fail-closed. + if (connected && !stopped) void requestSelfProbe(identityAtStart.key); return true; } catch (error) { if (stopped || !bridgeIdentityIsCurrent(identityAtStart)) return false; @@ -854,8 +1023,9 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT reason: string, bridgeKey: string, failedTunnel?: Tunnel, + source: BridgeOpenFailureSource = "bridge-open", ): void => { - const hasReadyBridge = [...tunnels].some( + const hasReadyBridge = source === "bridge-open" && [...tunnels].some( (tunnel) => tunnel !== failedTunnel && tunnel.ready && tunnel.bridgeKey === bridgeKey, ); if (hasReadyBridge) { @@ -867,8 +1037,18 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT }); return; } - const stateChanged = bridgeOpenFailure?.key !== bridgeKey; - bridgeOpenFailure = { key: bridgeKey, reason }; + const stateChanged = bridgeOpenFailure?.key !== bridgeKey + || bridgeOpenFailure?.reason !== reason + || bridgeOpenFailure?.source !== source; + bridgeOpenFailure = { key: bridgeKey, reason, source }; + if ( + source === "bridge-open" + && selfProbeBridgeKey === bridgeKey + && selfProbe.verifiedAtMs != null + ) { + selfProbeRoundTripMs = null; + selfProbe = { ...selfProbe, verifiedAtMs: null }; + } recordFailure(reason); log.warn?.("sync_tunnel.bridge_open_failed", { connectionId: failedTunnel?.connectionId ?? null, @@ -878,6 +1058,164 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT if (stateChanged) requestPublicationStatePublish("route-state-changed"); }; + const selfProbeReasonClass = (reason: string): string => { + if (/timed out/i.test(reason)) return "timeout"; + if (/closed before/i.test(reason)) return "closed_before_ready"; + if (/invalid|binary/i.test(reason)) return "protocol"; + if (/WebSocket error|could not open/i.test(reason)) return "transport"; + return "unknown"; + }; + + const prepareSelfProbeKey = (key: string): void => { + if (selfProbeBridgeKey === key) return; + selfProbeBridgeKey = key; + selfProbeRoundTripMs = null; + selfProbe = { + verifiedAtMs: null, + lastFailure: selfProbe.lastFailure, + lastFailureAtMs: selfProbe.lastFailureAtMs, + inFlight: false, + }; + }; + + const performSelfProbe = (key: string): Promise => { + if ( + stopped + || !accountSignedIn() + || control?.readyState !== WebSocket.OPEN + || validatedBridgeKey !== key + || bridgeValidationIdentity().key !== key + ) { + return Promise.resolve({ + ok: false, + reason: "Relay self-probe skipped because the current control and bridge are not ready.", + }); + } + prepareSelfProbeKey(key); + const controlSocket = control; + const machineIdentity = identity(); + selfProbe = { ...selfProbe, inFlight: true }; + const current = probeRelayEndToEnd({ + relayWsBase: httpToWsUrl(relayHttpUrl()), + machineKey: machineIdentity.machineKey, + createWebSocket: (url) => { + const socket = createWebSocket(url); + selfProbeSocket = socket; + return socket; + }, + }).then((result) => { + if ( + stopped + || selfProbeBridgeKey !== key + || validatedBridgeKey !== key + || bridgeValidationIdentity().key !== key + ) { + return result; + } + if (result.ok) { + const verifiedAtMs = Date.now(); + const stateChanged = selfProbe.verifiedAtMs !== verifiedAtMs + || selfProbe.lastFailure != null + || selfProbeRoundTripMs !== result.roundTripMs; + selfProbeRoundTripMs = result.roundTripMs; + selfProbe = { + verifiedAtMs, + lastFailure: null, + lastFailureAtMs: null, + inFlight: false, + }; + const clearedFailure = clearBridgeOpenFailure(key, "self-probe"); + if (stateChanged && !clearedFailure) { + requestPublicationStatePublish("route-state-changed"); + } + log.debug?.("sync_tunnel.self_probe_ok", { + machineKey: machineIdentity.machineKey, + roundTripMs: result.roundTripMs, + }); + return result; + } + + const failureAtMs = Date.now(); + selfProbeRoundTripMs = null; + selfProbe = { + verifiedAtMs: null, + lastFailure: result.reason, + lastFailureAtMs: failureAtMs, + inFlight: false, + }; + recordBridgeOpenFailure(result.reason, key, undefined, "self-probe"); + log.warn?.("sync_tunnel.self_probe_failed", { + machineKey: machineIdentity.machineKey, + reason: result.reason, + reasonClass: selfProbeReasonClass(result.reason), + }); + if (control === controlSocket && controlSocket.readyState === WebSocket.OPEN) { + const controlFailure = `relay end-to-end self-probe failed: ${result.reason}`; + const socketState = controlSocketStates.get(controlSocket); + if (socketState) socketState.failureReason = controlFailure; + recordControlFailure(controlFailure); + log.warn?.("sync_tunnel.zombie_control_detected", { + machineKey: machineIdentity.machineKey, + controlAgeMs: controlOpenedAtMs == null + ? null + : Math.max(0, Date.now() - controlOpenedAtMs), + transportMode, + source: "self-probe", + }); + try { + controlSocket.terminate(); + } catch { + // already dead + } + } + return result; + }).finally(() => { + if (selfProbeInFlight?.promise === current) selfProbeInFlight = null; + if (selfProbeInFlight == null) selfProbeSocket = null; + if (selfProbeBridgeKey === key && selfProbe.inFlight) { + selfProbe = { ...selfProbe, inFlight: false }; + } + }); + selfProbeInFlight = { key, promise: current }; + return current; + }; + + requestSelfProbe = ( + key: string, + options: { debounce?: boolean } = {}, + ): Promise => { + prepareSelfProbeKey(key); + if (selfProbeInFlight) { + if (selfProbeInFlight.key === key) return selfProbeInFlight.promise; + return selfProbeInFlight.promise.then(() => { + if (bridgeValidationIdentity().key !== key) { + return { ok: false, reason: "Relay bridge identity changed before self-probe." }; + } + return requestSelfProbe(key, options); + }); + } + if (selfProbeScheduled?.key === key) return selfProbeScheduled.promise; + if (selfProbeScheduled) { + clearScheduledSelfProbe("Relay bridge identity changed before self-probe."); + } + if (options.debounce === false) return performSelfProbe(key); + + let resolveScheduled!: (result: RelaySelfProbeResult) => void; + const promise = new Promise((resolve) => { + resolveScheduled = resolve; + }); + selfProbeScheduled = { key, promise, resolve: resolveScheduled }; + selfProbeDebounceTimer = setTimeout(() => { + selfProbeDebounceTimer = null; + const scheduled = selfProbeScheduled; + if (!scheduled || scheduled.key !== key) return; + selfProbeScheduled = null; + void performSelfProbe(key).then(scheduled.resolve); + }, RELAY_SELF_PROBE_DEBOUNCE_MS); + selfProbeDebounceTimer.unref?.(); + return promise; + }; + const sendControlLifecycle = ( controlSocket: WebSocket, payload: Record, @@ -931,7 +1269,7 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT const openTunnel = async ( id: MachineIdentity, - message: ControlMessage, + message: ControlOpenMessage, controlSocket: WebSocket, ): Promise => { const { id: connectionId, epoch } = message; @@ -1071,7 +1409,7 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT } } tunnel.ready = true; - clearBridgeOpenFailure(validatedKey); + clearBridgeOpenFailure(validatedKey, "bridge-open"); acceptOpen(); log.debug?.("sync_tunnel.ready", { connectionId, @@ -1148,6 +1486,8 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT const closeRelayConnections = (controlReason: string): void => { stopControlLiveness?.(); stopControlLiveness = null; + stopControlJsonKeepalive?.(); + stopControlJsonKeepalive = null; clearControlReadyTimer(); const socket = control; for (const pendingOpen of [...pendingBridgeOpens]) { @@ -1174,7 +1514,9 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT safeCloseWebSocket(tunnel.local, RELAY_CLOSE_PARTNER_CLOSED, "host unavailable"); } connected = false; + controlOpenedAtMs = null; clearBridgeValidation(); + if (!socket) resetSelfProbeForControlGeneration(); }; const reconcileAccountEligibility = async (): Promise => { @@ -1184,7 +1526,8 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT accountGeneration += 1; clearControlReadyTimer(); clearBridgeValidation(); - clearBridgeOpenFailure(); + bridgeOpenFailure = null; + resetSelfProbeForControlGeneration(); } accountEligible = nextEligibility; if (!nextEligibility) { @@ -1216,7 +1559,8 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT accountGeneration += 1; clearControlReadyTimer(); clearBridgeValidation(); - clearBridgeOpenFailure(); + bridgeOpenFailure = null; + resetSelfProbeForControlGeneration(); closeRelayConnections(nextUserId ? "account identity changed" : "account lease unavailable"); } } catch (error) { @@ -1277,6 +1621,36 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT validateCurrentBridge, + async runSelfProbe(): Promise<{ ok: boolean; detail: string }> { + if ( + stopped + || !accountSignedIn() + || control?.readyState !== WebSocket.OPEN + ) { + return { + ok: false, + detail: "Relay self-probe skipped because the control socket is not connected.", + }; + } + if (!await validateCurrentBridge()) { + return { + ok: false, + detail: "Relay self-probe skipped because the local bridge is not validated.", + }; + } + const currentIdentity = bridgeValidationIdentity(); + if (validatedBridgeKey !== currentIdentity.key) { + return { + ok: false, + detail: "Relay self-probe skipped because the bridge identity changed.", + }; + } + const result = await requestSelfProbe(currentIdentity.key); + return result.ok + ? { ok: true, detail: `Relay end-to-end verified in ${result.roundTripMs}ms.` } + : { ok: false, detail: result.reason }; + }, + getStatus(): SyncTunnelClientStatus { const { machineKey } = identity(); const currentValidationIdentity = bridgeValidationIdentity(); @@ -1286,6 +1660,12 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT const currentBridgeOpenFailure = bridgeOpenFailure?.key === currentValidationIdentity.key ? bridgeOpenFailure.reason : null; + const currentSelfProbeVerified = selfProbeBridgeKey === currentValidationIdentity.key + ? selfProbe.verifiedAtMs + : null; + const currentSelfProbeRoundTripMs = selfProbeBridgeKey === currentValidationIdentity.key + ? selfProbeRoundTripMs + : null; return { accountLeaseValid: eligible, connected: eligible && connected, @@ -1303,6 +1683,11 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT lastFailureAt, lastControlOpenAt, lastBridgeValidationAt, + relayEndToEndVerifiedAt: eligible && currentSelfProbeVerified != null + ? new Date(currentSelfProbeVerified).toISOString() + : null, + relayEndToEndFailure: eligible ? selfProbe.lastFailure : null, + relayEndToEndRoundTripMs: eligible ? currentSelfProbeRoundTripMs : null, relayUrl: relayHttpUrl(), machineKey, }; @@ -1497,6 +1882,79 @@ function armControlLiveness( return stop; } +function armControlJsonKeepalive( + socket: WebSocket, + onMiss: () => void, + pingIntervalMs: number, + pongDeadlineMs: number, +): { stop: () => void; acceptPong: () => void } { + const normalizedIntervalMs = Math.max(1, Math.floor(pingIntervalMs)); + const normalizedDeadlineMs = Math.max(1, Math.floor(pongDeadlineMs)); + let stopped = false; + let pongDeadline: NodeJS.Timeout | null = null; + let initialPingTimer: NodeJS.Timeout | null = null; + let nextIntervalDueAt = Date.now() + normalizedIntervalMs; + + const clearPongDeadline = (): void => { + if (!pongDeadline) return; + clearTimeout(pongDeadline); + pongDeadline = null; + }; + const miss = (): void => { + clearPongDeadline(); + if (!stopped) onMiss(); + }; + const sendPing = (): void => { + if (stopped || socket.readyState !== WebSocket.OPEN || pongDeadline) return; + pongDeadline = setTimeout(miss, normalizedDeadlineMs); + pongDeadline.unref?.(); + try { + socket.send(JSON.stringify({ t: "ping" }), (error) => { + if (error) miss(); + }); + } catch { + miss(); + } + }; + + initialPingTimer = setTimeout(() => { + initialPingTimer = null; + sendPing(); + }, CONTROL_JSON_INITIAL_PING_DELAY_MS); + initialPingTimer.unref?.(); + const pingTimer = setInterval(() => { + const now = Date.now(); + // Sleep/wake may delay this tick well beyond two intervals. Sending now is + // the recovery action; no separate wake hook or catch-up burst is needed. + const delayedAfterSleep = now - nextIntervalDueAt > normalizedIntervalMs; + nextIntervalDueAt = now + normalizedIntervalMs; + if (delayedAfterSleep) { + sendPing(); + return; + } + sendPing(); + }, normalizedIntervalMs); + pingTimer.unref?.(); + + const acceptPong = (): void => { + clearPongDeadline(); + }; + const onClose = (): void => { + stop(); + }; + const stop = (): void => { + if (stopped) return; + stopped = true; + if (initialPingTimer) clearTimeout(initialPingTimer); + initialPingTimer = null; + clearInterval(pingTimer); + clearPongDeadline(); + socket.off("close", onClose); + }; + socket.once("close", onClose); + return { stop, acceptPong }; +} + function safeCloseWebSocket(socket: WebSocket, code: number, reason: string): void { try { if (socket.readyState === WebSocket.OPEN) { From 2bd8abd8d0b76477e60da9db9aa1fd4914d80b58 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:47:04 -0400 Subject: [PATCH 02/11] feat(desktop): surface account-machine connect failures, stages, and routes AccountMachineRow now renders adoption errors (previously computed and silently dropped), a guided "Pair nearby instead" jump into the existing PIN flow, live stage text over the new account:pairMachineProgress IPC channel, and a transient connected-via route/latency line. Co-Authored-By: Claude Fable 5 --- .../account/accountBridge.trust.test.ts | 51 ++- .../main/services/account/accountBridge.ts | 56 +++- .../src/main/services/ipc/registerIpc.ts | 10 + apps/desktop/src/preload/global.d.ts | 4 + apps/desktop/src/preload/preload.test.ts | 26 +- apps/desktop/src/preload/preload.ts | 12 + apps/desktop/src/renderer/browserMock.ts | 1 + .../remoteTargets/AccountMachineRow.tsx | 60 +++- .../remoteTargets/PairMachineForm.tsx | 1 + .../remoteTargets/RemoteTargetList.test.tsx | 296 +++++++++++++++++ .../remoteTargets/RemoteTargetList.tsx | 297 ++++++++++++++++-- .../remoteTargets/SavedMachineRow.tsx | 17 + .../remoteTargets/remoteTargetListStyles.ts | 14 + .../src/renderer/webclient/adapter/account.ts | 1 + apps/desktop/src/shared/ipc.ts | 1 + apps/desktop/src/shared/types/account.ts | 6 + 16 files changed, 822 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/main/services/account/accountBridge.trust.test.ts b/apps/desktop/src/main/services/account/accountBridge.trust.test.ts index b14959538..6d36ee1d4 100644 --- a/apps/desktop/src/main/services/account/accountBridge.trust.test.ts +++ b/apps/desktop/src/main/services/account/accountBridge.trust.test.ts @@ -34,6 +34,7 @@ const pollLogin = vi.hoisted(() => vi.fn()); const signOut = vi.hoisted(() => vi.fn()); const deleteMachine = vi.hoisted(() => vi.fn()); const listMachines = vi.hoisted(() => vi.fn()); +const pairMachine = vi.hoisted(() => vi.fn()); const observedDirectoryBaseUrls = vi.hoisted(() => [] as Array); const resolveOfficialAccountDirectoryBaseUrl = vi.hoisted(() => vi.fn( () => "https://ade-account-directory-production.arulsharma1028.workers.dev", @@ -76,8 +77,8 @@ vi.mock( return listMachines(); } - async pairMachine() { - throw new Error("not used"); + async pairMachine(machineKey: string) { + return pairMachine(machineKey); } async deleteMachine(machineKey: string) { @@ -423,6 +424,7 @@ describe("desktop account machine lifecycle", () => { signOut.mockReset().mockReturnValue({ ...accountStatus }); deleteMachine.mockReset(); listMachines.mockReset().mockResolvedValue({ state: "ok", machines: [], message: null }); + pairMachine.mockReset(); observedDirectoryBaseUrls.splice(0); resolveOfficialAccountDirectoryBaseUrl.mockClear(); }); @@ -494,6 +496,51 @@ describe("desktop account machine lifecycle", () => { expect(deleteMachine).toHaveBeenCalledWith("machine-a"); }); + it("emits account machine progress at the bridge and per-call boundaries", async () => { + pairMachine.mockResolvedValue({ + targetId: "target-a", + machineKey: "machine-a", + deviceId: "device-a", + name: "Studio", + }); + const { createAccountBridge } = await import("./accountBridge"); + const bridge = createAccountBridge({ getProjectRoot: () => null }); + const listener = vi.fn(); + const onProgress = vi.fn(); + const unsubscribe = bridge.onPairMachineProgress(listener); + + await expect( + bridge.pairMachine("machine-a", { onProgress }), + ).resolves.toMatchObject({ + targetId: "target-a", + machineKey: "machine-a", + }); + + const expectedProgress = [ + { + machineKey: "machine-a", + stage: "relay", + label: "Connecting through ADE relay…", + }, + { + machineKey: "machine-a", + stage: "opening", + label: "Opening connection…", + }, + ]; + expect(pairMachine).toHaveBeenCalledWith("machine-a"); + expect(listener.mock.calls.map(([progress]) => progress)).toEqual( + expectedProgress, + ); + expect(onProgress.mock.calls.map(([progress]) => progress)).toEqual( + expectedProgress, + ); + + unsubscribe(); + await bridge.pairMachine("machine-a"); + expect(listener).toHaveBeenCalledTimes(2); + }); + it("preserves a classified directory auth failure for the desktop surface", async () => { listMachines.mockResolvedValue({ state: "auth_expired", diff --git a/apps/desktop/src/main/services/account/accountBridge.ts b/apps/desktop/src/main/services/account/accountBridge.ts index 626dc69d6..ed1a33738 100644 --- a/apps/desktop/src/main/services/account/accountBridge.ts +++ b/apps/desktop/src/main/services/account/accountBridge.ts @@ -26,6 +26,7 @@ import type { AdeAccountMachinePairResult, AdeAccountMachineRemovalResult, AdeAccountMachinesResult, + AdeAccountPairMachineProgress, AdeAccountLoginPoll, AdeAccountStatus, } from "../../../shared/types"; @@ -130,12 +131,25 @@ export type AccountBridge = { cancelLogin(sessionId: string): void; signOut(): AdeAccountStatus; listMachines(): Promise; - pairMachine(machineKey: string): Promise; + pairMachine( + machineKey: string, + options?: AccountBridgePairMachineOptions, + ): Promise; + onPairMachineProgress( + listener: (progress: AdeAccountPairMachineProgress) => void, + ): () => void; removeMachine(machineKey: string): Promise; }; +export type AccountBridgePairMachineOptions = { + onProgress?: (progress: AdeAccountPairMachineProgress) => void; +}; + export function createAccountBridge(options: AccountBridgeOptions): AccountBridge { const secretsDir = resolveMachineAdeLayout().secretsDir; + const pairMachineProgressListeners = new Set< + (progress: AdeAccountPairMachineProgress) => void + >(); const service = () => getSharedAccountAuthService({ @@ -207,8 +221,44 @@ export function createAccountBridge(options: AccountBridgeOptions): AccountBridg return result; }, - pairMachine: async (machineKey: string): Promise => { - return await directoryService().pairMachine(machineKey); + pairMachine: async ( + machineKey: string, + pairOptions: AccountBridgePairMachineOptions = {}, + ): Promise => { + const emitProgress = (progress: AdeAccountPairMachineProgress): void => { + try { + pairOptions.onProgress?.(progress); + } catch { + // Progress reporting is best-effort and must not abort adoption. + } + for (const listener of pairMachineProgressListeners) { + try { + listener(progress); + } catch { + // One window listener must not prevent the pair attempt or other listeners. + } + } + }; + emitProgress({ + machineKey, + stage: "relay", + label: "Connecting through ADE relay…", + }); + // AccountMachineDirectoryService does not expose adoption progress yet. + // When it does, its onProgress callback can be wired directly to + // emitProgress without changing this bridge or the renderer contract. + const result = await directoryService().pairMachine(machineKey); + emitProgress({ + machineKey, + stage: "opening", + label: "Opening connection…", + }); + return result; + }, + + onPairMachineProgress: (listener) => { + pairMachineProgressListeners.add(listener); + return () => pairMachineProgressListeners.delete(listener); }, removeMachine: async (machineKey: string): Promise => { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index c0c541be7..fa5fca3e7 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -8809,6 +8809,16 @@ export function registerIpc({ warn: (message, meta) => getCtx().logger.warn(message, meta), }, }); + accountBridge.onPairMachineProgress((progress) => { + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed()) continue; + try { + win.webContents.send(IPC.accountPairMachineProgress, progress); + } catch { + // Ignore a window that closes while progress is being broadcast. + } + } + }); ipcMain.handle(IPC.accountStatus, async (): Promise => { return accountBridge.status(); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 1e27292e0..39d1f2d58 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -320,6 +320,7 @@ import type { AdeAccountMachineRemovalResult, AdeAccountMachinesResult, AdeAccountMachinePairResult, + AdeAccountPairMachineProgress, CreateLaneFromPrBranchArgs, CreateLaneFromPrBranchPreflightResult, CreateLaneFromPrBranchResult, @@ -2028,6 +2029,9 @@ declare global { listMachines: () => Promise; getLocalMachineIdentity: () => Promise; pairMachine: (machineKey: string) => Promise; + onPairMachineProgress: ( + cb: (progress: AdeAccountPairMachineProgress) => void, + ) => () => void; removeMachine: (machineKey: string) => Promise; }; prs: { diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index e490f6903..ff853f133 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -79,12 +79,14 @@ describe("preload OAuth bridge", () => { it("exposes local account identity and machine removal IPC", async () => { const invoke = vi.fn(async () => undefined); + const on = vi.fn(); + const removeListener = vi.fn(); const exposeInMainWorld = vi.fn((_name: string, value: unknown) => { (globalThis as any).__adeBridge = value; }); vi.doMock("electron", () => ({ contextBridge: { exposeInMainWorld }, - ipcRenderer: { invoke, on: vi.fn(), removeListener: vi.fn() }, + ipcRenderer: { invoke, on, removeListener }, webFrame: { getZoomLevel: vi.fn(() => 0), setZoomLevel: vi.fn(), @@ -96,11 +98,33 @@ describe("preload OAuth bridge", () => { const bridge = (globalThis as any).__adeBridge; await bridge.account.getLocalMachineIdentity(); await bridge.account.removeMachine("machine-a"); + const progressCallback = vi.fn(); + const unsubscribe = bridge.account.onPairMachineProgress(progressCallback); expect(invoke).toHaveBeenCalledWith(IPC.accountGetLocalMachineIdentity); expect(invoke).toHaveBeenCalledWith(IPC.accountRemoveMachine, { machineKey: "machine-a", }); + expect(on).toHaveBeenCalledWith( + IPC.accountPairMachineProgress, + expect.any(Function), + ); + const listener = on.mock.calls.at(-1)?.[1]; + listener({}, { + machineKey: "machine-a", + stage: "relay", + label: "Connecting through ADE relay…", + }); + expect(progressCallback).toHaveBeenCalledWith({ + machineKey: "machine-a", + stage: "relay", + label: "Connecting through ADE relay…", + }); + unsubscribe(); + expect(removeListener).toHaveBeenCalledWith( + IPC.accountPairMachineProgress, + listener, + ); }); it("exposes per-window project tab session IPC", async () => { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index af88afd96..9d68ec466 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -241,6 +241,7 @@ import type { AdeAccountMachineRemovalResult, AdeAccountMachinesResult, AdeAccountMachinePairResult, + AdeAccountPairMachineProgress, CreateLaneFromPrBranchArgs, CreateLaneFromPrBranchPreflightResult, CreateLaneFromPrBranchResult, @@ -7766,6 +7767,17 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.accountGetLocalMachineIdentity), pairMachine: (machineKey: string): Promise => ipcRenderer.invoke(IPC.accountPairMachine, { machineKey }), + onPairMachineProgress: ( + cb: (progress: AdeAccountPairMachineProgress) => void, + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + progress: AdeAccountPairMachineProgress, + ) => cb(progress); + ipcRenderer.on(IPC.accountPairMachineProgress, listener); + return () => + ipcRenderer.removeListener(IPC.accountPairMachineProgress, listener); + }, removeMachine: (machineKey: string): Promise => ipcRenderer.invoke(IPC.accountRemoveMachine, { machineKey }), }, diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index e57543160..80a643c43 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3283,6 +3283,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { deviceId: "dev_studio", name: "Studio", }), + onPairMachineProgress: () => () => {}, }; })(), app: { diff --git a/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx b/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx index ea366f2a6..43480c184 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx @@ -9,13 +9,25 @@ import { type AccountMachineRow as AccountMachineRowModel, type MachineSection, } from "./remoteMachineModel"; -import { helperTextStyle, inlineDetailStyle, machineRowStyle, nameStyle, subTextStyle } from "./remoteTargetListStyles"; +import { + helperTextStyle, + inlineDetailStyle, + inlineErrorTextStyle, + inlineSuccessTextStyle, + machineRowStyle, + nameStyle, + subTextStyle, +} from "./remoteTargetListStyles"; type AccountMachineRowProps = { row: AccountMachineRowModel; section: MachineSection; busy: boolean; connecting: boolean; + error?: string | null; + stageLabel?: string | null; + successLabel?: string | null; + onPairNearby?: (() => void) | null; detailOpen: boolean; onToggleDetail: (rowId: string) => void; onConnect: (machine: AdeAccountMachine) => void; @@ -42,6 +54,10 @@ export function AccountMachineRow({ section, busy, connecting, + error = null, + stageLabel = null, + successLabel = null, + onPairNearby = null, detailOpen, onToggleDetail, onConnect, @@ -54,7 +70,10 @@ export function AccountMachineRow({ const canExplain = trulyOffline || needsSetup; return ( -
+
@@ -106,6 +125,43 @@ export function AccountMachineRow({ ) : null}
+ + {connecting && stageLabel ? ( +
+ {stageLabel} +
+ ) : null} + + {successLabel ? ( +
+ {successLabel} +
+ ) : null} + + {error ? ( +
+
{error}
+ {onPairNearby ? ( + + ) : null} +
+ ) : null}
{canExplain && detailOpen ? ( diff --git a/apps/desktop/src/renderer/components/remoteTargets/PairMachineForm.tsx b/apps/desktop/src/renderer/components/remoteTargets/PairMachineForm.tsx index 794e33cda..a214948c9 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/PairMachineForm.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/PairMachineForm.tsx @@ -167,6 +167,7 @@ export function PairMachineForm({ style={{ ...fieldStyle, letterSpacing: "0.24em" }} disabled={busy || submitting} autoComplete="off" + autoFocus />
diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx index 82cf1ab03..c7f5fbdec 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx @@ -6,6 +6,7 @@ import { fireEvent, render, screen, + within, waitFor, } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -49,6 +50,7 @@ const appMock = { const accountMock = { pairMachine: vi.fn(), getLocalMachineIdentity: vi.fn(), + onPairMachineProgress: vi.fn(), }; function installAdeMock(): void { @@ -62,6 +64,7 @@ function installAdeMock(): void { }); remoteRuntimeMock.runDoctor.mockResolvedValue({ checks: [] }); accountMock.getLocalMachineIdentity.mockResolvedValue({ machineKey: "local-mk", deviceId: "local-dev" }); + accountMock.onPairMachineProgress.mockReturnValue(() => {}); Object.defineProperty(window, "ade", { configurable: true, value: { @@ -73,6 +76,33 @@ function installAdeMock(): void { }); } +function accountMachine( + overrides: Partial & Pick, +): AdeAccountMachine { + return { + deviceId: `${overrides.machineKey}-device`, + platform: "darwin", + deviceType: "desktop", + reachableEndpoints: [ + { + kind: "relay", + url: `${DEFAULT_ADE_TUNNEL_RELAY_URL.replace("https:", "wss:")}/connect/${overrides.machineKey}`, + }, + ], + lastSeenAt: Date.now(), + online: true, + ...overrides, + }; +} + +function getAccountRow(name: string): HTMLElement { + const row = screen.getByText(name).closest("[data-account-machine-key]"); + if (!(row instanceof HTMLElement)) { + throw new Error(`Account row not found for ${name}`); + } + return row; +} + function openAddMode(label: "Find nearby Macs" | "Add over SSH"): void { fireEvent.click(screen.getByRole("button", { name: "Add machine" })); fireEvent.click(screen.getByRole("button", { name: new RegExp(`^${label}`) })); @@ -81,6 +111,7 @@ function openAddMode(label: "Find nearby Macs" | "Add over SSH"): void { describe("RemoteTargetList", () => { afterEach(() => { cleanup(); + vi.useRealTimers(); vi.restoreAllMocks(); vi.clearAllMocks(); Reflect.deleteProperty(remoteRuntimeMock, "getConnectionSnapshot"); @@ -1114,6 +1145,271 @@ describe("RemoteTargetList", () => { ); }); + it("shows an account connect failure only on the machine that failed", async () => { + remoteRuntimeMock.listTargets.mockResolvedValue([]); + remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ + machines: [], + diagnostics: [], + }); + accountMock.pairMachine.mockRejectedValue( + new Error("Account relay adoption failed."), + ); + installAdeMock(); + + render( + , + ); + + await screen.findByText("Failing Studio"); + const failingRow = getAccountRow("Failing Studio"); + const otherRow = getAccountRow("Other Studio"); + fireEvent.click( + within(failingRow).getByRole("button", { name: "Connect" }), + ); + + await waitFor(() => + expect( + within(failingRow).getByText("Account relay adoption failed."), + ).toBeTruthy(), + ); + expect( + within(otherRow).queryByText("Account relay adoption failed."), + ).toBeNull(); + }); + + it("offers nearby pairing only for a failed account row with a matching discovered machine", async () => { + remoteRuntimeMock.listTargets.mockResolvedValue([]); + remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ + machines: [ + { + id: "nearby-studio", + serviceName: "ADE Sync Nearby Studio", + machineName: "Nearby Studio", + hostIdentity: "nearby-device", + hostName: "nearby-studio.local", + port: 8787, + addresses: ["192.168.1.55"], + primaryRoute: "192.168.1.55", + tailscaleAddress: null, + runtimeKind: "daemon", + runtimeVersion: "1.0.0", + connectable: true, + projectIds: [], + projectCount: 0, + lastSeenAt: Date.now(), + }, + ], + diagnostics: [], + }); + remoteRuntimeMock.parsePairingInput.mockResolvedValue({ + hostIdentity: { + deviceId: "nearby-device", + siteId: "", + name: "Nearby Studio", + platform: "macOS", + deviceType: "desktop", + }, + machineName: "Nearby Studio", + endpoints: ["wss://192.168.1.55:8787"], + requiresPin: true, + }); + accountMock.pairMachine.mockRejectedValue( + new Error("ADE relay was unavailable."), + ); + installAdeMock(); + + render( + , + ); + + await screen.findByText("Nearby Studio"); + const matchingRow = getAccountRow("Nearby Studio"); + const unmatchedRow = getAccountRow("No Nearby Match"); + + fireEvent.click( + within(matchingRow).getByRole("button", { name: "Connect" }), + ); + await waitFor(() => + expect( + within(matchingRow).getByRole("button", { + name: "It's on your network — Pair nearby instead ›", + }), + ).toBeTruthy(), + ); + + fireEvent.click( + within(unmatchedRow).getByRole("button", { name: "Connect" }), + ); + await waitFor(() => + expect( + within(unmatchedRow).getByText("ADE relay was unavailable."), + ).toBeTruthy(), + ); + expect( + within(unmatchedRow).queryByRole("button", { + name: "It's on your network — Pair nearby instead ›", + }), + ).toBeNull(); + + fireEvent.click( + within(matchingRow).getByRole("button", { + name: "It's on your network — Pair nearby instead ›", + }), + ); + + const pinInput = await screen.findByLabelText("6-digit code"); + await waitFor(() => + expect(remoteRuntimeMock.parsePairingInput).toHaveBeenCalledWith( + expect.stringMatching(/^https:\/\/ade-app\.dev\/pair#/), + ), + ); + expect(document.activeElement).toBe(pinInput); + }); + + it("shows account pairing progress on the matching row while it is connecting", async () => { + remoteRuntimeMock.listTargets.mockResolvedValue([]); + remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ + machines: [], + diagnostics: [], + }); + accountMock.pairMachine.mockReturnValue(new Promise(() => {})); + installAdeMock(); + + render( + , + ); + + await screen.findByText("Progress Studio"); + const row = getAccountRow("Progress Studio"); + fireEvent.click(within(row).getByRole("button", { name: "Connect" })); + await waitFor(() => + expect(accountMock.pairMachine).toHaveBeenCalledWith("mk-progress"), + ); + + const progressListener = accountMock.onPairMachineProgress.mock.calls[0]?.[0] as + | ((progress: { + machineKey: string; + stage: "relay"; + label: string; + }) => void) + | undefined; + expect(progressListener).toBeTypeOf("function"); + act(() => { + progressListener?.({ + machineKey: "mk-progress", + stage: "relay", + label: "Connecting through ADE relay…", + }); + }); + + expect( + within(row).getByText("Connecting through ADE relay…"), + ).toBeTruthy(); + }); + + it("shows the winning account route briefly after connecting", async () => { + remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ + machines: [], + diagnostics: [], + }); + const savedTarget = { + id: "target-toast", + name: "Toast Studio", + hostname: "100.92.14.3", + transport: "paired" as const, + pairedMachine: { + hostIdentity: "toast-device", + machineKey: "mk-toast", + }, + sshUser: null, + port: null, + sshKeyPath: null, + routes: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, + }; + remoteRuntimeMock.listTargets + .mockResolvedValueOnce([]) + .mockResolvedValue([savedTarget]); + accountMock.pairMachine.mockResolvedValue({ + targetId: savedTarget.id, + machineKey: "mk-toast", + deviceId: "toast-device", + name: "Toast Studio", + }); + remoteRuntimeMock.connect.mockResolvedValue({ + target: savedTarget, + arch: "darwin-arm64", + version: "1.0.0", + route: { + kind: "tailnet", + endpoint: "100.92.14.3", + latencyMs: 12, + }, + projects: [], + }); + installAdeMock(); + + render( + , + ); + + await screen.findByText("Toast Studio"); + const row = getAccountRow("Toast Studio"); + vi.useFakeTimers(); + fireEvent.click(within(row).getByRole("button", { name: "Connect" })); + await act(async () => { + for (let index = 0; index < 12; index += 1) { + await Promise.resolve(); + } + }); + + expect(screen.getByText("Connected via Tailscale · 12ms")).toBeTruthy(); + act(() => { + vi.advanceTimersByTime(4_000); + }); + expect(screen.queryByText("Connected via Tailscale · 12ms")).toBeNull(); + }); + it("never lists this Mac as its own remote target (self-filter by machineKey or deviceId)", async () => { remoteRuntimeMock.listTargets.mockResolvedValue([]); remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ machines: [], diagnostics: [] }); diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx index dbb2b877c..2957dfa48 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx @@ -68,10 +68,47 @@ type RemoteTargetListProps = { type ConnectTargetOptions = { skipHostKeyTrustCheck?: boolean; + onError?: (message: string) => void; }; type AddMode = "choose" | "nearby" | "pair" | "ssh"; +type AccountConnectionToast = { + targetId: string; + label: string; +}; + +const ACCOUNT_CONNECTION_TOAST_MS = 4_000; + +function accountMachineMatchesNearby( + accountMachine: AdeAccountMachine, + discoveredMachine: RemoteRuntimeDiscoveredMachine, +): boolean { + const accountDeviceId = accountMachine.deviceId?.trim() ?? ""; + const discoveredDeviceId = discoveredMachine.hostIdentity?.trim() ?? ""; + if (accountDeviceId && discoveredDeviceId) { + return accountDeviceId === discoveredDeviceId; + } + const accountName = accountMachine.name?.trim().toLowerCase() ?? ""; + const discoveredName = discoveredMachine.machineName.trim().toLowerCase(); + return Boolean(accountName && discoveredName && accountName === discoveredName); +} + +function connectedViaLabel(result: RemoteRuntimeConnectResult): string | null { + if (!result.route) return null; + const routeLabel = { + lan: "local network", + tailnet: "Tailscale", + relay: "ADE relay", + ssh: "SSH", + }[result.route.kind]; + const latency = + typeof result.route.latencyMs === "number" + ? ` · ${Math.round(result.route.latencyMs)}ms` + : ""; + return `Connected via ${routeLabel}${latency}`; +} + function targetFormPrefill( target: RemoteRuntimeTarget, ): RemoteTargetFormPrefill { @@ -140,6 +177,65 @@ export function RemoteTargetList({ const [localMachineIdentity, setLocalMachineIdentity] = useState<{ machineKey: string; deviceId: string } | null>(null); const [pairingPrefill, setPairingPrefill] = useState(null); + const [accountConnectingMachineKey, setAccountConnectingMachineKey] = + useState(null); + const [accountRowErrors, setAccountRowErrors] = useState< + Record + >({}); + const [accountRowStages, setAccountRowStages] = useState< + Record + >({}); + const [accountConnectionToasts, setAccountConnectionToasts] = useState< + Record + >({}); + const accountConnectionToastTimers = useRef< + Map + >(new Map()); + + const clearAccountConnectionToast = useCallback((machineKey: string) => { + const timer = accountConnectionToastTimers.current.get(machineKey); + if (timer != null) window.clearTimeout(timer); + accountConnectionToastTimers.current.delete(machineKey); + setAccountConnectionToasts((current) => { + if (!(machineKey in current)) return current; + const next = { ...current }; + delete next[machineKey]; + return next; + }); + }, []); + + const showAccountConnectionToast = useCallback( + (machineKey: string, targetId: string, label: string) => { + const existingTimer = + accountConnectionToastTimers.current.get(machineKey); + if (existingTimer != null) window.clearTimeout(existingTimer); + setAccountConnectionToasts((current) => ({ + ...current, + [machineKey]: { targetId, label }, + })); + const timer = window.setTimeout(() => { + accountConnectionToastTimers.current.delete(machineKey); + setAccountConnectionToasts((current) => { + if (!(machineKey in current)) return current; + const next = { ...current }; + delete next[machineKey]; + return next; + }); + }, ACCOUNT_CONNECTION_TOAST_MS); + accountConnectionToastTimers.current.set(machineKey, timer); + }, + [], + ); + + useEffect( + () => () => { + for (const timer of accountConnectionToastTimers.current.values()) { + window.clearTimeout(timer); + } + accountConnectionToastTimers.current.clear(); + }, + [], + ); // Never surface THIS Mac in its own account list. Match on the stable // machineKey OR deviceId reported by the local identity IPC (#A3). @@ -222,6 +318,17 @@ export function RemoteTargetList({ if (!accountSignedIn) void loadTargets(); }, [accountSignedIn, loadTargets]); + useEffect(() => { + const subscribe = window.ade.account?.onPairMachineProgress; + if (!subscribe) return; + return subscribe((progress) => { + setAccountRowStages((current) => ({ + ...current, + [progress.machineKey]: progress.label, + })); + }); + }, []); + useEffect(() => { if (!window.ade.remoteRuntime.onConnectionSnapshotChanged) return; const unsubscribe = window.ade.remoteRuntime.onConnectionSnapshotChanged( @@ -354,7 +461,7 @@ export function RemoteTargetList({ try { if (!options.skipHostKeyTrustCheck) { const trusted = await ensureHostKeyTrust(targetId); - if (!trusted) return false; + if (!trusted) return null; } const result = await window.ade.remoteRuntime.connect(targetId); setConnected(result); @@ -408,7 +515,7 @@ export function RemoteTargetList({ setError(null); setTestingId(null); onConnected?.(result); - return true; + return result; } catch (err) { let trustRequired = false; try { @@ -416,8 +523,12 @@ export function RemoteTargetList({ } catch { // Preserve the connect failure when a follow-up trust probe also fails. } - if (!trustRequired) setError(formatRemoteTargetError(err)); - return false; + if (!trustRequired) { + const message = formatRemoteTargetError(err); + if (options.onError) options.onError(message); + else setError(message); + } + return null; } finally { setBusyId(null); } @@ -485,18 +596,24 @@ export function RemoteTargetList({ [formPrefill?.targetId, saveTargetAndConnect], ); + const openNearbyPairing = useCallback( + (machine: RemoteRuntimeDiscoveredMachine): boolean => { + const directPairingInput = discoveredPairingInput(machine); + if (!directPairingInput) return false; + setSelectedId(null); + setHostKeyTrust(null); + setError(null); + setPairingPrefill(directPairingInput); + setAddMode("pair"); + return true; + }, + [], + ); + const connectDiscoveredMachine = useCallback( async (machine: RemoteRuntimeDiscoveredMachine) => { if (machine.connectable === false) return; - const directPairingInput = discoveredPairingInput(machine); - if (directPairingInput) { - setSelectedId(null); - setHostKeyTrust(null); - setError(null); - setPairingPrefill(directPairingInput); - setAddMode("pair"); - return; - } + if (openNearbyPairing(machine)) return; if (isSshOnlyDiscovered(machine)) return; const input = discoveredTargetInput(machine); if (!input) return; @@ -510,26 +627,84 @@ export function RemoteTargetList({ setBusyId(null); } }, - [saveTargetAndConnect], + [openNearbyPairing, saveTargetAndConnect], ); const connectAccountMachine = useCallback( async (machine: AdeAccountMachine) => { - setBusyId(`account:${machine.machineKey}`); + const machineKey = machine.machineKey; + clearAccountConnectionToast(machineKey); + setAccountRowErrors((current) => { + if (!(machineKey in current)) return current; + const next = { ...current }; + delete next[machineKey]; + return next; + }); + setAccountRowStages((current) => { + if (!(machineKey in current)) return current; + const next = { ...current }; + delete next[machineKey]; + return next; + }); + setAccountConnectingMachineKey(machineKey); + setBusyId(`account:${machineKey}`); setSelectedId(null); setHostKeyTrust(null); - setError(null); try { - const paired = await window.ade.account.pairMachine(machine.machineKey); + const paired = await window.ade.account.pairMachine(machineKey); await loadTargets(); - await connectTarget(paired.targetId, { skipHostKeyTrustCheck: true }); + let connectionErrorReported = false; + const result = await connectTarget(paired.targetId, { + skipHostKeyTrustCheck: true, + onError: (message) => { + connectionErrorReported = true; + setAccountRowErrors((current) => ({ + ...current, + [machineKey]: message, + })); + }, + }); + if (!result) { + if (!connectionErrorReported) { + setAccountRowErrors((current) => ({ + ...current, + [machineKey]: "Couldn't open the connection. Try again.", + })); + } + return; + } + const label = connectedViaLabel(result); + if (label) { + showAccountConnectionToast( + machineKey, + result.target.id, + label, + ); + } } catch (err) { - setError(formatRemoteTargetError(err)); + setAccountRowErrors((current) => ({ + ...current, + [machineKey]: formatRemoteTargetError(err), + })); } finally { + setAccountRowStages((current) => { + if (!(machineKey in current)) return current; + const next = { ...current }; + delete next[machineKey]; + return next; + }); + setAccountConnectingMachineKey((current) => + current === machineKey ? null : current, + ); setBusyId(null); } }, - [connectTarget, loadTargets], + [ + clearAccountConnectionToast, + connectTarget, + loadTargets, + showAccountConnectionToast, + ], ); const onPaired = useCallback( @@ -647,6 +822,27 @@ export function RemoteTargetList({ sections.available.length + sections.unavailable.length; + const nearbyPairingByAccountMachineKey = useMemo(() => { + const matches = new Map(); + for (const accountMachine of visibleAccountMachines ?? []) { + const discovered = discoveredMachines.find( + (machine) => + accountMachineMatchesNearby(accountMachine, machine) && + discoveredPairingInput(machine) != null, + ); + if (discovered) matches.set(accountMachine.machineKey, discovered); + } + return matches; + }, [discoveredMachines, visibleAccountMachines]); + + const accountToastByTargetId = useMemo(() => { + const labels = new Map(); + for (const toast of Object.values(accountConnectionToasts)) { + labels.set(toast.targetId, toast.label); + } + return labels; + }, [accountConnectionToasts]); + function renderSection(section: MachineSection) { const rows = sections[section]; if (rows.length === 0) return null; @@ -666,12 +862,41 @@ export function RemoteTargetList({ saving={saving} formPrefill={formPrefill} testOpen={testingId === row.target.id} - error={error} + error={ + (row.target.pairedMachine?.machineKey + ? accountRowErrors[ + row.target.pairedMachine.machineKey + ] ?? null + : null) ?? error + } + stageLabel={ + row.target.pairedMachine?.machineKey && + accountConnectingMachineKey === + row.target.pairedMachine.machineKey + ? accountRowStages[ + row.target.pairedMachine.machineKey + ] ?? null + : null + } + transientStatus={ + accountToastByTargetId.get(row.target.id) ?? null + } hostKeyTrust={ selectedId === row.target.id ? selectedHostKeyTrust : null } trustingHostKey={trustingHostKey} - onConnect={(targetId) => void connectTarget(targetId)} + onConnect={(targetId) => { + const machineKey = row.target.pairedMachine?.machineKey; + if (machineKey) { + setAccountRowErrors((current) => { + if (!(machineKey in current)) return current; + const next = { ...current }; + delete next[machineKey]; + return next; + }); + } + void connectTarget(targetId); + }} onDisconnect={(targetId) => void disconnectTarget(targetId)} onToggleTest={toggleTest} onToggleEdit={toggleEditForm} @@ -692,7 +917,33 @@ export function RemoteTargetList({ row={row} section={section} busy={busyId != null} - connecting={busyId === row.id} + connecting={ + accountConnectingMachineKey === row.machine.machineKey + } + error={accountRowErrors[row.machine.machineKey] ?? null} + stageLabel={accountRowStages[row.machine.machineKey] ?? null} + successLabel={ + accountConnectionToasts[row.machine.machineKey]?.label ?? null + } + onPairNearby={ + nearbyPairingByAccountMachineKey.has(row.machine.machineKey) + ? () => { + const machine = nearbyPairingByAccountMachineKey.get( + row.machine.machineKey, + ); + if (!machine) return; + setAccountRowErrors((current) => { + if (!(row.machine.machineKey in current)) { + return current; + } + const next = { ...current }; + delete next[row.machine.machineKey]; + return next; + }); + openNearbyPairing(machine); + } + : null + } detailOpen={testingId === row.id} onToggleDetail={toggleTest} onConnect={(machine) => void connectAccountMachine(machine)} diff --git a/apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsx b/apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsx index 1a603479a..df0648a4e 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsx @@ -35,6 +35,7 @@ import { import { helperTextStyle, inlineDetailStyle, + inlineSuccessTextStyle, machineRowStyle, nameStyle, subTextStyle, @@ -50,6 +51,8 @@ type SavedMachineRowProps = { formPrefill: RemoteTargetFormPrefill | null; testOpen: boolean; error: string | null; + stageLabel?: string | null; + transientStatus?: string | null; hostKeyTrust: RemoteRuntimeSshHostKeyTrustStatus | null; trustingHostKey: boolean; onConnect: (targetId: string) => void; @@ -73,6 +76,8 @@ export function SavedMachineRow({ formPrefill, testOpen, error, + stageLabel = null, + transientStatus = null, hostKeyTrust, trustingHostKey, onConnect, @@ -240,6 +245,18 @@ export function SavedMachineRow({
+ {stageLabel ? ( +
+ {stageLabel} +
+ ) : null} + + {transientStatus ? ( +
+ {transientStatus} +
+ ) : null} + {errorCard ? ( { throw new Error("Choose this machine from the web machine switcher."); }, + onPairMachineProgress: () => () => {}, removeMachine: async (machineKey: string) => await accountClient.removeMachine(machineKey), }; } diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 7372366d3..18fd5894f 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -589,6 +589,7 @@ export const IPC = { accountSignOut: "ade.account.signOut", accountListMachines: "ade.account.listMachines", accountPairMachine: "ade.account.pairMachine", + accountPairMachineProgress: "account:pairMachineProgress", accountGetLocalMachineIdentity: "ade.account.getLocalMachineIdentity", accountRemoveMachine: "ade.account.removeMachine", prsCreateFromLane: "ade.prs.createFromLane", diff --git a/apps/desktop/src/shared/types/account.ts b/apps/desktop/src/shared/types/account.ts index 6b66127f0..66cfcfedc 100644 --- a/apps/desktop/src/shared/types/account.ts +++ b/apps/desktop/src/shared/types/account.ts @@ -91,3 +91,9 @@ export type AdeAccountMachinePairResult = { deviceId: string; name: string; }; + +export type AdeAccountPairMachineProgress = { + machineKey: string; + stage: "relay" | "tailnet" | "lan" | "verifying" | "opening"; + label: string; +}; From 4809aba24a34362fd2014774abf67c85b1251cd0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:30:08 -0400 Subject: [PATCH 03/11] =?UTF-8?q?feat(sync):=20ade-adopt-v1=20=E2=80=94=20?= =?UTF-8?q?host=20identity=20proof=20+=20sealed=20account=20adoption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Machines publish an ed25519 identity key in their directory row and answer a pre-auth account_challenge by signing the client nonce over an ephemeral X25519 exchange. Adoption hellos and hello_ok replies are then sealed with the derived session key, so account adoption can safely run over any route and clients verify the host is genuine before releasing credentials. Plaintext account hellos remain relay-only for legacy compatibility. Co-Authored-By: Claude Fable 5 --- apps/account-directory/README.md | 5 + apps/account-directory/src/directory.ts | 2 + apps/account-directory/test/directory.test.ts | 31 ++ apps/ade-cli/README.md | 7 +- .../accountMachineDirectoryService.test.ts | 12 +- .../accountMachinePublisherService.test.ts | 27 ++ .../account/accountMachinePublisherService.ts | 40 ++- .../sync/machineIdentitySigningStore.test.ts | 58 ++++ .../sync/machineIdentitySigningStore.ts | 153 +++++++++ .../src/services/sync/syncHostService.test.ts | 312 +++++++++++++++++ .../src/services/sync/syncHostService.ts | 318 +++++++++++++++++- .../syncPairedMachineStore.test.ts | 235 +++++++++++++ .../remoteRuntime/syncPairedMachineStore.ts | 189 ++++++++++- apps/desktop/src/shared/accountDirectory.ts | 9 +- .../shared/sync/adoptChannelCrypto.test.ts | 138 ++++++++ .../src/shared/sync/adoptChannelCrypto.ts | 207 ++++++++++++ apps/desktop/src/shared/types/account.ts | 2 + apps/desktop/src/shared/types/sync.ts | 49 ++- 18 files changed, 1769 insertions(+), 25 deletions(-) create mode 100644 apps/ade-cli/src/services/sync/machineIdentitySigningStore.test.ts create mode 100644 apps/ade-cli/src/services/sync/machineIdentitySigningStore.ts create mode 100644 apps/desktop/src/shared/sync/adoptChannelCrypto.test.ts create mode 100644 apps/desktop/src/shared/sync/adoptChannelCrypto.ts diff --git a/apps/account-directory/README.md b/apps/account-directory/README.md index 774d90597..44b8e1f0a 100644 --- a/apps/account-directory/README.md +++ b/apps/account-directory/README.md @@ -16,6 +16,11 @@ Device codes and approval-attempt rate limits are stored in D1. The daemon secret is stored only as a SHA-256 digest; approved token pairs are cleared by the one-time redemption update or when the device code expires. +Machine registration and list records may carry a `pubkey` string. Current ADE +hosts publish `ed25519:` so clients can verify and seal +account adoption on direct or relay routes. The Worker treats the value as +opaque metadata and rejects values longer than 128 characters. + ## Local checks ```sh diff --git a/apps/account-directory/src/directory.ts b/apps/account-directory/src/directory.ts index 75dcc2bbc..563780d49 100644 --- a/apps/account-directory/src/directory.ts +++ b/apps/account-directory/src/directory.ts @@ -62,6 +62,7 @@ type AccountRoute = | { kind: "delete"; machineKey: string }; export const DEFAULT_ONLINE_WINDOW_MS = 90_000; +const MAX_PUBKEY_CHARS = 128; const remoteJwksByUrl = new Map>(); type CallerTokenFailureReason = @@ -181,6 +182,7 @@ function parseRegisterInput(value: unknown): RegisterInput | null { || !platform || !deviceType || pubkey === undefined + || (pubkey !== null && pubkey.length > MAX_PUBKEY_CHARS) || !reachableEndpoints || typeof retainRelayEndpoints !== "boolean" ) { diff --git a/apps/account-directory/test/directory.test.ts b/apps/account-directory/test/directory.test.ts index 379fbebaa..aed2d8cde 100644 --- a/apps/account-directory/test/directory.test.ts +++ b/apps/account-directory/test/directory.test.ts @@ -1143,6 +1143,37 @@ describe("machine directory", () => { expect(await response.json()).toEqual({ error: "invalid request body" }); }); + it("stores and lists a bounded Ed25519 machine public key", async () => { + const env = makeEnv(); + const token = await mintToken(); + const pubkey = `ed25519:${Buffer.alloc(32, 4).toString("base64")}`; + const registered = await handleRequest(request( + "POST", + "/account/machines/register", + token, + { ...registerBody("machine-keyed"), pubkey }, + ), env); + expect(registered.status).toBe(200); + expect(await registered.json()).toEqual(expect.objectContaining({ pubkey })); + + const listed = await handleRequest( + request("GET", "/account/machines", token), + env, + ); + expect(await listed.json()).toMatchObject({ + machines: [expect.objectContaining({ pubkey })], + }); + + const oversized = await handleRequest(request( + "POST", + "/account/machines/register", + token, + { ...registerBody("machine-oversized-key"), pubkey: "x".repeat(129) }, + ), env); + expect(oversized.status).toBe(400); + expect(await oversized.json()).toEqual({ error: "invalid request body" }); + }); + it("returns online and offline machines, online first and newest first", async () => { const env = makeEnv(); const token = await mintToken({ sub: "user_1" }); diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index f541a89fe..26daa322b 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -24,7 +24,7 @@ Default routing for typed commands: prefer the machine brain endpoint if reachab | `\\.\pipe\ade-runtime` | ADE runtime named-pipe endpoint (Windows). | | `$ADE_HOME/projects.json` | Project catalog. | | `$ADE_HOME/personal-chats/` | Machine-owned projectless chat runtime state, hidden workspace, transcripts, and attachments. | -| `~/.ade/secrets/` | Machine credential store (`credentials.safe.enc` for desktop safeStorage, `credentials.json.enc` plus `.machine-key` for headless fallback storage, and per-store `*.lock` files). | +| `~/.ade/secrets/` | Machine credential store (`credentials.safe.enc` for desktop safeStorage, `credentials.json.enc` plus `.machine-key` for headless fallback storage, per-store `*.lock` files, and the Ed25519 `machine-identity-signing.json` used by account adoption). | | `~/.ade/bin/ade` | Bundled static runtime binary (release installs / remote uploads). | | `~/.ade/agent-skills/` | Bundled, version-locked ADE agent skills. Desktop remote bootstrap uploads this beside the remote runtime; CLI launch then re-seeds ADE-managed skills into runtime-native home skill directories. | | `~/.ade/runtime//` | Native node modules for that runtime binary. | @@ -234,6 +234,7 @@ The `sync.connectToBrain`, `sync.disconnectFromBrain`, and `sync.transferBrainTo - Desktop uses `ElectronSafeStorageCredentialStore`, which encrypts `credentials.safe.enc` with Electron `safeStorage` and migrates legacy file-encrypted stores on first read. - Headless CLI fallback uses `EncryptedFileCredentialStore`, which keeps `credentials.json.enc` encrypted with AES-256-GCM and serializes read-modify-write access with `credentials.json.enc.lock`. - Secret directories are created with mode `0700`; credential blobs, lock files, and legacy machine keys are written with mode `0600`. +- Account-machine adoption uses a long-lived Ed25519 identity in `machine-identity-signing.json`. The file is created atomically with mode `0600`; only its raw 32-byte public key is published to the account directory. `ade login`, `ade logout`, and `ade auth status` operate on the daemon-owned ADE account session in that store. Installed ADE uses the production Clerk @@ -247,6 +248,10 @@ unambiguous; otherwise the command prints the matching stable machine keys. Directory presence is a short-lived hint: a machine with a directory-verified relay endpoint remains connectable after its most recent heartbeat expires. Machines without a verified route remain listed but unavailable. +When a directory row contains `pubkey: "ed25519:"`, account +adoption verifies the host signature and seals the bearer, DPoP proof, and +returned paired secret over verified LAN, tailnet, or relay routes. Rows +without a published key retain the legacy WSS-relay-only account path. Targets and paired credentials created through `ade machines connect` belong to that account and are removed on sign-out or account switch. Direct PIN, SSH, and explicit-address pairings remain local and are not converted into account-owned diff --git a/apps/ade-cli/src/services/account/accountMachineDirectoryService.test.ts b/apps/ade-cli/src/services/account/accountMachineDirectoryService.test.ts index af9f93594..7d70c0180 100644 --- a/apps/ade-cli/src/services/account/accountMachineDirectoryService.test.ts +++ b/apps/ade-cli/src/services/account/accountMachineDirectoryService.test.ts @@ -358,7 +358,11 @@ describe("AccountMachineDirectoryService", () => { getAccessToken: async () => "account-token", }, { directoryBaseUrl: () => "https://directory.example", - fetchImpl: directoryFetch([machine({ online: false, lastSeenAt: 1 })]), + fetchImpl: directoryFetch([machine({ + online: false, + lastSeenAt: 1, + pubkey: "ed25519:directory-key", + })]), deviceName: () => "ADE Code test", pairedStore: { pairWithAccountMachine }, targetRegistry: { save }, @@ -373,7 +377,11 @@ describe("AccountMachineDirectoryService", () => { name: "Studio", }); expect(pairWithAccountMachine).toHaveBeenCalledWith( - expect.objectContaining({ machineKey: "mk-studio", online: false }), + expect.objectContaining({ + machineKey: "mk-studio", + online: false, + pubkey: "ed25519:directory-key", + }), "account-token", "ADE Code test", { diff --git a/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts index bc4342856..c531a040e 100644 --- a/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts +++ b/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts @@ -166,6 +166,33 @@ describe("account machine publisher health", () => { }); }); + it("includes the machine Ed25519 signing key in the registration payload", async () => { + let body: unknown = null; + const publicKeyRawBase64 = Buffer.alloc(32, 9).toString("base64"); + const service = createAccountMachinePublisherService({ + getAccessToken: async () => "account-token", + getAccountStatus: () => ({ + signedIn: true, + sessionReadState: "available" as const, + }), + getSnapshot: async () => snapshot(), + getMachineKey: () => "machine-studio", + getMachineIdentitySigningPublicKey: () => publicKeyRawBase64, + directoryBaseUrl: () => "https://directory.example", + fetchImpl: vi.fn(async (_input, init) => { + body = JSON.parse(String(init?.body)); + return new Response(null, { status: 204 }); + }), + }); + + await service.publishNow(); + + expect(body).toMatchObject({ + machineKey: "machine-studio", + pubkey: `ed25519:${publicKeyRawBase64}`, + }); + }); + it.each([ { name: "sync disabled", diff --git a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts index b830fbe88..d4c9f8daf 100644 --- a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts +++ b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { createSyncAccountDirectoryHealth, type AdeAccountMachineEndpoint, @@ -20,6 +21,7 @@ import { getSharedAccountAuthService, resolveOfficialAccountDirectoryBaseUrl, } from "./sharedAccountAuthService"; +import { createMachineIdentitySigningStore } from "../sync/machineIdentitySigningStore"; export const ACCOUNT_MACHINE_HEARTBEAT_MS = 30_000; export const ACCOUNT_MACHINE_RELAY_STATE_POLL_MS = 2_000; @@ -34,7 +36,7 @@ export type AccountMachineRegistration = { name: string; platform: string; deviceType: string; - pubkey: null; + pubkey: string | null; reachableEndpoints: AdeAccountMachineEndpoint[]; /** * Asks a compatible directory to retain its stored Relay endpoint when this @@ -148,6 +150,7 @@ export function buildAccountMachineRegistration(args: { machineKey: string; snapshot: AccountMachineRegistrationSnapshot; packageChannel?: string | null; + publicKeyRawBase64?: string | null; }): AccountMachineRegistration | null { const machineKey = args.machineKey.trim(); const connectInfo = args.snapshot.pairingConnectInfo; @@ -208,9 +211,9 @@ export function buildAccountMachineRegistration(args: { ), platform: identity.platform, deviceType: identity.deviceType, - // Reserved for a future machine-owned key. Account pairing currently - // proves the connecting DEVICE's DPoP key during the sync hello instead. - pubkey: null, + pubkey: args.publicKeyRawBase64?.trim() + ? `ed25519:${args.publicKeyRawBase64.trim()}` + : null, reachableEndpoints: endpoints, }; } @@ -227,6 +230,7 @@ function relayPublishStateSignature( relayBridgeValidated: snapshot.routeHealth.relay.relayBridgeValidated, relayEndToEndVerifiedAt: snapshot.routeHealth.relay.relayEndToEndVerifiedAt ?? null, relayEndToEndFailure: snapshot.routeHealth.relay.relayEndToEndFailure ?? null, + pubkey: registration.pubkey, reachableEndpoints, }); } @@ -236,6 +240,7 @@ export function createAccountMachinePublisherService(options: { getAccountStatus?: () => PublisherAccountStatus; getSnapshot: () => Promise; getMachineKey: () => string; + getMachineIdentitySigningPublicKey?: () => string; directoryBaseUrl?: () => string | null | undefined; isSyncEnabled?: () => boolean; subscribeToSignIn?: (listener: () => void) => (() => void); @@ -274,6 +279,14 @@ export function createAccountMachinePublisherService(options: { "Account-directory publishing has not started.", ); + const readSigningPublicKey = (): string | null => { + try { + return options.getMachineIdentitySigningPublicKey?.().trim() || null; + } catch { + return null; + } + }; + const observeRelayPublishState = (signature: string): boolean => { const changed = lastRelayPublishStateSignature !== signature; lastRelayPublishStateSignature = signature; @@ -444,10 +457,20 @@ export function createAccountMachinePublisherService(options: { return; } + const publicKeyRawBase64 = readSigningPublicKey(); + if (options.getMachineIdentitySigningPublicKey && !publicKeyRawBase64) { + recordOutcome("machine_key_unavailable", { + attemptAt, + skipReason: "The machine identity signing key is unavailable.", + directoryOrigin, + }); + return; + } const observedRegistration = buildAccountMachineRegistration({ machineKey, snapshot, packageChannel: process.env.ADE_PACKAGE_CHANNEL, + publicKeyRawBase64, }); if (!observedRegistration) { recordOutcome("machine_key_unavailable", { @@ -728,10 +751,13 @@ export function createAccountMachinePublisherService(options: { return; } if (!machineKey) return; + const publicKeyRawBase64 = readSigningPublicKey(); + if (options.getMachineIdentitySigningPublicKey && !publicKeyRawBase64) return; const registration = buildAccountMachineRegistration({ machineKey, snapshot, packageChannel: process.env.ADE_PACKAGE_CHANNEL, + publicKeyRawBase64, }); if (!registration) return; @@ -815,6 +841,10 @@ export function createBrainAccountMachinePublisherService(options: { projectRoots: options.projectRoots, logger: options.logger, }); + const signingStore = createMachineIdentitySigningStore({ + filePath: path.join(options.secretsDir, "machine-identity-signing.json"), + logger: options.logger, + }); return createAccountMachinePublisherService({ getAccessToken: (tokenOptions) => getSignedInAccountAccessToken( accountAuthService, @@ -832,6 +862,8 @@ export function createBrainAccountMachinePublisherService(options: { isSyncEnabled: options.isSyncEnabled, getSnapshot: options.getSnapshot, getMachineKey: options.getMachineKey, + getMachineIdentitySigningPublicKey: () => + signingStore.getOrCreate().publicKeyRawBase64, directoryBaseUrl: () => { const explicit = options.directoryBaseUrl?.(); if (explicit?.trim()) { diff --git a/apps/ade-cli/src/services/sync/machineIdentitySigningStore.test.ts b/apps/ade-cli/src/services/sync/machineIdentitySigningStore.test.ts new file mode 100644 index 000000000..8881ffb39 --- /dev/null +++ b/apps/ade-cli/src/services/sync/machineIdentitySigningStore.test.ts @@ -0,0 +1,58 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createMachineIdentitySigningStore } from "./machineIdentitySigningStore"; + +describe("machineIdentitySigningStore", () => { + it("creates once with mode 0600 and reloads the same key", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-machine-signing-")); + const filePath = path.join(dir, "machine-identity-signing.json"); + const first = createMachineIdentitySigningStore({ filePath }).getOrCreate(); + const second = createMachineIdentitySigningStore({ filePath }).getOrCreate(); + + expect(first.publicKeyRawBase64).toBe(second.publicKeyRawBase64); + expect(first.privateKeyPkcs8Base64).toBe(second.privateKeyPkcs8Base64); + expect(Buffer.from(first.publicKeyRawBase64, "base64")).toHaveLength(32); + expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); + }); + + it("shares one cached identity across consumers of the same file", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-machine-signing-shared-")); + const filePath = path.join(dir, "machine-identity-signing.json"); + const publisherStore = createMachineIdentitySigningStore({ filePath }); + const published = publisherStore.getOrCreate(); + fs.writeFileSync(filePath, "{broken", { mode: 0o600 }); + + const hostStore = createMachineIdentitySigningStore({ filePath }); + const signedByHost = hostStore.getOrCreate(); + + expect(hostStore).toBe(publisherStore); + expect(signedByHost.publicKeyRawBase64).toBe(published.publicKeyRawBase64); + expect(signedByHost.privateKeyPkcs8Base64).toBe( + published.privateKeyPkcs8Base64, + ); + }); + + it("regenerates a corrupt file and logs a warning", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-machine-signing-corrupt-")); + const filePath = path.join(dir, "machine-identity-signing.json"); + const warn = vi.fn(); + fs.writeFileSync(filePath, "{broken", { mode: 0o600 }); + + const value = createMachineIdentitySigningStore({ + filePath, + logger: { warn }, + }).getOrCreate(); + + expect(Buffer.from(value.publicKeyRawBase64, "base64")).toHaveLength(32); + expect(warn).toHaveBeenCalledWith( + "machine_identity_signing.corrupt_regenerated", + { filePath }, + ); + expect(JSON.parse(fs.readFileSync(filePath, "utf8"))).toMatchObject({ + version: 1, + publicKeyRawBase64: value.publicKeyRawBase64, + }); + }); +}); diff --git a/apps/ade-cli/src/services/sync/machineIdentitySigningStore.ts b/apps/ade-cli/src/services/sync/machineIdentitySigningStore.ts new file mode 100644 index 000000000..e7e1e5d12 --- /dev/null +++ b/apps/ade-cli/src/services/sync/machineIdentitySigningStore.ts @@ -0,0 +1,153 @@ +import { + createPrivateKey, + createPublicKey, + generateKeyPairSync, + type KeyObject, +} from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { + decodeCanonicalBase64, + rawPublicKeyFromSpki, +} from "../../../../desktop/src/shared/sync/adoptChannelCrypto"; +import { safeJsonParse, writeTextAtomic } from "../../../../desktop/src/main/services/shared/utils"; +import { resolveMachineAdeLayout } from "../projects/machineLayout"; + +export const MACHINE_IDENTITY_SIGNING_FILE_NAME = "machine-identity-signing.json"; + +export type MachineIdentitySigningRecord = { + version: 1; + createdAt: string; + publicKeyRawBase64: string; + privateKeyPkcs8Base64: string; +}; + +export type MachineIdentitySigningKey = MachineIdentitySigningRecord & { + privateKey: KeyObject; +}; + +type MachineIdentitySigningStoreLogger = { + warn(message: string, meta?: Record): void; +}; + +export type MachineIdentitySigningStore = { + filePath: string; + getOrCreate(): MachineIdentitySigningKey; +}; + +const storesByFilePath = new Map(); + +function parseRecord(raw: string): MachineIdentitySigningKey | null { + const value = safeJsonParse | null>(raw, null); + if ( + value?.version !== 1 + || typeof value.createdAt !== "string" + || !Number.isFinite(Date.parse(value.createdAt)) + ) { + return null; + } + const publicKeyRaw = decodeCanonicalBase64(value.publicKeyRawBase64); + const privateKeyPkcs8 = decodeCanonicalBase64(value.privateKeyPkcs8Base64); + if (!publicKeyRaw || publicKeyRaw.byteLength !== 32 || !privateKeyPkcs8) return null; + try { + const privateKey = createPrivateKey({ + key: privateKeyPkcs8, + format: "der", + type: "pkcs8", + }); + if ( + privateKey.asymmetricKeyType !== "ed25519" + || !rawPublicKeyFromSpki(createPublicKey(privateKey)).equals(publicKeyRaw) + ) { + return null; + } + return { + version: 1, + createdAt: value.createdAt, + publicKeyRawBase64: value.publicKeyRawBase64!, + privateKeyPkcs8Base64: value.privateKeyPkcs8Base64!, + privateKey, + }; + } catch { + return null; + } +} + +function createRecord(): MachineIdentitySigningKey { + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + return { + version: 1, + createdAt: new Date().toISOString(), + publicKeyRawBase64: rawPublicKeyFromSpki(publicKey).toString("base64"), + privateKeyPkcs8Base64: privateKey.export({ + format: "der", + type: "pkcs8", + }).toString("base64"), + privateKey, + }; +} + +export function createMachineIdentitySigningStore(options: { + filePath?: string; + logger?: MachineIdentitySigningStoreLogger; +} = {}): MachineIdentitySigningStore { + const filePath = path.resolve( + options.filePath + ?? path.join(resolveMachineAdeLayout().secretsDir, MACHINE_IDENTITY_SIGNING_FILE_NAME), + ); + const existing = storesByFilePath.get(filePath); + if (existing) return existing; + let cached: MachineIdentitySigningKey | null = null; + + const persist = (value: MachineIdentitySigningKey): void => { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); + const serialized: MachineIdentitySigningRecord = { + version: value.version, + createdAt: value.createdAt, + publicKeyRawBase64: value.publicKeyRawBase64, + privateKeyPkcs8Base64: value.privateKeyPkcs8Base64, + }; + writeTextAtomic(filePath, `${JSON.stringify(serialized, null, 2)}\n`, { mode: 0o600 }); + try { + fs.chmodSync(filePath, 0o600); + } catch { + // Best effort on filesystems that do not expose POSIX modes. + } + }; + + const store: MachineIdentitySigningStore = { + filePath, + getOrCreate(): MachineIdentitySigningKey { + if (cached) return cached; + let corrupt = false; + try { + if (fs.existsSync(filePath)) { + const parsed = parseRecord(fs.readFileSync(filePath, "utf8")); + if (parsed) { + cached = parsed; + try { + fs.chmodSync(filePath, 0o600); + } catch { + // Best effort on filesystems that do not expose POSIX modes. + } + return parsed; + } + corrupt = true; + } + } catch { + corrupt = true; + } + if (corrupt) { + options.logger?.warn("machine_identity_signing.corrupt_regenerated", { + filePath, + }); + } + const created = createRecord(); + persist(created); + cached = created; + return created; + }, + }; + storesByFilePath.set(filePath, store); + return store; +} diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index e9351bfc9..d638ac3da 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -72,6 +72,17 @@ import { import { encodeSyncEnvelope, parseSyncEnvelope, PEER_BACKPRESSURE_BYTES, SYNC_CHUNKED_ENVELOPES_CAPABILITY, SYNC_RUNTIME_ONLY_CAPABILITY, wsDataToText, type ParsedSyncEnvelope } from "./syncProtocol"; import { EncryptedFileCredentialStore } from "../credentials/credentialStore"; import { verifyClerkAccountAttestation } from "../account/accountAttestationVerifier"; +import { + buildAdoptChallengeSignatureInput, + buildAdoptHelloAad, + buildAdoptHelloOkAad, + deriveAdoptSessionKey, + generateX25519EphemeralKeyPair, + seal, + unseal, + verifyEd25519, +} from "../../../../desktop/src/shared/sync/adoptChannelCrypto"; +import { createMachineIdentitySigningStore } from "./machineIdentitySigningStore"; // The sync host now binds to all interfaces (0.0.0.0) by default so phones on // the LAN can reach it. These tests assert the LOOPBACK-only posture (no LAN @@ -1763,6 +1774,307 @@ describe("sync host account authentication", () => { })); } + it("authenticates a signed sealed account hello over direct transport and seals hello_ok", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const secretsDir = path.join(projectRoot, ".ade", "secrets"); + const pinStore = createSyncPinStore({ filePath: path.join(secretsDir, "sync-pin.json") }); + const pairingSecretsPath = path.join(secretsDir, "sync-paired-devices.json"); + const pairingStore = createSyncPairingStore({ filePath: pairingSecretsPath, pinStore }); + const machineIdentitySigningStore = createMachineIdentitySigningStore({ + filePath: path.join(secretsDir, "machine-identity-signing.json"), + }); + const baseArgs = createHostArgs(projectRoot, []); + let adoptNow = Date.now(); + const host = createSyncHostService({ + ...baseArgs, + ...accountDependencies(), + pinStore, + pairingStore, + pairingSecretsPath, + machineIdentitySigningStore, + adoptNow: () => adoptNow, + discoveryEnabled: false, + deviceRegistryService: { + ...baseArgs.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + } as unknown as Parameters[0]); + const clients: Array>> = []; + const issueChallenge = async ( + client: Awaited>, + requestId: string, + ) => { + const nonce = Buffer.alloc(32, requestId.length); + const ephemeral = generateX25519EphemeralKeyPair(); + client.ws.send(encodeSyncEnvelope({ + type: "account_challenge", + requestId, + payload: { + v: 1, + nonce: nonce.toString("base64"), + clientEphemeralPublicKey: ephemeral.publicKeyRaw.toString("base64"), + }, + })); + const envelope = await waitForValue( + () => client.envelopes.find((entry) => + entry.type === "account_challenge_ok" && entry.requestId === requestId + ), + `${requestId} challenge`, + ); + const payload = envelope.payload as { + v: number; + hostDeviceId: string; + ts: number; + hostEphemeralPublicKey: string; + signature: string; + }; + const canonical = buildAdoptChallengeSignatureInput({ + hostDeviceId: payload.hostDeviceId, + nonce: nonce.toString("base64"), + clientEphemeralPublicKey: ephemeral.publicKeyRaw.toString("base64"), + hostEphemeralPublicKey: payload.hostEphemeralPublicKey, + ts: payload.ts, + }); + expect(verifyEd25519( + Buffer.from( + machineIdentitySigningStore.getOrCreate().publicKeyRawBase64, + "base64", + ), + canonical, + Buffer.from(payload.signature, "base64"), + )).toBe(true); + return { + payload, + sessionKey: deriveAdoptSessionKey({ + privateKey: ephemeral.privateKey, + peerPublicKeyRaw: Buffer.from(payload.hostEphemeralPublicKey, "base64"), + nonce, + }), + }; + }; + + try { + const port = await host.waitUntilListening(); + const client = await openAccountClient(port); + clients.push(client); + const challenge = await issueChallenge(client, "sealed-direct"); + expect(challenge.payload).toMatchObject({ + v: 1, + hostDeviceId: "host-device-1", + ts: adoptNow, + }); + const duplicateEphemeral = generateX25519EphemeralKeyPair(); + client.ws.send(encodeSyncEnvelope({ + type: "account_challenge", + requestId: "duplicate-challenge", + payload: { + v: 1, + nonce: Buffer.alloc(32, 9).toString("base64"), + clientEphemeralPublicKey: + duplicateEphemeral.publicKeyRaw.toString("base64"), + }, + })); + const duplicateChallenge = await waitForValue( + () => client.envelopes.find((entry) => + entry.type === "account_challenge_error" + && entry.requestId === "duplicate-challenge" + ), + "duplicate challenge rejection", + ); + expect(duplicateChallenge.payload).toMatchObject({ + message: expect.stringMatching(/already active/i), + }); + + const accountToken = await mintAccountToken(); + const dpopKey = makeDpopKeyPair(); + const peer = { + deviceId: "sealed-direct-device", + deviceName: "Sealed direct device", + platform: "macOS", + deviceType: "desktop", + siteId: "sealed-direct-site", + dbVersion: 0, + } satisfies SyncPeerMetadata; + const accountAuth = { + deviceId: peer.deviceId, + accountToken, + dpop: signAccountDpop({ + privateKey: dpopKey.privateKey, + publicKeyX963: dpopKey.publicKeyX963, + deviceId: peer.deviceId, + accountToken, + }), + }; + client.ws.send(encodeSyncEnvelope({ + type: "hello", + requestId: "sealed-hello", + payload: { + peer, + auth: { + kind: "account_sealed", + v: 1, + deviceId: peer.deviceId, + sealed: seal( + challenge.sessionKey, + buildAdoptHelloAad(challenge.payload.hostDeviceId, peer.deviceId), + Buffer.from(JSON.stringify(accountAuth)), + ), + }, + }, + })); + const sealedHelloOk = await waitForValue( + () => client.envelopes.find((entry) => + entry.type === "hello_ok" && entry.requestId === "sealed-hello" + ), + "sealed direct hello_ok", + ); + expect(sealedHelloOk.payload).toMatchObject({ + v: 1, + sealed: expect.any(String), + }); + const openedHelloOk = JSON.parse(unseal( + challenge.sessionKey, + buildAdoptHelloOkAad(challenge.payload.hostDeviceId, peer.deviceId), + (sealedHelloOk.payload as { sealed: string }).sealed, + ).toString("utf8")) as { + brain: { deviceId: string }; + connectionTransport: string; + accountPairing: { deviceId: string; secret: string }; + }; + expect(openedHelloOk).toMatchObject({ + brain: { deviceId: "host-device-1" }, + connectionTransport: "direct", + accountPairing: { + deviceId: peer.deviceId, + secret: expect.any(String), + }, + }); + expect(pairingStore.authenticate( + peer.deviceId, + openedHelloOk.accountPairing.secret, + )).toBe(true); + expect(baseArgs.logger.warn.mock.calls.some( + ([message]) => message === "sync_host.account_auth_requires_relay", + )).toBe(false); + + const plainClient = await openAccountClient(port); + clients.push(plainClient); + const plainPeer = { + ...peer, + deviceId: "plain-direct-device", + siteId: "plain-direct-site", + }; + sendAccountHello({ + ws: plainClient.ws, + peer: plainPeer, + accountToken, + dpop: signAccountDpop({ + privateKey: dpopKey.privateKey, + publicKeyX963: dpopKey.publicKeyX963, + deviceId: plainPeer.deviceId, + accountToken, + }), + }); + await waitForValue( + () => plainClient.envelopes.find((entry) => entry.type === "hello_error"), + "plain direct account rejection", + ); + expect(baseArgs.logger.warn.mock.calls.some( + ([message]) => message === "sync_host.account_auth_requires_relay", + )).toBe(true); + + const noChallengeClient = await openAccountClient(port); + clients.push(noChallengeClient); + noChallengeClient.ws.send(encodeSyncEnvelope({ + type: "hello", + requestId: "missing-challenge", + payload: { + peer: { ...peer, deviceId: "missing-challenge-device" }, + auth: { + kind: "account_sealed", + v: 1, + deviceId: "missing-challenge-device", + sealed: Buffer.alloc(28).toString("base64"), + }, + }, + })); + const missingChallenge = await waitForValue( + () => noChallengeClient.envelopes.find((entry) => + entry.type === "hello_error" && entry.requestId === "missing-challenge" + ), + "missing challenge rejection", + ); + expect(missingChallenge.payload).toMatchObject({ + code: "invalid_hello", + message: expect.stringMatching(/challenge is required/i), + }); + + const expiredClient = await openAccountClient(port); + clients.push(expiredClient); + await issueChallenge(expiredClient, "expired-challenge"); + adoptNow += 60_001; + expiredClient.ws.send(encodeSyncEnvelope({ + type: "hello", + requestId: "expired-hello", + payload: { + peer: { ...peer, deviceId: "expired-challenge-device" }, + auth: { + kind: "account_sealed", + v: 1, + deviceId: "expired-challenge-device", + sealed: Buffer.alloc(28).toString("base64"), + }, + }, + })); + const expired = await waitForValue( + () => expiredClient.envelopes.find((entry) => + entry.type === "hello_error" && entry.requestId === "expired-hello" + ), + "expired challenge rejection", + ); + expect(expired.payload).toMatchObject({ + code: "invalid_hello", + message: expect.stringMatching(/expired/i), + }); + + // The successful sealed hello above cleared its issuance budget. The + // expired challenge counts as one new unauthenticated signing operation; + // four more are admitted, and the following connection is throttled. + for (let index = 0; index < 4; index += 1) { + const rateClient = await openAccountClient(port); + clients.push(rateClient); + await issueChallenge(rateClient, `rate-${index}`); + } + const throttledClient = await openAccountClient(port); + clients.push(throttledClient); + const throttledEphemeral = generateX25519EphemeralKeyPair(); + throttledClient.ws.send(encodeSyncEnvelope({ + type: "account_challenge", + requestId: "rate-throttled", + payload: { + v: 1, + nonce: Buffer.alloc(32, 10).toString("base64"), + clientEphemeralPublicKey: + throttledEphemeral.publicKeyRaw.toString("base64"), + }, + })); + const throttled = await waitForValue( + () => throttledClient.envelopes.find((entry) => + entry.type === "account_challenge_error" + && entry.requestId === "rate-throttled" + ), + "challenge issuance throttle", + ); + expect(throttled.payload).toMatchObject({ + message: expect.stringMatching(/too many failed authentication attempts/i), + }); + } finally { + for (const client of clients) client.ws.close(); + await host.dispose(); + cleanup(); + } + }); + it("commits exactly one concurrent account hello winner for the same connection attempt", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); const secretsDir = path.join(projectRoot, ".ade", "secrets"); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 955793ea6..7ad4e7f22 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -148,6 +148,22 @@ import { } from "./syncPairedChannelService"; import type { SyncPinStore } from "./syncPinStore"; import type { SyncRuntimeNameStore } from "./syncRuntimeNameStore"; +import { + ADOPT_CHANNEL_CHALLENGE_TTL_MS, + buildAdoptChallengeSignatureInput, + buildAdoptHelloAad, + buildAdoptHelloOkAad, + decodeCanonicalBase64, + deriveAdoptSessionKey, + generateX25519EphemeralKeyPair, + seal, + signEd25519, + unseal, +} from "../../../../desktop/src/shared/sync/adoptChannelCrypto"; +import { + createMachineIdentitySigningStore, + type MachineIdentitySigningStore, +} from "./machineIdentitySigningStore"; import { DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, DEFAULT_SYNC_HOST_PORT, DEFAULT_SYNC_MAX_FRAME_BYTES, encodeSyncEnvelope, encodeSyncEnvelopeFrames, mapPlatform, parseSyncEnvelope, SYNC_CHUNKED_ENVELOPES_CAPABILITY, SYNC_RUNTIME_ONLY_CAPABILITY, wsDataToText, type ParsedSyncEnvelope } from "./syncProtocol"; import { resolveTailscaleCliPath } from "./resolveTailscaleCliPath"; import { createSyncRemoteCommandService, type SyncRemoteCommandService } from "./syncRemoteCommandService"; @@ -507,6 +523,12 @@ type PeerState = { remotePort: number | null; transportOrigin: SyncTransportOrigin; relayAuthorization: RelayAuthorizationLifecycle | null; + adoptChallenge: { + sessionKey: Buffer; + nonce: string; + hostDeviceId: string; + expiresAtMs: number; + } | null; subscribedSessionIds: Set; pendingTerminalSnapshots: Map; nextTerminalSnapshotGeneration: number; @@ -766,6 +788,10 @@ type SyncHostServiceArgs = { pinStore: SyncPinStore; /** Test/integration seam for serialized pairing commit verification. */ pairingStore?: ReturnType; + /** Test seam; production lazily creates the machine-wide signing key. */ + machineIdentitySigningStore?: MachineIdentitySigningStore; + /** Test seam for adoption challenge timestamps and expiry. */ + adoptNow?: () => number; accountAuthService?: Pick; getAccountAttestationConfig?: () => AccountAttestationConfig; /** Test seam for controlling an in-flight account attestation. */ @@ -1294,6 +1320,12 @@ function parseHelloPayload(payload: unknown): SyncHelloPayload | null { && (typeof normalizedAuth.dpop !== "object" || Array.isArray(normalizedAuth.dpop)) ) return null; if (normalizedAuth.runtimeHostGrant != null && !toOptionalString(normalizedAuth.runtimeHostGrant)) return null; + } else if (normalizedAuth.kind === "account_sealed") { + if ( + normalizedAuth.v !== 1 + || !toOptionalString(normalizedAuth.deviceId) + || !toOptionalString(normalizedAuth.sealed) + ) return null; } else { return null; } @@ -1346,6 +1378,64 @@ function parseHelloPayload(payload: unknown): SyncHelloPayload | null { }; } +type ParsedAccountChallenge = { + nonce: string; + nonceBytes: Buffer; + clientEphemeralPublicKey: string; + clientEphemeralPublicKeyBytes: Buffer; +}; + +function parseAccountChallengePayload(payload: unknown): ParsedAccountChallenge | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const value = payload as Record; + if (value.v !== 1) return null; + const nonceBytes = decodeCanonicalBase64(value.nonce, 32); + const clientEphemeralPublicKeyBytes = decodeCanonicalBase64( + value.clientEphemeralPublicKey, + 32, + ); + if ( + !nonceBytes + || !clientEphemeralPublicKeyBytes + || typeof value.nonce !== "string" + || typeof value.clientEphemeralPublicKey !== "string" + ) { + return null; + } + return { + nonce: value.nonce, + nonceBytes, + clientEphemeralPublicKey: value.clientEphemeralPublicKey, + clientEphemeralPublicKeyBytes, + }; +} + +function parseUnsealedAccountAuth(value: unknown): Extract< + SyncHelloPayload["auth"], + { kind: "account" } +> | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const account = value as Record; + const deviceId = toOptionalString(account.deviceId); + const accountToken = toOptionalString(account.accountToken); + if (!deviceId || !accountToken) return null; + if ( + account.dpop != null + && (typeof account.dpop !== "object" || Array.isArray(account.dpop)) + ) return null; + const runtimeHostGrant = account.runtimeHostGrant == null + ? null + : toOptionalString(account.runtimeHostGrant); + if (account.runtimeHostGrant != null && !runtimeHostGrant) return null; + return { + kind: "account", + deviceId, + accountToken, + dpop: account.dpop as SyncDpopProof | null | undefined, + ...(runtimeHostGrant ? { runtimeHostGrant } : {}), + }; +} + function safeStringEquals(expected: string, actual: string): boolean { const expectedBuffer = Buffer.from(expected, "utf8"); const actualBuffer = Buffer.from(actual, "utf8"); @@ -1872,6 +1962,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { filePath: pairingSecretsPath, pinStore: args.pinStore, }); + const machineIdentitySigningStore = + args.machineIdentitySigningStore + ?? createMachineIdentitySigningStore({ logger: args.logger }); + const adoptNow = args.adoptNow ?? Date.now; const dpopNonceCache = createSyncDpopNonceCache(); const remoteCommandService = args.remoteCommandService ?? createSyncRemoteCommandService({ db: args.db, @@ -2284,6 +2378,12 @@ export function createSyncHostService(args: SyncHostServiceArgs) { type PairFailureEntry = { count: number; cooldownUntilMs: number; updatedAtMs: number }; const pairFailures = new Map(); const globalPairFailures: PairFailureEntry = { count: 0, cooldownUntilMs: 0, updatedAtMs: 0 }; + const adoptChallengeIssuances = new Map(); + const globalAdoptChallengeIssuances: PairFailureEntry = { + count: 0, + cooldownUntilMs: 0, + updatedAtMs: 0, + }; const resetPairFailureEntry = (entry: PairFailureEntry): void => { entry.count = 0; entry.cooldownUntilMs = 0; @@ -2338,6 +2438,46 @@ export function createSyncHostService(args: SyncHostServiceArgs) { resetPairFailureEntry(globalPairFailures); if (ip) pairFailures.delete(ip); }; + const pruneExpiredAdoptChallengeIssuances = (now = Date.now()): void => { + for (const [ip, entry] of adoptChallengeIssuances) { + if (isPairFailureEntryExpired(entry, now)) { + adoptChallengeIssuances.delete(ip); + } + } + if (isPairFailureEntryExpired(globalAdoptChallengeIssuances, now)) { + resetPairFailureEntry(globalAdoptChallengeIssuances); + } + }; + const registerAdoptChallengeIssuance = (ip: string | null): void => { + const now = Date.now(); + pruneExpiredAdoptChallengeIssuances(now); + incrementPairFailureEntry(globalAdoptChallengeIssuances, now); + if (ip) { + const entry = adoptChallengeIssuances.get(ip) + ?? { count: 0, cooldownUntilMs: 0, updatedAtMs: now }; + incrementPairFailureEntry(entry, now); + adoptChallengeIssuances.set(ip, entry); + } + }; + const adoptChallengeCooldownMsRemaining = (ip: string | null): number => { + const now = Date.now(); + pruneExpiredAdoptChallengeIssuances(now); + const globalRemaining = Math.max( + 0, + globalAdoptChallengeIssuances.cooldownUntilMs - now, + ); + const ipEntry = ip ? adoptChallengeIssuances.get(ip) ?? null : null; + const ipRemaining = ipEntry + ? Math.max(0, ipEntry.cooldownUntilMs - now) + : 0; + return Math.max(globalRemaining, ipRemaining); + }; + const clearAdoptChallengeIssuancesAfterSuccessfulAuth = ( + ip: string | null, + ): void => { + resetPairFailureEntry(globalAdoptChallengeIssuances); + if (ip) adoptChallengeIssuances.delete(ip); + }; const normalizeLaneId = (laneId: string | null | undefined): string | null => { const normalized = toOptionalString(laneId); @@ -3009,6 +3149,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { remotePort, transportOrigin, relayAuthorization: null, + adoptChallenge: null, subscribedSessionIds: new Set(), pendingTerminalSnapshots: new Map(), nextTerminalSnapshotGeneration: 0, @@ -6054,10 +6195,14 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const heartbeatAwaitedAt = markPeerMessageSeen(peer); if (!peer.authenticated) { - if (envelope.type !== "hello" && envelope.type !== "pairing_request") { + if ( + envelope.type !== "hello" + && envelope.type !== "pairing_request" + && envelope.type !== "account_challenge" + ) { send(peer.ws, "hello_error", { code: "invalid_hello", - message: "Authenticate with hello or pairing_request before sending other messages.", + message: "Authenticate with hello, pairing_request, or account_challenge before sending other messages.", }, envelope.requestId); try { peer.ws.close(4003, "Authentication required"); @@ -6066,6 +6211,84 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } return; } + if (envelope.type === "account_challenge") { + const cooldownMs = Math.max( + pairingCooldownMsRemaining(peer.remoteAddress), + adoptChallengeCooldownMsRemaining(peer.remoteAddress), + ); + if (cooldownMs > 0) { + const minutes = Math.ceil(cooldownMs / 60_000); + send(peer.ws, "account_challenge_error", { + message: `Too many failed authentication attempts. Try again in ${minutes} minute${minutes === 1 ? "" : "s"}.`, + }, envelope.requestId); + return; + } + if (peer.adoptChallenge) { + registerAdoptChallengeIssuance(peer.remoteAddress); + send(peer.ws, "account_challenge_error", { + message: "An account adoption challenge is already active on this connection.", + }, envelope.requestId); + return; + } + const challenge = envelope.requestId + ? parseAccountChallengePayload(envelope.payload) + : null; + if (!challenge) { + peer.adoptChallenge = null; + registerAdoptChallengeIssuance(peer.remoteAddress); + send(peer.ws, "account_challenge_error", { + message: "Invalid account adoption challenge.", + }, envelope.requestId); + return; + } + // Count every unauthenticated signing operation, including requests + // whose peer key causes X25519 to reject. A successful sealed hello is + // the only operation that clears this per-IP/global issuance budget. + registerAdoptChallengeIssuance(peer.remoteAddress); + try { + const identity = machineIdentitySigningStore.getOrCreate(); + const hostDeviceId = readBrainMetadata().deviceId; + const hostEphemeral = generateX25519EphemeralKeyPair(); + const hostEphemeralPublicKey = + hostEphemeral.publicKeyRaw.toString("base64"); + const ts = adoptNow(); + const canonical = buildAdoptChallengeSignatureInput({ + hostDeviceId, + nonce: challenge.nonce, + clientEphemeralPublicKey: challenge.clientEphemeralPublicKey, + hostEphemeralPublicKey, + ts, + }); + const sessionKey = deriveAdoptSessionKey({ + privateKey: hostEphemeral.privateKey, + peerPublicKeyRaw: challenge.clientEphemeralPublicKeyBytes, + nonce: challenge.nonceBytes, + }); + peer.adoptChallenge = { + sessionKey, + nonce: challenge.nonce, + hostDeviceId, + expiresAtMs: ts + ADOPT_CHANNEL_CHALLENGE_TTL_MS, + }; + send(peer.ws, "account_challenge_ok", { + v: 1, + hostDeviceId, + ts, + hostEphemeralPublicKey, + signature: signEd25519(identity.privateKey, canonical).toString("base64"), + }, envelope.requestId); + } catch (error) { + peer.adoptChallenge = null; + args.logger.warn("sync_host.account_challenge_failed", { + remoteAddress: peer.remoteAddress, + reason: error instanceof Error ? error.message : String(error), + }); + send(peer.ws, "account_challenge_error", { + message: "Host identity is unavailable.", + }, envelope.requestId); + } + return; + } if (envelope.type === "pairing_request") { const pairing = parsePairingRequestPayload(envelope.payload); if (!pairing) { @@ -6212,6 +6435,72 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } return; } + let sealedAdoption: { + sessionKey: Buffer; + hostDeviceId: string; + clientDeviceId: string; + } | null = null; + if (hello.auth?.kind === "account_sealed") { + const sealedAuth = hello.auth; + const challenge = peer.adoptChallenge; + // A challenge is single-use even when unsealing or validation fails. + peer.adoptChallenge = null; + const rejectSealedHello = (message: string): void => { + send(peer.ws, "hello_error", { + code: "invalid_hello", + message, + } satisfies SyncHelloErrorPayload, envelope.requestId); + try { + peer.ws.close(4003, "Invalid sealed account hello"); + } catch { + // ignore close failures + } + }; + if (!challenge) { + rejectSealedHello("A completed account challenge is required."); + return; + } + if (challenge.expiresAtMs < adoptNow()) { + rejectSealedHello("The account challenge expired."); + return; + } + if ( + sealedAuth.deviceId !== hello.peer.deviceId + || !sealedAuth.sealed + ) { + rejectSealedHello("The sealed account hello is invalid."); + return; + } + try { + const plaintext = unseal( + challenge.sessionKey, + buildAdoptHelloAad( + challenge.hostDeviceId, + sealedAuth.deviceId, + ), + sealedAuth.sealed, + ); + const accountAuth = parseUnsealedAccountAuth( + JSON.parse(plaintext.toString("utf8")), + ); + if ( + !accountAuth + || accountAuth.deviceId !== sealedAuth.deviceId + ) { + rejectSealedHello("The sealed account credentials are invalid."); + return; + } + hello.auth = accountAuth; + sealedAdoption = { + sessionKey: challenge.sessionKey, + hostDeviceId: challenge.hostDeviceId, + clientDeviceId: sealedAuth.deviceId, + }; + } catch { + rejectSealedHello("The sealed account credentials could not be opened."); + return; + } + } let authenticatedPairingRecord: SyncPairingRecord | null = null; let accountPairing: { deviceId: string; secret: string } | null = null; let relayAccountOwnerUserId: string | null = null; @@ -6367,7 +6656,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // Account bearer credentials must never traverse or authenticate a // plaintext direct sync route. Existing devices reconnect directly // with their stored paired secret + DPoP key instead. - if (peer.transportOrigin !== "relay-bridge") { + if (!sealedAdoption && peer.transportOrigin !== "relay-bridge") { args.logger.warn("sync_host.account_auth_requires_relay", { deviceId: accountAuth.deviceId, transportOrigin: peer.transportOrigin, @@ -6516,6 +6805,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return; } peer.authenticated = true; + if (sealedAdoption) { + clearAdoptChallengeIssuancesAfterSuccessfulAuth(peer.remoteAddress); + } + peer.adoptChallenge = null; clearPeerAuthTimeout(peer); peer.metadata = hello.peer; const auth = hello.auth ?? { kind: "bootstrap", token: "" }; @@ -6561,7 +6854,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { && args.projectCatalogProvider.cloneProject && args.projectCatalogProvider.listMyGitHubRepos, ); - send(peer.ws, "hello_ok", buildSyncHostHelloOkPayload({ + const helloOkPayload = buildSyncHostHelloOkPayload({ peer: hello.peer, brain: readBrainMetadata(), serverDbVersion, @@ -6587,7 +6880,22 @@ export function createSyncHostService(args: SyncHostServiceArgs) { runtimeChannelEnabled: isRecordBackedSyncAuthKind(auth.kind) && isRuntimeHostPairingRecord(authenticatedPairingRecord), accountPairing, - }), envelope.requestId); + }); + if (sealedAdoption) { + send(peer.ws, "hello_ok", { + v: 1, + sealed: seal( + sealedAdoption.sessionKey, + buildAdoptHelloOkAad( + sealedAdoption.hostDeviceId, + sealedAdoption.clientDeviceId, + ), + Buffer.from(JSON.stringify(helloOkPayload), "utf8"), + ), + }, envelope.requestId); + } else { + send(peer.ws, "hello_ok", helloOkPayload, envelope.requestId); + } args.onStateChanged?.(); // Catch-up is background work. The periodic poll starts it after the // serialized hello queue has had a chance to admit subscriptions. diff --git a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts index 0380edef2..1a1bc09e4 100644 --- a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import { generateKeyPairSync } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -14,6 +15,17 @@ import type { AdeAccountMachine } from "../../../shared/types/account"; import type { DesktopPairedMachineCredentials } from "../../../shared/types/pairedRuntime"; import { encodeSyncEnvelope, parseSyncEnvelope, wsDataToText } from "../sync/syncProtocol"; import { DesktopPairedMachineStore } from "./syncPairedMachineStore"; +import { + buildAdoptChallengeSignatureInput, + buildAdoptHelloAad, + buildAdoptHelloOkAad, + deriveAdoptSessionKey, + generateX25519EphemeralKeyPair, + rawPublicKeyFromSpki, + seal, + signEd25519, + unseal, +} from "../../../shared/sync/adoptChannelCrypto"; const originalAdeHome = process.env.ADE_HOME; @@ -447,6 +459,229 @@ describe("DesktopPairedMachineStore", () => { expect(fs.readFileSync(store.path, "utf8")).not.toContain("clerk-access-token"); }); + it("verifies a directory signing key before sending a sealed account hello over a direct route", async () => { + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-desktop-sealed-pairing-")); + process.env.ADE_HOME = adeHome; + const signing = generateKeyPairSync("ed25519"); + const hostSigningPublicKey = rawPublicKeyFromSpki(signing.publicKey); + const sentTypes: string[] = []; + let hostSessionKey: Buffer | null = null; + const machine: AdeAccountMachine = { + machineKey: "machine-sealed", + deviceId: "host-sealed", + name: "Sealed Studio", + platform: "macOS", + deviceType: "desktop", + pubkey: `ed25519:${hostSigningPublicKey.toString("base64")}`, + online: true, + lastSeenAt: Date.now(), + reachableEndpoints: [ + { kind: "lan", host: "sealed-studio.local", port: 8787 }, + { kind: "relay", url: "wss://relay.example/connect/machine-sealed" }, + ], + }; + const createWebSocket = () => new FakeWebSocket((text, ws) => { + const envelope = parseSyncEnvelope(wsDataToText(text)); + sentTypes.push(envelope.type); + if (envelope.type === "account_challenge") { + const request = envelope.payload as { + nonce: string; + clientEphemeralPublicKey: string; + }; + const hostEphemeral = generateX25519EphemeralKeyPair(); + const hostEphemeralPublicKey = hostEphemeral.publicKeyRaw.toString("base64"); + const ts = Date.now(); + const canonical = buildAdoptChallengeSignatureInput({ + hostDeviceId: "host-sealed", + nonce: request.nonce, + clientEphemeralPublicKey: request.clientEphemeralPublicKey, + hostEphemeralPublicKey, + ts, + }); + hostSessionKey = deriveAdoptSessionKey({ + privateKey: hostEphemeral.privateKey, + peerPublicKeyRaw: Buffer.from(request.clientEphemeralPublicKey, "base64"), + nonce: Buffer.from(request.nonce, "base64"), + }); + ws.receive(encodeSyncEnvelope({ + type: "account_challenge_ok", + requestId: envelope.requestId, + payload: { + v: 1, + hostDeviceId: "host-sealed", + ts, + hostEphemeralPublicKey, + signature: signEd25519(signing.privateKey, canonical).toString("base64"), + }, + })); + return; + } + if (envelope.type !== "hello" || !hostSessionKey) return; + const payload = envelope.payload as { + peer: { deviceId: string }; + auth: { + kind: string; + deviceId: string; + sealed: string; + }; + }; + expect(payload.auth.kind).toBe("account_sealed"); + expect(text).not.toContain("clerk-sealed-token"); + const accountAuth = JSON.parse(unseal( + hostSessionKey, + buildAdoptHelloAad("host-sealed", payload.auth.deviceId), + payload.auth.sealed, + ).toString("utf8")) as { + deviceId: string; + accountToken: string; + dpop: SyncDpopProof; + }; + expect(accountAuth).toMatchObject({ + deviceId: payload.auth.deviceId, + accountToken: "clerk-sealed-token", + dpop: { publicKey: expect.any(String) }, + }); + const helloOk = { + peer: payload.peer, + brain: { + deviceId: "host-sealed", + deviceName: "Sealed Studio", + platform: "macOS", + deviceType: "desktop", + siteId: "host-sealed-site", + dbVersion: 0, + }, + serverDbVersion: 0, + heartbeatIntervalMs: 5_000, + pollIntervalMs: 1_500, + connectionTransport: "direct", + features: { rpcChannel: true, portForward: true }, + accountPairing: { + deviceId: payload.auth.deviceId, + secret: "sealed-paired-secret", + }, + }; + ws.receive(encodeSyncEnvelope({ + type: "hello_ok", + requestId: envelope.requestId, + payload: { + v: 1, + sealed: seal( + hostSessionKey, + buildAdoptHelloOkAad("host-sealed", payload.auth.deviceId), + Buffer.from(JSON.stringify(helloOk)), + ), + }, + })); + }) as unknown as WebSocket; + + const adopted = await new DesktopPairedMachineStore().pairWithAccountMachine( + machine, + "clerk-sealed-token", + "Sealed client", + { + accountOwnerUserId: "account-user-sealed", + pairingTimeoutMs: 2_000, + createWebSocket, + relayBaseUrls: ["https://relay.example"], + }, + ); + + expect(sentTypes).toEqual(["account_challenge", "hello"]); + expect(adopted.hostIdentity.deviceId).toBe("host-sealed"); + expect(adopted.secret).toBe("sealed-paired-secret"); + expect(adopted.endpoints[0]).toBe("ws://sealed-studio.local:8787/"); + }); + + it.each([ + { name: "bad signature", mode: "bad_signature" as const }, + { name: "device id mismatch", mode: "device_mismatch" as const }, + { name: "stale timestamp", mode: "stale_timestamp" as const }, + { name: "challenge error", mode: "challenge_error" as const }, + ])("aborts before hello when signed host verification fails: $name", async ({ mode }) => { + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-desktop-bad-host-")); + process.env.ADE_HOME = adeHome; + const signing = generateKeyPairSync("ed25519"); + const sentTypes: string[] = []; + const machine: AdeAccountMachine = { + machineKey: `machine-${mode}`, + deviceId: "expected-host", + name: "Expected host", + platform: "macOS", + deviceType: "desktop", + pubkey: `ed25519:${rawPublicKeyFromSpki(signing.publicKey).toString("base64")}`, + online: true, + lastSeenAt: Date.now(), + reachableEndpoints: [ + { kind: "relay", url: `wss://relay.example/connect/machine-${mode}` }, + ], + }; + + const pairing = new DesktopPairedMachineStore().pairWithAccountMachine( + machine, + "must-not-leave-client", + "Laptop", + { + accountOwnerUserId: "account-user", + pairingTimeoutMs: 500, + relayBaseUrls: ["https://relay.example"], + createWebSocket: () => new FakeWebSocket((text, ws) => { + const envelope = parseSyncEnvelope(wsDataToText(text)); + sentTypes.push(envelope.type); + if (envelope.type !== "account_challenge") return; + if (mode === "challenge_error") { + ws.receive(encodeSyncEnvelope({ + type: "account_challenge_error", + requestId: envelope.requestId, + payload: { message: "unsupported" }, + })); + return; + } + const request = envelope.payload as { + nonce: string; + clientEphemeralPublicKey: string; + }; + const hostEphemeral = generateX25519EphemeralKeyPair(); + const hostDeviceId = mode === "device_mismatch" + ? "impersonated-host" + : "expected-host"; + const ts = mode === "stale_timestamp" + ? Date.now() - 120_001 + : Date.now(); + const hostEphemeralPublicKey = hostEphemeral.publicKeyRaw.toString("base64"); + const canonical = buildAdoptChallengeSignatureInput({ + hostDeviceId, + nonce: request.nonce, + clientEphemeralPublicKey: request.clientEphemeralPublicKey, + hostEphemeralPublicKey, + ts, + }); + const signature = mode === "bad_signature" + ? Buffer.alloc(64, 7) + : signEd25519(signing.privateKey, canonical); + ws.receive(encodeSyncEnvelope({ + type: "account_challenge_ok", + requestId: envelope.requestId, + payload: { + v: 1, + hostDeviceId, + ts, + hostEphemeralPublicKey, + signature: signature.toString("base64"), + }, + })); + }) as unknown as WebSocket, + }, + ); + await expect(pairing).rejects.toThrow( + "Host identity verification failed — the machine may be running an older ADE.", + ); + await expect(pairing).rejects.toMatchObject({ + code: "account_host_identity_verification_failed", + }); + expect(sentTypes).toEqual(["account_challenge"]); + }); + it.each([ { name: "sign-out", ownerUserId: null }, { name: "account switch", ownerUserId: "account-user-2" }, diff --git a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts index f92bf5c5b..88161bcb7 100644 --- a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts +++ b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts @@ -1,5 +1,6 @@ import { generateKeyPairSync, + randomBytes, randomUUID, } from "node:crypto"; import fs from "node:fs"; @@ -13,10 +14,24 @@ import type { } from "../../../shared/types/pairedRuntime"; import type { SyncPairingHostIdentity, + SyncAccountChallengeOkPayload, SyncHelloPayload, SyncPairingResultPayload, SyncPeerMetadata, } from "../../../shared/types/sync"; +import { + ADOPT_CHANNEL_CHALLENGE_TIMEOUT_MS, + ADOPT_CHANNEL_MAX_CLOCK_SKEW_MS, + buildAdoptChallengeSignatureInput, + buildAdoptHelloAad, + buildAdoptHelloOkAad, + decodeCanonicalBase64, + deriveAdoptSessionKey, + generateX25519EphemeralKeyPair, + seal, + unseal, + verifyEd25519, +} from "../../../shared/sync/adoptChannelCrypto"; import type { AdeAccountMachine } from "../../../shared/types/account"; import { accountMachinePairedSyncEndpoints, @@ -63,6 +78,30 @@ class AccountPairingAuthorizationError extends Error { readonly code = "account_session_changed"; } +const HOST_IDENTITY_VERIFICATION_ERROR = + "Host identity verification failed — the machine may be running an older ADE."; + +export class AccountHostIdentityVerificationError extends Error { + readonly code = "account_host_identity_verification_failed"; + + constructor() { + super(HOST_IDENTITY_VERIFICATION_ERROR); + this.name = "AccountHostIdentityVerificationError"; + } +} + +function hostIdentityVerificationError(): AccountHostIdentityVerificationError { + return new AccountHostIdentityVerificationError(); +} + +function parseDirectoryEd25519PublicKey(value: string): Buffer { + const prefix = "ed25519:"; + if (!value.startsWith(prefix)) throw hostIdentityVerificationError(); + const raw = decodeCanonicalBase64(value.slice(prefix.length), 32); + if (!raw) throw hostIdentityVerificationError(); + return raw; +} + function nowIso(): string { return new Date().toISOString(); } @@ -582,17 +621,27 @@ export class DesktopPairedMachineStore { if (!deviceName) throw new Error("Desktop device name is required."); if (!expectedHostDeviceId) throw new Error("The account machine is missing a stable device id."); + const hasDirectoryPubkey = typeof machine.pubkey === "string"; + const hostSigningPublicKey = hasDirectoryPubkey + ? parseDirectoryEd25519PublicKey(machine.pubkey!) + : null; const accountRelayEndpoints = accountMachineSecureSyncEndpoints( machine, options.relayBaseUrls, ); - if (accountRelayEndpoints.length === 0) { + if (!hasDirectoryPubkey && accountRelayEndpoints.length === 0) { throw new Error("That machine has no directory-verified WSS relay route for account authentication."); } const pairedEndpoints = accountMachinePairedSyncEndpoints( machine, options.relayBaseUrls, ); + const accountAuthenticationEndpoints = hasDirectoryPubkey + ? pairedEndpoints + : accountRelayEndpoints; + if (accountAuthenticationEndpoints.length === 0) { + throw new Error("That machine has no directory-verified sync route for account authentication."); + } const savedCandidate = this.get(expectedHostDeviceId) ?? this.get(machine.machineKey); const existing = savedCandidate?.accountOwnerUserId == null @@ -628,7 +677,8 @@ export class DesktopPairedMachineStore { ...(options.appVersion?.trim() ? { appVersion: options.appVersion.trim() } : {}), }; const failures: string[] = []; - for (const endpoint of accountRelayEndpoints) { + let lastHostIdentityFailure: AccountHostIdentityVerificationError | null = null; + for (const endpoint of accountAuthenticationEndpoints) { throwIfAborted(options.signal); let connection: SyncEnvelopeConnection; try { @@ -644,14 +694,105 @@ export class DesktopPairedMachineStore { continue; } try { + let adoptSessionKey: Buffer | null = null; + if (hostSigningPublicKey) { + const challengeRequestId = `challenge-${randomUUID()}`; + const nonce = randomBytes(32); + const nonceBase64 = nonce.toString("base64"); + const clientEphemeral = generateX25519EphemeralKeyPair(); + const clientEphemeralPublicKey = + clientEphemeral.publicKeyRaw.toString("base64"); + const challengeResponse = waitForSyncEnvelope( + connection, + (envelope) => envelope.requestId === challengeRequestId + && ( + envelope.type === "account_challenge_ok" + || envelope.type === "account_challenge_error" + ), + ADOPT_CHANNEL_CHALLENGE_TIMEOUT_MS, + options.signal, + ); + try { + connection.send("account_challenge", { + v: 1, + nonce: nonceBase64, + clientEphemeralPublicKey, + }, challengeRequestId); + } catch (error) { + void challengeResponse.catch(() => {}); + throw error; + } + let challengeEnvelope; + try { + challengeEnvelope = await challengeResponse; + } catch { + throwIfAborted(options.signal); + throw hostIdentityVerificationError(); + } + if (challengeEnvelope.type === "account_challenge_error") { + throw hostIdentityVerificationError(); + } + const challenge = challengeEnvelope.payload as + Partial | null; + const hostEphemeralPublicKey = decodeCanonicalBase64( + challenge?.hostEphemeralPublicKey, + 32, + ); + const signature = decodeCanonicalBase64(challenge?.signature, 64); + if ( + challenge?.v !== 1 + || challenge.hostDeviceId !== expectedHostDeviceId + || !Number.isSafeInteger(challenge.ts) + || Math.abs(Date.now() - Number(challenge.ts)) + > ADOPT_CHANNEL_MAX_CLOCK_SKEW_MS + || !hostEphemeralPublicKey + || !signature + ) { + throw hostIdentityVerificationError(); + } + const canonical = buildAdoptChallengeSignatureInput({ + hostDeviceId: challenge.hostDeviceId, + nonce: nonceBase64, + clientEphemeralPublicKey, + hostEphemeralPublicKey: challenge.hostEphemeralPublicKey!, + ts: challenge.ts!, + }); + if (!verifyEd25519(hostSigningPublicKey, canonical, signature)) { + throw hostIdentityVerificationError(); + } + adoptSessionKey = deriveAdoptSessionKey({ + privateKey: clientEphemeral.privateKey, + peerPublicKeyRaw: hostEphemeralPublicKey, + nonce, + }); + } + + const accountDpop = createDesktopSyncDpopProof(proofCredentials); + const legacyAccountAuth = { + deviceId: localDeviceId, + accountToken, + dpop: accountDpop, + }; const hello: SyncHelloPayload = { peer, - auth: { - kind: "account", - deviceId: localDeviceId, - accountToken, - dpop: createDesktopSyncDpopProof(proofCredentials), - }, + auth: adoptSessionKey + ? { + kind: "account_sealed", + v: 1, + deviceId: localDeviceId, + sealed: seal( + adoptSessionKey, + buildAdoptHelloAad( + expectedHostDeviceId, + localDeviceId, + ), + Buffer.from(JSON.stringify(legacyAccountAuth), "utf8"), + ), + } + : { + kind: "account", + ...legacyAccountAuth, + }, }; const requestId = `account-${randomUUID()}`; const response = waitForSyncEnvelope( @@ -671,7 +812,33 @@ export class DesktopPairedMachineStore { : "Account authentication was rejected.", ); } - const helloOk = envelope.payload as PairedRuntimeHelloOkPayload; + let helloOk: PairedRuntimeHelloOkPayload; + if (adoptSessionKey) { + const sealedHelloOk = envelope.payload as { + v?: unknown; + sealed?: unknown; + } | null; + if ( + sealedHelloOk?.v !== 1 + || typeof sealedHelloOk.sealed !== "string" + ) { + throw hostIdentityVerificationError(); + } + try { + helloOk = JSON.parse(unseal( + adoptSessionKey, + buildAdoptHelloOkAad( + expectedHostDeviceId, + localDeviceId, + ), + sealedHelloOk.sealed, + ).toString("utf8")) as PairedRuntimeHelloOkPayload; + } catch { + throw hostIdentityVerificationError(); + } + } else { + helloOk = envelope.payload as PairedRuntimeHelloOkPayload; + } const hostIdentity = hostIdentityFromPeer(helloOk.brain); if (hostIdentity.deviceId !== expectedHostDeviceId) { throw new Error("Account machine endpoint identity did not match the directory record."); @@ -724,11 +891,15 @@ export class DesktopPairedMachineStore { } catch (error) { throwIfAborted(options.signal); if (error instanceof AccountPairingAuthorizationError) throw error; + if (error instanceof AccountHostIdentityVerificationError) { + lastHostIdentityFailure = error; + } failures.push(error instanceof Error ? error.message : String(error)); } finally { connection.close(1000, "Account pairing finished."); } } + if (lastHostIdentityFailure) throw lastHostIdentityFailure; throw new Error( `Could not connect to ${machine.name ?? machine.machineKey} with your ADE account. ${failures.slice(0, 3).join("; ")}`, ); diff --git a/apps/desktop/src/shared/accountDirectory.ts b/apps/desktop/src/shared/accountDirectory.ts index 93d9a90ab..764aab259 100644 --- a/apps/desktop/src/shared/accountDirectory.ts +++ b/apps/desktop/src/shared/accountDirectory.ts @@ -215,6 +215,7 @@ export function parseAccountMachine(value: unknown): AdeAccountMachine | null { name: optionalBoundedString(value.name, MAX_LABEL_CHARS), platform: optionalBoundedString(value.platform, MAX_LABEL_CHARS), deviceType: optionalBoundedString(value.deviceType, MAX_LABEL_CHARS), + pubkey: optionalBoundedString(value.pubkey, 128), reachableEndpoints: endpoints, lastSeenAt, online: value.online === true, @@ -549,9 +550,11 @@ function isLanHostname(value: string): boolean { } /** - * Routes that may be used only after account authentication has produced a - * normal DPoP-bound paired secret. Plain WS is allowed here for LAN/tailnet - * parity because the Clerk bearer never traverses these routes. + * Directory-verified routes that may carry either `ade-adopt-v1` sealed + * account adoption or an already-paired DPoP session. Plain WS is allowed for + * LAN/tailnet routes because adoption credentials are sealed to the + * directory-published host key; legacy plaintext account auth still uses only + * `accountMachineSecureSyncEndpoints`. */ export function accountMachinePairedSyncEndpoints( machine: AdeAccountMachine, diff --git a/apps/desktop/src/shared/sync/adoptChannelCrypto.test.ts b/apps/desktop/src/shared/sync/adoptChannelCrypto.test.ts new file mode 100644 index 000000000..996d1d706 --- /dev/null +++ b/apps/desktop/src/shared/sync/adoptChannelCrypto.test.ts @@ -0,0 +1,138 @@ +import { + createPrivateKey, + createPublicKey, + generateKeyPairSync, + randomBytes, +} from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + buildAdoptChallengeSignatureInput, + deriveAdoptSessionKey, + generateX25519EphemeralKeyPair, + rawPublicKeyFromSpki, + seal, + signEd25519, + unseal, + verifyEd25519, +} from "./adoptChannelCrypto"; + +describe("adoptChannelCrypto", () => { + it("derives the same session key and seals/unseals a payload", () => { + const client = generateX25519EphemeralKeyPair(); + const host = generateX25519EphemeralKeyPair(); + const nonce = randomBytes(32); + const clientKey = deriveAdoptSessionKey({ + privateKey: client.privateKey, + peerPublicKeyRaw: host.publicKeyRaw, + nonce, + }); + const hostKey = deriveAdoptSessionKey({ + privateKey: host.privateKey, + peerPublicKeyRaw: client.publicKeyRaw, + nonce, + }); + expect(clientKey).toEqual(hostKey); + + const aad = Buffer.from("ade-adopt-v1|host|client"); + const plaintext = Buffer.from('{"accountToken":"secret"}'); + const sealed = seal(clientKey, aad, plaintext); + expect(unseal(hostKey, aad, sealed)).toEqual(plaintext); + }); + + it("rejects tampering, the wrong AAD, and the wrong key", () => { + const key = randomBytes(32); + const aad = Buffer.from("correct aad"); + const sealed = seal(key, aad, Buffer.from("credential")); + const tampered = Buffer.from(sealed, "base64"); + tampered[15] ^= 0x01; + + expect(() => unseal(key, aad, tampered.toString("base64"))).toThrow(); + expect(() => unseal(key, Buffer.from("wrong aad"), sealed)).toThrow(); + expect(() => unseal(randomBytes(32), aad, sealed)).toThrow(); + }); + + it("builds the exact canonical challenge signature string", () => { + expect(buildAdoptChallengeSignatureInput({ + hostDeviceId: "host-device", + nonce: "bm9uY2U=", + clientEphemeralPublicKey: "Y2xpZW50", + hostEphemeralPublicKey: "aG9zdA==", + ts: 1_783_500_123_456, + })).toMatchInlineSnapshot( + `"ade-adopt-v1|host-device|bm9uY2U=|Y2xpZW50|aG9zdA==|1783500123456"`, + ); + }); + + it("signs and verifies Ed25519 with the raw 32-byte public key", () => { + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const publicKeyRaw = rawPublicKeyFromSpki(publicKey); + const message = Buffer.from("ade-adopt-v1|host|nonce|client|host-eph|123"); + const signature = signEd25519(privateKey, message); + expect(publicKeyRaw).toHaveLength(32); + expect(verifyEd25519(publicKeyRaw, message, signature)).toBe(true); + expect(verifyEd25519(publicKeyRaw, Buffer.from("changed"), signature)).toBe(false); + }); + + it("matches the fixed iOS ChaChaPoly combined-vector bytes", () => { + // iOS compatibility vector: + // client X25519 private = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f + // client X25519 public = 8f40c5adb68f25624ae5b214ea767a6ec94d829d3d7b5e1ad1ba6f3e2138285f + // host X25519 private = 202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f + // host X25519 public = 358072d6365880d1aeea329adf9121383851ed21a28e3b75e965d0d2cd166254 + // HKDF salt = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f + // session key = 73dd8c3462d2bd6af30580cd4147d5049e6b96d6e0caad0abb512e47ea9c056e + // AEAD nonce = 000102030405060708090a0b + // aad = "ade-adopt-v1|host-vector|client-vector" + // plaintext = "{\"deviceId\":\"client-vector\",\"accountToken\":\"token-vector\",\"dpop\":null}" + // sealed base64 = + // AAECAwQFBgcICQoL2ZbKNSOix6tRlgt6GSfO7OGy0Pp8/04VMGCtmGI+2K5fKpJv27j0R8un/fPj0v1aEAizUH3l7uZ6nwS6WM8f+GJfCvAcH6bo1KfPq04vbY2jLUSXuYo= + const x25519Pkcs8Prefix = Buffer.from( + "302e020100300506032b656e04220420", + "hex", + ); + const clientPrivateKey = createPrivateKey({ + key: Buffer.concat([x25519Pkcs8Prefix, Buffer.from( + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "hex", + )]), + format: "der", + type: "pkcs8", + }); + const hostPrivateKey = createPrivateKey({ + key: Buffer.concat([x25519Pkcs8Prefix, Buffer.from( + "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "hex", + )]), + format: "der", + type: "pkcs8", + }); + expect(rawPublicKeyFromSpki(createPublicKey(clientPrivateKey)).toString("hex")) + .toBe("8f40c5adb68f25624ae5b214ea767a6ec94d829d3d7b5e1ad1ba6f3e2138285f"); + const hostPublicKey = rawPublicKeyFromSpki(createPublicKey(hostPrivateKey)); + expect(hostPublicKey.toString("hex")) + .toBe("358072d6365880d1aeea329adf9121383851ed21a28e3b75e965d0d2cd166254"); + const key = deriveAdoptSessionKey({ + privateKey: clientPrivateKey, + peerPublicKeyRaw: hostPublicKey, + nonce: Buffer.from( + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "hex", + ), + }); + expect(key.toString("hex")).toBe( + "73dd8c3462d2bd6af30580cd4147d5049e6b96d6e0caad0abb512e47ea9c056e", + ); + const aeadNonce = Buffer.from( + "000102030405060708090a0b", + "hex", + ); + const aad = Buffer.from("ade-adopt-v1|host-vector|client-vector"); + const plaintext = Buffer.from( + '{"deviceId":"client-vector","accountToken":"token-vector","dpop":null}', + ); + const expected = + "AAECAwQFBgcICQoL2ZbKNSOix6tRlgt6GSfO7OGy0Pp8/04VMGCtmGI+2K5fKpJv27j0R8un/fPj0v1aEAizUH3l7uZ6nwS6WM8f+GJfCvAcH6bo1KfPq04vbY2jLUSXuYo="; + expect(seal(key, aad, plaintext, aeadNonce)).toBe(expected); + expect(unseal(key, aad, expected)).toEqual(plaintext); + }); +}); diff --git a/apps/desktop/src/shared/sync/adoptChannelCrypto.ts b/apps/desktop/src/shared/sync/adoptChannelCrypto.ts new file mode 100644 index 000000000..fcf50f578 --- /dev/null +++ b/apps/desktop/src/shared/sync/adoptChannelCrypto.ts @@ -0,0 +1,207 @@ +import { + createCipheriv, + createDecipheriv, + createPrivateKey, + createPublicKey, + diffieHellman, + generateKeyPairSync, + hkdfSync, + randomBytes, + sign, + verify, + type KeyObject, +} from "node:crypto"; + +export const ADOPT_CHANNEL_CONTEXT = "ade-adopt-v1"; +export const ADOPT_CHANNEL_HELLO_OK_CONTEXT = "ade-adopt-v1-hellook"; +export const ADOPT_CHANNEL_CHALLENGE_TTL_MS = 60_000; +export const ADOPT_CHANNEL_MAX_CLOCK_SKEW_MS = 120_000; +export const ADOPT_CHANNEL_CHALLENGE_TIMEOUT_MS = 3_000; +export const ADOPT_CHANNEL_NONCE_BYTES = 32; +export const ADOPT_CHANNEL_KEY_BYTES = 32; +export const ADOPT_CHANNEL_AEAD_NONCE_BYTES = 12; +export const ADOPT_CHANNEL_PUBLIC_KEY_BYTES = 32; + +const X25519_SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex"); +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); +const AEAD_TAG_BYTES = 16; + +function assertLength(value: Buffer, expected: number, label: string): void { + if (value.byteLength !== expected) { + throw new Error(`${label} must be exactly ${expected} bytes.`); + } +} + +function publicKeyFromRaw(prefix: Buffer, raw: Buffer, label: string): KeyObject { + assertLength(raw, ADOPT_CHANNEL_PUBLIC_KEY_BYTES, label); + return createPublicKey({ + key: Buffer.concat([prefix, raw]), + format: "der", + type: "spki", + }); +} + +export function rawPublicKeyFromSpki(key: KeyObject): Buffer { + const spki = key.export({ format: "der", type: "spki" }); + if (spki.byteLength < ADOPT_CHANNEL_PUBLIC_KEY_BYTES) { + throw new Error("Public key SPKI export is malformed."); + } + return Buffer.from(spki.subarray(spki.byteLength - ADOPT_CHANNEL_PUBLIC_KEY_BYTES)); +} + +export function generateX25519EphemeralKeyPair(): { + privateKey: KeyObject; + publicKeyRaw: Buffer; +} { + const { privateKey, publicKey } = generateKeyPairSync("x25519"); + return { + privateKey, + publicKeyRaw: rawPublicKeyFromSpki(publicKey), + }; +} + +export function decodeCanonicalBase64( + value: unknown, + expectedBytes?: number, +): Buffer | null { + if (typeof value !== "string" || !value) return null; + const decoded = Buffer.from(value, "base64"); + if ( + decoded.toString("base64") !== value + || (expectedBytes !== undefined && decoded.byteLength !== expectedBytes) + ) { + return null; + } + return decoded; +} + +export function buildAdoptChallengeSignatureInput(args: { + hostDeviceId: string; + nonce: string; + clientEphemeralPublicKey: string; + hostEphemeralPublicKey: string; + ts: number; +}): string { + return [ + ADOPT_CHANNEL_CONTEXT, + args.hostDeviceId, + args.nonce, + args.clientEphemeralPublicKey, + args.hostEphemeralPublicKey, + String(args.ts), + ].join("|"); +} + +export function buildAdoptHelloAad( + hostDeviceId: string, + clientDeviceId: string, +): Buffer { + return Buffer.from( + `${ADOPT_CHANNEL_CONTEXT}|${hostDeviceId}|${clientDeviceId}`, + "utf8", + ); +} + +export function buildAdoptHelloOkAad( + hostDeviceId: string, + clientDeviceId: string, +): Buffer { + return Buffer.from( + `${ADOPT_CHANNEL_HELLO_OK_CONTEXT}|${hostDeviceId}|${clientDeviceId}`, + "utf8", + ); +} + +export function deriveAdoptSessionKey(args: { + privateKey: KeyObject; + peerPublicKeyRaw: Buffer; + nonce: Buffer; +}): Buffer { + assertLength(args.peerPublicKeyRaw, ADOPT_CHANNEL_PUBLIC_KEY_BYTES, "X25519 public key"); + assertLength(args.nonce, ADOPT_CHANNEL_NONCE_BYTES, "Adoption nonce"); + const sharedSecret = diffieHellman({ + privateKey: args.privateKey, + publicKey: publicKeyFromRaw( + X25519_SPKI_PREFIX, + args.peerPublicKeyRaw, + "X25519 public key", + ), + }); + return Buffer.from(hkdfSync( + "sha256", + sharedSecret, + args.nonce, + Buffer.from(ADOPT_CHANNEL_CONTEXT, "utf8"), + ADOPT_CHANNEL_KEY_BYTES, + )); +} + +export function seal( + key: Buffer, + aad: Buffer, + plaintext: Buffer, + nonce: Buffer = randomBytes(ADOPT_CHANNEL_AEAD_NONCE_BYTES), +): string { + assertLength(key, ADOPT_CHANNEL_KEY_BYTES, "Adoption session key"); + assertLength(nonce, ADOPT_CHANNEL_AEAD_NONCE_BYTES, "AEAD nonce"); + const cipher = createCipheriv("chacha20-poly1305", key, nonce, { + authTagLength: AEAD_TAG_BYTES, + }); + cipher.setAAD(aad, { plaintextLength: plaintext.byteLength }); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return Buffer.concat([nonce, ciphertext, cipher.getAuthTag()]).toString("base64"); +} + +export function unseal( + key: Buffer, + aad: Buffer, + blobBase64: string, +): Buffer { + assertLength(key, ADOPT_CHANNEL_KEY_BYTES, "Adoption session key"); + const blob = decodeCanonicalBase64(blobBase64); + if ( + !blob + || blob.byteLength < ADOPT_CHANNEL_AEAD_NONCE_BYTES + AEAD_TAG_BYTES + ) { + throw new Error("Sealed adoption payload is malformed."); + } + const nonce = blob.subarray(0, ADOPT_CHANNEL_AEAD_NONCE_BYTES); + const tag = blob.subarray(blob.byteLength - AEAD_TAG_BYTES); + const ciphertext = blob.subarray( + ADOPT_CHANNEL_AEAD_NONCE_BYTES, + blob.byteLength - AEAD_TAG_BYTES, + ); + const decipher = createDecipheriv("chacha20-poly1305", key, nonce, { + authTagLength: AEAD_TAG_BYTES, + }); + decipher.setAAD(aad, { plaintextLength: ciphertext.byteLength }); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +export function signEd25519( + privateKey: KeyObject | Buffer, + message: Buffer | string, +): Buffer { + const key = Buffer.isBuffer(privateKey) + ? createPrivateKey({ key: privateKey, format: "der", type: "pkcs8" }) + : privateKey; + return sign(null, typeof message === "string" ? Buffer.from(message, "utf8") : message, key); +} + +export function verifyEd25519( + publicKeyRaw: Buffer, + message: Buffer | string, + signature: Buffer, +): boolean { + try { + return verify( + null, + typeof message === "string" ? Buffer.from(message, "utf8") : message, + publicKeyFromRaw(ED25519_SPKI_PREFIX, publicKeyRaw, "Ed25519 public key"), + signature, + ); + } catch { + return false; + } +} diff --git a/apps/desktop/src/shared/types/account.ts b/apps/desktop/src/shared/types/account.ts index 66cfcfedc..b7d4987ab 100644 --- a/apps/desktop/src/shared/types/account.ts +++ b/apps/desktop/src/shared/types/account.ts @@ -55,6 +55,8 @@ export type AdeAccountMachine = { name: string | null; platform: string | null; deviceType: string | null; + /** Long-lived machine identity key used to verify sealed account adoption. */ + pubkey?: string | null; reachableEndpoints: AdeAccountMachineEndpoint[]; lastSeenAt: number | null; online: boolean; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 84ca86805..1f1aceba0 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -784,8 +784,37 @@ export type SyncHelloAuth = dpop?: SyncDpopProof | null; /** Optional legacy one-time desktop runtime authorization. */ runtimeHostGrant?: string | null; + } + | { + kind: "account_sealed"; + v: 1; + deviceId: string; + sealed: string; }; +export type SyncAccountChallengePayload = { + v: 1; + nonce: string; + clientEphemeralPublicKey: string; +}; + +export type SyncAccountChallengeOkPayload = { + v: 1; + hostDeviceId: string; + ts: number; + hostEphemeralPublicKey: string; + signature: string; +}; + +export type SyncAccountChallengeErrorPayload = { + message: string; +}; + +export type SyncSealedHelloOkPayload = { + v: 1; + sealed: string; +}; + export type SyncHelloOkPayload = { peer: SyncPeerMetadata; brain: SyncPeerMetadata; @@ -1675,8 +1704,23 @@ type SyncEnvelopeWithPayload = }); export type SyncHelloEnvelope = SyncEnvelopeWithPayload<"hello", SyncHelloPayload>; -export type SyncHelloOkEnvelope = SyncEnvelopeWithPayload<"hello_ok", SyncHelloOkPayload>; +export type SyncHelloOkEnvelope = SyncEnvelopeWithPayload< + "hello_ok", + SyncHelloOkPayload | SyncSealedHelloOkPayload +>; export type SyncHelloErrorEnvelope = SyncEnvelopeWithPayload<"hello_error", SyncHelloErrorPayload>; +export type SyncAccountChallengeEnvelope = SyncEnvelopeWithPayload< + "account_challenge", + SyncAccountChallengePayload +>; +export type SyncAccountChallengeOkEnvelope = SyncEnvelopeWithPayload< + "account_challenge_ok", + SyncAccountChallengeOkPayload +>; +export type SyncAccountChallengeErrorEnvelope = SyncEnvelopeWithPayload< + "account_challenge_error", + SyncAccountChallengeErrorPayload +>; export type SyncRelayReauthorizeEnvelope = SyncEnvelopeWithPayload<"relay_reauthorize", SyncRelayReauthorizePayload>; export type SyncRelayReauthorizeResultEnvelope = SyncEnvelopeWithPayload<"relay_reauthorize_result", SyncRelayReauthorizeResultPayload>; export type SyncProjectCatalogRequestEnvelope = SyncEnvelopeWithPayload<"project_catalog_request", Record>; @@ -1745,6 +1789,9 @@ export type SyncEnvelope = | SyncHelloEnvelope | SyncHelloOkEnvelope | SyncHelloErrorEnvelope + | SyncAccountChallengeEnvelope + | SyncAccountChallengeOkEnvelope + | SyncAccountChallengeErrorEnvelope | SyncRelayReauthorizeEnvelope | SyncRelayReauthorizeResultEnvelope | SyncProjectCatalogRequestEnvelope From 2893e46c72bfcefc719778fa53bbd72b94223916 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:41:30 -0400 Subject: [PATCH 04/11] =?UTF-8?q?feat(sync):=20account=20adoption=20falls?= =?UTF-8?q?=20back=20relay=20=E2=86=92=20Tailscale=20=E2=86=92=20LAN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adoption tries the directory-verified relay first and, for hosts that publish an identity key, falls back to sealed adoption over tailnet and LAN routes. Host-identity verification failures abort immediately without dialing further routes; route-by-route stage progress feeds the desktop Connections panel and failures aggregate every attempted route. Co-Authored-By: Claude Fable 5 --- .../accountMachineDirectoryService.test.ts | 4 +- .../account/accountMachineDirectoryService.ts | 2 +- .../account/accountBridge.trust.test.ts | 68 +- .../main/services/account/accountBridge.ts | 51 +- .../syncPairedMachineStore.test.ts | 373 +++++++++- .../remoteRuntime/syncPairedMachineStore.ts | 125 +++- .../src/shared/accountDirectory.test.ts | 45 ++ apps/desktop/src/shared/accountDirectory.ts | 43 ++ apps/ios/ADE/App/ContentView.swift | 31 + apps/ios/ADE/Services/AccountDirectory.swift | 7 +- apps/ios/ADE/Services/SyncService.swift | 642 ++++++++++++++++-- .../Views/Account/MobileAccessGateView.swift | 63 +- .../Settings/ConnectionSettingsView.swift | 61 +- .../Settings/SettingsConnectionHeader.swift | 27 +- apps/ios/ADETests/PairingAndDpopTests.swift | 71 ++ 15 files changed, 1490 insertions(+), 123 deletions(-) create mode 100644 apps/desktop/src/shared/accountDirectory.test.ts diff --git a/apps/ade-cli/src/services/account/accountMachineDirectoryService.test.ts b/apps/ade-cli/src/services/account/accountMachineDirectoryService.test.ts index 7d70c0180..3a46f4e50 100644 --- a/apps/ade-cli/src/services/account/accountMachineDirectoryService.test.ts +++ b/apps/ade-cli/src/services/account/accountMachineDirectoryService.test.ts @@ -325,6 +325,7 @@ describe("AccountMachineDirectoryService", () => { }); it("pairs stale directory presence through a verified account relay and saves an ADE Code target", async () => { + const onStage = vi.fn(); const pairWithAccountMachine = vi.fn(async (): Promise { relayBaseUrls: ["https://relay.example"], }); - await expect(service.pairMachine("mk-studio")).resolves.toEqual({ + await expect(service.pairMachine("mk-studio", { onStage })).resolves.toEqual({ targetId: "target-account-studio", machineKey: "mk-studio", deviceId: "device-studio", @@ -389,6 +390,7 @@ describe("AccountMachineDirectoryService", () => { relayBaseUrls: ["https://relay.example"], accountOwnerUserId: "user", authorizeAccountCommit: expect.any(Function), + onStage, }, ); expect(save).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/apps/ade-cli/src/services/account/accountMachineDirectoryService.ts b/apps/ade-cli/src/services/account/accountMachineDirectoryService.ts index ae00bc919..45fcbd310 100644 --- a/apps/ade-cli/src/services/account/accountMachineDirectoryService.ts +++ b/apps/ade-cli/src/services/account/accountMachineDirectoryService.ts @@ -99,7 +99,7 @@ export function reconcileAccountOwnedMachineTrust( export type AccountMachinePairOptions = Pick< PairWithAccountMachineOptions, - "connectTimeoutMs" | "pairingTimeoutMs" | "signal" + "connectTimeoutMs" | "pairingTimeoutMs" | "signal" | "onStage" >; export type AccountMachineListOptions = { diff --git a/apps/desktop/src/main/services/account/accountBridge.trust.test.ts b/apps/desktop/src/main/services/account/accountBridge.trust.test.ts index 6d36ee1d4..d3f6eec75 100644 --- a/apps/desktop/src/main/services/account/accountBridge.trust.test.ts +++ b/apps/desktop/src/main/services/account/accountBridge.trust.test.ts @@ -77,8 +77,16 @@ vi.mock( return listMachines(); } - async pairMachine(machineKey: string) { - return pairMachine(machineKey); + async pairMachine( + machineKey: string, + options?: { + onStage?: (stage: { + kind: "relay" | "tailnet" | "lan"; + phase: "connecting" | "verifying"; + }) => void; + }, + ) { + return pairMachine(machineKey, options); } async deleteMachine(machineKey: string) { @@ -497,17 +505,41 @@ describe("desktop account machine lifecycle", () => { }); it("emits account machine progress at the bridge and per-call boundaries", async () => { - pairMachine.mockResolvedValue({ - targetId: "target-a", - machineKey: "machine-a", - deviceId: "device-a", - name: "Studio", + listMachines.mockResolvedValue({ + state: "ok", + machines: [machine({ + machineKey: "machine-a", + deviceId: "device-a", + name: "Studio", + })], + message: null, + }); + pairMachine.mockImplementation(async ( + _machineKey: string, + options?: { + onStage?: (stage: { + kind: "relay" | "tailnet" | "lan"; + phase: "connecting" | "verifying"; + }) => void; + }, + ) => { + options?.onStage?.({ kind: "relay", phase: "connecting" }); + options?.onStage?.({ kind: "tailnet", phase: "connecting" }); + options?.onStage?.({ kind: "tailnet", phase: "verifying" }); + options?.onStage?.({ kind: "lan", phase: "connecting" }); + return { + targetId: "target-a", + machineKey: "machine-a", + deviceId: "device-a", + name: "Studio", + }; }); const { createAccountBridge } = await import("./accountBridge"); const bridge = createAccountBridge({ getProjectRoot: () => null }); const listener = vi.fn(); const onProgress = vi.fn(); const unsubscribe = bridge.onPairMachineProgress(listener); + await bridge.listMachines(); await expect( bridge.pairMachine("machine-a", { onProgress }), @@ -522,13 +554,31 @@ describe("desktop account machine lifecycle", () => { stage: "relay", label: "Connecting through ADE relay…", }, + { + machineKey: "machine-a", + stage: "tailnet", + label: "Trying Tailscale…", + }, + { + machineKey: "machine-a", + stage: "verifying", + label: "Verifying it's really Studio…", + }, + { + machineKey: "machine-a", + stage: "lan", + label: "Trying local network…", + }, { machineKey: "machine-a", stage: "opening", label: "Opening connection…", }, ]; - expect(pairMachine).toHaveBeenCalledWith("machine-a"); + expect(pairMachine).toHaveBeenCalledWith( + "machine-a", + expect.objectContaining({ onStage: expect.any(Function) }), + ); expect(listener.mock.calls.map(([progress]) => progress)).toEqual( expectedProgress, ); @@ -538,7 +588,7 @@ describe("desktop account machine lifecycle", () => { unsubscribe(); await bridge.pairMachine("machine-a"); - expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenCalledTimes(5); }); it("preserves a classified directory auth failure for the desktop surface", async () => { diff --git a/apps/desktop/src/main/services/account/accountBridge.ts b/apps/desktop/src/main/services/account/accountBridge.ts index ed1a33738..e4b4ad3cb 100644 --- a/apps/desktop/src/main/services/account/accountBridge.ts +++ b/apps/desktop/src/main/services/account/accountBridge.ts @@ -150,6 +150,7 @@ export function createAccountBridge(options: AccountBridgeOptions): AccountBridg const pairMachineProgressListeners = new Set< (progress: AdeAccountPairMachineProgress) => void >(); + const accountMachineNames = new Map(); const service = () => getSharedAccountAuthService({ @@ -208,13 +209,30 @@ export function createAccountBridge(options: AccountBridgeOptions): AccountBridg signOut: () => { const accountService = service(); const status = accountService.signOut(); + accountMachineNames.clear(); reconcileLocalMachines(null); return toAccountStatus(status, configured()); }, listMachines: async (): Promise => { const result = await directoryService().listMachines(); - if (result.state === "auth_expired") reconcileLocalMachines(null); + if (result.state === "ok") { + accountMachineNames.clear(); + for (const machine of result.machines) { + const name = machine.name?.trim() || machine.machineKey; + accountMachineNames.set(machine.machineKey, name); + if (machine.deviceId?.trim()) { + accountMachineNames.set(machine.deviceId.trim(), name); + } + if (machine.name?.trim()) { + accountMachineNames.set(machine.name.trim().toLowerCase(), name); + } + } + } + if (result.state === "auth_expired") { + accountMachineNames.clear(); + reconcileLocalMachines(null); + } if (result.state === "unavailable") { options.logger?.warn("account.machines_fetch_failed", { state: result.state }); } @@ -239,15 +257,30 @@ export function createAccountBridge(options: AccountBridgeOptions): AccountBridg } } }; - emitProgress({ - machineKey, - stage: "relay", - label: "Connecting through ADE relay…", + const result = await directoryService().pairMachine(machineKey, { + onStage: ({ kind, phase }) => { + const machineName = accountMachineNames.get(machineKey) + ?? accountMachineNames.get(machineKey.trim().toLowerCase()) + ?? machineKey; + if (phase === "verifying") { + emitProgress({ + machineKey, + stage: "verifying", + label: `Verifying it's really ${machineName}…`, + }); + return; + } + emitProgress({ + machineKey, + stage: kind, + label: kind === "relay" + ? "Connecting through ADE relay…" + : kind === "tailnet" + ? "Trying Tailscale…" + : "Trying local network…", + }); + }, }); - // AccountMachineDirectoryService does not expose adoption progress yet. - // When it does, its onProgress callback can be wired directly to - // emitProgress without changing this bridge or the renderer contract. - const result = await directoryService().pairMachine(machineKey); emitProgress({ machineKey, stage: "opening", diff --git a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts index 1a1bc09e4..1e9f50ca5 100644 --- a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts @@ -3,7 +3,7 @@ import { generateKeyPairSync } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { WebSocket } from "ws"; import type { SyncDpopVerification } from "../../../../../ade-cli/src/services/sync/syncDpop"; import { @@ -66,6 +66,91 @@ class FakeWebSocket extends EventEmitter { } } +function successfulSealedAdoptionSocket(args: { + signingPrivateKey: Parameters[0]; + hostDeviceId: string; + hostName: string; + pairedSecret?: string; +}): FakeWebSocket { + let hostSessionKey: Buffer | null = null; + return new FakeWebSocket((text, ws) => { + const envelope = parseSyncEnvelope(wsDataToText(text)); + if (envelope.type === "account_challenge") { + const request = envelope.payload as { + nonce: string; + clientEphemeralPublicKey: string; + }; + const hostEphemeral = generateX25519EphemeralKeyPair(); + const hostEphemeralPublicKey = hostEphemeral.publicKeyRaw.toString("base64"); + const ts = Date.now(); + const canonical = buildAdoptChallengeSignatureInput({ + hostDeviceId: args.hostDeviceId, + nonce: request.nonce, + clientEphemeralPublicKey: request.clientEphemeralPublicKey, + hostEphemeralPublicKey, + ts, + }); + hostSessionKey = deriveAdoptSessionKey({ + privateKey: hostEphemeral.privateKey, + peerPublicKeyRaw: Buffer.from(request.clientEphemeralPublicKey, "base64"), + nonce: Buffer.from(request.nonce, "base64"), + }); + ws.receive(encodeSyncEnvelope({ + type: "account_challenge_ok", + requestId: envelope.requestId, + payload: { + v: 1, + hostDeviceId: args.hostDeviceId, + ts, + hostEphemeralPublicKey, + signature: signEd25519( + args.signingPrivateKey, + canonical, + ).toString("base64"), + }, + })); + return; + } + if (envelope.type !== "hello" || !hostSessionKey) return; + const payload = envelope.payload as { + peer: { deviceId: string }; + auth: { kind: string; deviceId: string; sealed: string }; + }; + expect(payload.auth.kind).toBe("account_sealed"); + const helloOk = { + peer: payload.peer, + brain: { + deviceId: args.hostDeviceId, + deviceName: args.hostName, + platform: "macOS", + deviceType: "desktop", + siteId: `${args.hostDeviceId}-site`, + dbVersion: 0, + }, + serverDbVersion: 0, + heartbeatIntervalMs: 5_000, + pollIntervalMs: 1_500, + features: { rpcChannel: true, portForward: true }, + accountPairing: { + deviceId: payload.auth.deviceId, + secret: args.pairedSecret ?? "sealed-paired-secret", + }, + }; + ws.receive(encodeSyncEnvelope({ + type: "hello_ok", + requestId: envelope.requestId, + payload: { + v: 1, + sealed: seal( + hostSessionKey, + buildAdoptHelloOkAad(args.hostDeviceId, payload.auth.deviceId), + Buffer.from(JSON.stringify(helloOk)), + ), + }, + })); + }); +} + describe("DesktopPairedMachineStore", () => { it("pairs as a desktop with DPoP and round-trips a 0600 machine secret file", async () => { const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-desktop-pairing-")); @@ -459,6 +544,179 @@ describe("DesktopPairedMachineStore", () => { expect(fs.readFileSync(store.path, "utf8")).not.toContain("clerk-access-token"); }); + it("does not dial direct adoption routes when the directory row has no pubkey", async () => { + const openedEndpoints: string[] = []; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const machine: AdeAccountMachine = { + machineKey: "machine-legacy-relay-only", + deviceId: "host-legacy-relay-only", + name: "Legacy Studio", + platform: "macOS", + deviceType: "desktop", + online: true, + lastSeenAt: Date.now(), + reachableEndpoints: [ + { + kind: "relay", + url: "wss://relay.example/connect/machine-legacy-relay-only", + }, + { kind: "tailnet", host: "100.75.20.63", port: 8787 }, + { kind: "lan", host: "legacy-studio.local", port: 8787 }, + ], + }; + + try { + await expect(new DesktopPairedMachineStore().pairWithAccountMachine( + machine, + "legacy-token", + "Laptop", + { + accountOwnerUserId: "account-user", + relayBaseUrls: ["https://relay.example"], + createWebSocket: (endpoint) => { + openedEndpoints.push(endpoint); + const ws = new FakeWebSocket(() => {}, false); + queueMicrotask(() => ws.emit("error", new Error("relay refused"))); + return ws as unknown as WebSocket; + }, + }, + )).rejects.toThrow(/relay relay\.example:.*relay refused/i); + } finally { + warn.mockRestore(); + } + + expect(openedEndpoints).toEqual([ + "wss://relay.example/connect/machine-legacy-relay-only", + ]); + }); + + it("stops after a successful sealed relay adoption without dialing direct routes", async () => { + process.env.ADE_HOME = fs.mkdtempSync( + path.join(os.tmpdir(), "ade-desktop-relay-wins-"), + ); + const signing = generateKeyPairSync("ed25519"); + const openedEndpoints: string[] = []; + const stages: Array<{ + kind: "relay" | "tailnet" | "lan"; + phase: "connecting" | "verifying"; + }> = []; + const machine: AdeAccountMachine = { + machineKey: "machine-relay-wins", + deviceId: "host-relay-wins", + name: "Relay Studio", + platform: "macOS", + deviceType: "desktop", + pubkey: `ed25519:${rawPublicKeyFromSpki(signing.publicKey).toString("base64")}`, + online: true, + lastSeenAt: Date.now(), + reachableEndpoints: [ + { kind: "lan", host: "relay-studio.local", port: 8787 }, + { kind: "tailnet", host: "100.75.20.63", port: 8787 }, + { + kind: "relay", + url: "wss://relay.example/connect/machine-relay-wins", + }, + ], + }; + + await expect(new DesktopPairedMachineStore().pairWithAccountMachine( + machine, + "sealed-token", + "Laptop", + { + accountOwnerUserId: "account-user", + relayBaseUrls: ["https://relay.example"], + onStage: (stage) => stages.push(stage), + createWebSocket: (endpoint) => { + openedEndpoints.push(endpoint); + return successfulSealedAdoptionSocket({ + signingPrivateKey: signing.privateKey, + hostDeviceId: "host-relay-wins", + hostName: "Relay Studio", + }) as unknown as WebSocket; + }, + }, + )).resolves.toMatchObject({ + hostIdentity: { deviceId: "host-relay-wins" }, + secret: "sealed-paired-secret", + }); + expect(openedEndpoints).toEqual([ + "wss://relay.example/connect/machine-relay-wins", + ]); + expect(stages).toEqual([ + { kind: "relay", phase: "connecting" }, + { kind: "relay", phase: "verifying" }, + ]); + }); + + it("falls through a closed relay to sealed tailnet adoption with exact stages", async () => { + process.env.ADE_HOME = fs.mkdtempSync( + path.join(os.tmpdir(), "ade-desktop-tailnet-fallback-"), + ); + const signing = generateKeyPairSync("ed25519"); + const openedEndpoints: string[] = []; + const stages: Array<{ + kind: "relay" | "tailnet" | "lan"; + phase: "connecting" | "verifying"; + }> = []; + const machine: AdeAccountMachine = { + machineKey: "machine-tailnet-fallback", + deviceId: "host-tailnet-fallback", + name: "Tailnet Studio", + platform: "macOS", + deviceType: "desktop", + pubkey: `ed25519:${rawPublicKeyFromSpki(signing.publicKey).toString("base64")}`, + online: true, + lastSeenAt: Date.now(), + reachableEndpoints: [ + { kind: "lan", host: "tailnet-studio.local", port: 8787 }, + { kind: "tailnet", host: "100.75.20.63", port: 8787 }, + { + kind: "relay", + url: "wss://relay.example/connect/machine-tailnet-fallback", + }, + ], + }; + + await expect(new DesktopPairedMachineStore().pairWithAccountMachine( + machine, + "sealed-token", + "Laptop", + { + accountOwnerUserId: "account-user", + relayBaseUrls: ["https://relay.example"], + onStage: (stage) => stages.push(stage), + createWebSocket: (endpoint) => { + openedEndpoints.push(endpoint); + if (endpoint.startsWith("wss://")) { + return new FakeWebSocket((text, ws) => { + const envelope = parseSyncEnvelope(wsDataToText(text)); + if (envelope.type === "account_challenge") ws.close(); + }) as unknown as WebSocket; + } + return successfulSealedAdoptionSocket({ + signingPrivateKey: signing.privateKey, + hostDeviceId: "host-tailnet-fallback", + hostName: "Tailnet Studio", + }) as unknown as WebSocket; + }, + }, + )).resolves.toMatchObject({ + hostIdentity: { deviceId: "host-tailnet-fallback" }, + secret: "sealed-paired-secret", + }); + + expect(openedEndpoints).toEqual([ + "wss://relay.example/connect/machine-tailnet-fallback", + "ws://100.75.20.63:8787/", + ]); + expect(stages).toEqual([ + { kind: "relay", phase: "connecting" }, + { kind: "tailnet", phase: "connecting" }, + { kind: "tailnet", phase: "verifying" }, + ]); + }); + it("verifies a directory signing key before sending a sealed account hello over a direct route", async () => { const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-desktop-sealed-pairing-")); process.env.ADE_HOME = adeHome; @@ -477,7 +735,6 @@ describe("DesktopPairedMachineStore", () => { lastSeenAt: Date.now(), reachableEndpoints: [ { kind: "lan", host: "sealed-studio.local", port: 8787 }, - { kind: "relay", url: "wss://relay.example/connect/machine-sealed" }, ], }; const createWebSocket = () => new FakeWebSocket((text, ws) => { @@ -682,6 +939,118 @@ describe("DesktopPairedMachineStore", () => { expect(sentTypes).toEqual(["account_challenge"]); }); + it("aborts on a direct-route impostor without dialing later routes or sending hello", async () => { + const signing = generateKeyPairSync("ed25519"); + const openedEndpoints: string[] = []; + const sentTypes: string[] = []; + const machine: AdeAccountMachine = { + machineKey: "machine-direct-impostor", + deviceId: "expected-direct-host", + name: "Expected Direct Host", + platform: "macOS", + deviceType: "desktop", + pubkey: `ed25519:${rawPublicKeyFromSpki(signing.publicKey).toString("base64")}`, + online: true, + lastSeenAt: Date.now(), + reachableEndpoints: [ + { kind: "tailnet", host: "100.75.20.63", port: 8787 }, + { kind: "lan", host: "expected-direct-host.local", port: 8787 }, + ], + }; + + const pairing = new DesktopPairedMachineStore().pairWithAccountMachine( + machine, + "must-remain-sealed", + "Laptop", + { + accountOwnerUserId: "account-user", + createWebSocket: (endpoint) => { + openedEndpoints.push(endpoint); + return new FakeWebSocket((text, ws) => { + const envelope = parseSyncEnvelope(wsDataToText(text)); + sentTypes.push(envelope.type); + if (envelope.type !== "account_challenge") return; + const hostEphemeral = generateX25519EphemeralKeyPair(); + ws.receive(encodeSyncEnvelope({ + type: "account_challenge_ok", + requestId: envelope.requestId, + payload: { + v: 1, + hostDeviceId: "expected-direct-host", + ts: Date.now(), + hostEphemeralPublicKey: + hostEphemeral.publicKeyRaw.toString("base64"), + signature: Buffer.alloc(64, 9).toString("base64"), + }, + })); + }) as unknown as WebSocket; + }, + }, + ); + + await expect(pairing).rejects.toMatchObject({ + code: "account_host_identity_verification_failed", + }); + expect(openedEndpoints).toEqual(["ws://100.75.20.63:8787/"]); + expect(sentTypes).toEqual(["account_challenge"]); + }); + + it("aggregates every failed adoption route with its kind and host", async () => { + const signing = generateKeyPairSync("ed25519"); + const openedEndpoints: string[] = []; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const machine: AdeAccountMachine = { + machineKey: "machine-all-routes-fail", + deviceId: "host-all-routes-fail", + name: "Unavailable Studio", + platform: "macOS", + deviceType: "desktop", + pubkey: `ed25519:${rawPublicKeyFromSpki(signing.publicKey).toString("base64")}`, + online: true, + lastSeenAt: Date.now(), + reachableEndpoints: [ + { + kind: "relay", + url: "wss://relay.example/connect/machine-all-routes-fail", + }, + { kind: "tailnet", host: "100.75.20.63", port: 8787 }, + { kind: "lan", host: "unavailable-studio.local", port: 8787 }, + ], + }; + + let failure: Error | null = null; + try { + await new DesktopPairedMachineStore().pairWithAccountMachine( + machine, + "sealed-token", + "Laptop", + { + accountOwnerUserId: "account-user", + relayBaseUrls: ["https://relay.example"], + createWebSocket: (endpoint) => { + openedEndpoints.push(endpoint); + const ws = new FakeWebSocket(() => {}, false); + queueMicrotask(() => ws.emit("error", new Error(`refused ${endpoint}`))); + return ws as unknown as WebSocket; + }, + }, + ); + } catch (error) { + failure = error instanceof Error ? error : new Error(String(error)); + } finally { + warn.mockRestore(); + } + + expect(failure?.message).toMatch(/relay relay\.example:/); + expect(failure?.message).toMatch(/tailnet 100\.75\.20\.63:/); + expect(failure?.message).toMatch(/lan unavailable-studio\.local:/); + expect(openedEndpoints).toEqual([ + "wss://relay.example/connect/machine-all-routes-fail", + "ws://100.75.20.63:8787/", + "ws://unavailable-studio.local:8787/", + ]); + }); + it.each([ { name: "sign-out", ownerUserId: null }, { name: "account switch", ownerUserId: "account-user-2" }, diff --git a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts index 88161bcb7..576fe0c8e 100644 --- a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts +++ b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts @@ -34,9 +34,10 @@ import { } from "../../../shared/sync/adoptChannelCrypto"; import type { AdeAccountMachine } from "../../../shared/types/account"; import { + accountMachineAdoptionRoutes, accountMachinePairedSyncEndpoints, - accountMachineSecureSyncEndpoints, resolveAccountHelloPairing, + type AccountMachineAdoptionRoute, } from "../../../shared/accountDirectory"; import { buildDesktopPairedHello, @@ -72,6 +73,10 @@ export type PairWithAccountMachineOptions = Omit< authorizeAccountCommit?: ( expectedOwnerUserId: string, ) => boolean | Promise; + onStage?: (stage: { + kind: "relay" | "tailnet" | "lan"; + phase: "connecting" | "verifying"; + }) => void; }; class AccountPairingAuthorizationError extends Error { @@ -102,6 +107,45 @@ function parseDirectoryEd25519PublicKey(value: string): Buffer { return raw; } +type AccountMachineAdoptionFailure = { + route: AccountMachineAdoptionRoute; + reason: string; +}; + +function emitAccountMachineAdoptionStage( + callback: PairWithAccountMachineOptions["onStage"], + stage: Parameters>[0], +): void { + try { + callback?.(stage); + } catch { + // Progress reporting is best-effort and must not abort adoption. + } +} + +function boundedInlineText(value: string, maxChars: number): string { + const normalized = value.replace(/\s+/g, " ").trim() || "Unknown failure."; + return normalized.length <= maxChars + ? normalized + : `${normalized.slice(0, Math.max(0, maxChars - 1))}…`; +} + +function accountMachineAdoptionRouteHost(route: AccountMachineAdoptionRoute): string { + try { + return boundedInlineText(new URL(route.endpoint).hostname, 96); + } catch { + return boundedInlineText(route.endpoint, 96); + } +} + +function formatAccountMachineAdoptionFailure( + failure: AccountMachineAdoptionFailure, +): string { + return `${failure.route.kind} ${accountMachineAdoptionRouteHost(failure.route)}: ${ + boundedInlineText(failure.reason, 160) + }`; +} + function nowIso(): string { return new Date().toISOString(); } @@ -625,21 +669,21 @@ export class DesktopPairedMachineStore { const hostSigningPublicKey = hasDirectoryPubkey ? parseDirectoryEd25519PublicKey(machine.pubkey!) : null; - const accountRelayEndpoints = accountMachineSecureSyncEndpoints( + const adoptionRoutes = accountMachineAdoptionRoutes( machine, options.relayBaseUrls, ); - if (!hasDirectoryPubkey && accountRelayEndpoints.length === 0) { + const accountAuthenticationRoutes = hostSigningPublicKey + ? adoptionRoutes + : adoptionRoutes.filter((route) => route.kind === "relay"); + if (!hostSigningPublicKey && accountAuthenticationRoutes.length === 0) { throw new Error("That machine has no directory-verified WSS relay route for account authentication."); } const pairedEndpoints = accountMachinePairedSyncEndpoints( machine, options.relayBaseUrls, ); - const accountAuthenticationEndpoints = hasDirectoryPubkey - ? pairedEndpoints - : accountRelayEndpoints; - if (accountAuthenticationEndpoints.length === 0) { + if (accountAuthenticationRoutes.length === 0) { throw new Error("That machine has no directory-verified sync route for account authentication."); } @@ -676,21 +720,32 @@ export class DesktopPairedMachineStore { capabilities: [], ...(options.appVersion?.trim() ? { appVersion: options.appVersion.trim() } : {}), }; - const failures: string[] = []; - let lastHostIdentityFailure: AccountHostIdentityVerificationError | null = null; - for (const endpoint of accountAuthenticationEndpoints) { + const failures: AccountMachineAdoptionFailure[] = []; + for (const route of accountAuthenticationRoutes) { throwIfAborted(options.signal); + if (route.kind !== "relay" && !hostSigningPublicKey) { + throw new Error( + "Refusing to send plaintext account authentication over a direct route.", + ); + } + emitAccountMachineAdoptionStage(options.onStage, { + kind: route.kind, + phase: "connecting", + }); let connection: SyncEnvelopeConnection; try { connection = await openSyncEnvelopeConnection({ - endpoint, + endpoint: route.endpoint, connectTimeoutMs: options.connectTimeoutMs, signal: options.signal, createWebSocket: options.createWebSocket, }); } catch (error) { throwIfAborted(options.signal); - failures.push(error instanceof Error ? error.message : String(error)); + failures.push({ + route, + reason: error instanceof Error ? error.message : String(error), + }); continue; } try { @@ -709,7 +764,10 @@ export class DesktopPairedMachineStore { envelope.type === "account_challenge_ok" || envelope.type === "account_challenge_error" ), - ADOPT_CHANNEL_CHALLENGE_TIMEOUT_MS, + Math.min( + ADOPT_CHANNEL_CHALLENGE_TIMEOUT_MS, + options.pairingTimeoutMs ?? DEFAULT_PAIRING_TIMEOUT_MS, + ), options.signal, ); try { @@ -722,13 +780,7 @@ export class DesktopPairedMachineStore { void challengeResponse.catch(() => {}); throw error; } - let challengeEnvelope; - try { - challengeEnvelope = await challengeResponse; - } catch { - throwIfAborted(options.signal); - throw hostIdentityVerificationError(); - } + const challengeEnvelope = await challengeResponse; if (challengeEnvelope.type === "account_challenge_error") { throw hostIdentityVerificationError(); } @@ -765,8 +817,17 @@ export class DesktopPairedMachineStore { peerPublicKeyRaw: hostEphemeralPublicKey, nonce, }); + emitAccountMachineAdoptionStage(options.onStage, { + kind: route.kind, + phase: "verifying", + }); } + if (route.kind !== "relay" && !adoptSessionKey) { + throw new Error( + "Refusing to send plaintext account authentication over a direct route.", + ); + } const accountDpop = createDesktopSyncDpopProof(proofCredentials); const legacyAccountAuth = { deviceId: localDeviceId, @@ -854,7 +915,7 @@ export class DesktopPairedMachineStore { throw new Error("Account-authenticated host did not provide or recognize paired credentials."); } const savedEndpoints = uniqueEndpoints( - endpoint, + route.endpoint, ...pairedEndpoints, helloOk.cloudRelayWssUrl, ); @@ -883,7 +944,7 @@ export class DesktopPairedMachineStore { relayUrl: helloOk.cloudRelayWssUrl ?? null, endpointStates: savedEndpoints.map((candidate) => ({ endpoint: candidate, - lastSucceededAt: candidate === endpoint ? Date.now() : null, + lastSucceededAt: candidate === route.endpoint ? Date.now() : null, })), createdAt, updatedAt: nowIso(), @@ -892,16 +953,28 @@ export class DesktopPairedMachineStore { throwIfAborted(options.signal); if (error instanceof AccountPairingAuthorizationError) throw error; if (error instanceof AccountHostIdentityVerificationError) { - lastHostIdentityFailure = error; + throw error; } - failures.push(error instanceof Error ? error.message : String(error)); + failures.push({ + route, + reason: error instanceof Error ? error.message : String(error), + }); } finally { connection.close(1000, "Account pairing finished."); } } - if (lastHostIdentityFailure) throw lastHostIdentityFailure; + console.warn("[account] Account machine adoption failed on every route.", { + machineKey: machine.machineKey, + attempts: failures.map((failure) => ({ + kind: failure.route.kind, + endpoint: failure.route.endpoint, + reason: failure.reason, + })), + }); throw new Error( - `Could not connect to ${machine.name ?? machine.machineKey} with your ADE account. ${failures.slice(0, 3).join("; ")}`, + `Could not connect to ${machine.name ?? machine.machineKey} with your ADE account. ${ + failures.map(formatAccountMachineAdoptionFailure).join("; ") + }`, ); } diff --git a/apps/desktop/src/shared/accountDirectory.test.ts b/apps/desktop/src/shared/accountDirectory.test.ts new file mode 100644 index 000000000..866f8584d --- /dev/null +++ b/apps/desktop/src/shared/accountDirectory.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + accountMachineAdoptionRoutes, +} from "./accountDirectory"; +import type { AdeAccountMachine } from "./types/account"; + +describe("accountMachineAdoptionRoutes", () => { + it("orders validated relay, tailnet, and LAN routes", () => { + const machine: AdeAccountMachine = { + machineKey: "machine-studio", + deviceId: "device-studio", + name: "Studio", + platform: "macOS", + deviceType: "desktop", + online: true, + lastSeenAt: Date.now(), + reachableEndpoints: [ + { kind: "lan", host: "studio.local", port: 8787 }, + { kind: "tailnet", host: "100.75.20.63", port: 8787 }, + { + kind: "relay", + url: "wss://relay.example/connect/machine-studio", + }, + { kind: "tailnet", host: "100.128.0.1", port: 8787 }, + { kind: "lan", url: "wss://public.example/sync" }, + ], + }; + + expect(accountMachineAdoptionRoutes(machine, ["https://relay.example"])) + .toEqual([ + { + endpoint: "wss://relay.example/connect/machine-studio", + kind: "relay", + }, + { + endpoint: "ws://100.75.20.63:8787/", + kind: "tailnet", + }, + { + endpoint: "ws://studio.local:8787/", + kind: "lan", + }, + ]); + }); +}); diff --git a/apps/desktop/src/shared/accountDirectory.ts b/apps/desktop/src/shared/accountDirectory.ts index 764aab259..d0a268af3 100644 --- a/apps/desktop/src/shared/accountDirectory.ts +++ b/apps/desktop/src/shared/accountDirectory.ts @@ -600,6 +600,49 @@ export function accountMachinePairedSyncEndpoints( return endpoints; } +export type AccountMachineAdoptionRoute = { + endpoint: string; + kind: "relay" | "tailnet" | "lan"; +}; + +/** + * Account-adoption routes in failover order. Endpoint validation remains + * centralized in the secure/paired endpoint helpers above; this helper only + * classifies their already-validated output. + */ +export function accountMachineAdoptionRoutes( + machine: AdeAccountMachine, + relayBaseUrls: readonly string[] = [], +): AccountMachineAdoptionRoute[] { + const relayEndpoints = accountMachineSecureSyncEndpoints( + machine, + relayBaseUrls, + ); + const relaySet = new Set(relayEndpoints); + const tailnet: AccountMachineAdoptionRoute[] = []; + const lan: AccountMachineAdoptionRoute[] = []; + + for (const endpoint of accountMachinePairedSyncEndpoints(machine, relayBaseUrls)) { + if (relaySet.has(endpoint)) continue; + const route = { + endpoint, + kind: isTailnetHostname(new URL(endpoint).hostname) + ? "tailnet" as const + : "lan" as const, + }; + (route.kind === "tailnet" ? tailnet : lan).push(route); + } + + return [ + ...relayEndpoints.map((endpoint) => ({ + endpoint, + kind: "relay" as const, + })), + ...tailnet, + ...lan, + ]; +} + export function selectAccountMachine( machines: AdeAccountMachine[], query: string, diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index dc6815d41..c6d4839bc 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -90,6 +90,15 @@ struct ContentView: View { .adeScreenBackground() .adeNavigationGlass() .adeInspectorHost() + .overlay(alignment: .top) { + if let label = syncService.accountConnectSuccessLabel { + AccountConnectStatusToast(label: label) + .padding(.horizontal, 20) + .padding(.top, 12) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.spring(response: 0.35, dampingFraction: 0.86), value: syncService.accountConnectSuccessLabel) .preferredColorScheme(colorSchemeChoice.preferredColorScheme) .sensoryFeedback(.selection, trigger: selectedTab) .environmentObject(syncService.attentionDrawer) @@ -250,6 +259,28 @@ struct ContentView: View { } } +struct AccountConnectStatusToast: View { + let label: String + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(ADEColor.success) + Text(label) + .font(.system(.footnote, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(ADEColor.cardBackground.opacity(0.92), in: Capsule()) + .glassEffect() + .overlay(Capsule().stroke(ADEColor.success.opacity(0.3), lineWidth: 0.75)) + .shadow(color: .black.opacity(0.18), radius: 14, y: 6) + .accessibilityElement(children: .combine) + } +} + /// Kept out of ContentView's already-long modifier chain so SwiftUI does not /// have to infer the entire app root and the cold-start task in one expression. private struct WorkSessionNavigationModifier: ViewModifier { diff --git a/apps/ios/ADE/Services/AccountDirectory.swift b/apps/ios/ADE/Services/AccountDirectory.swift index f49648464..8b963fa22 100644 --- a/apps/ios/ADE/Services/AccountDirectory.swift +++ b/apps/ios/ADE/Services/AccountDirectory.swift @@ -26,6 +26,10 @@ struct AccountMachine: Codable, Equatable, Identifiable, Hashable { let name: String? let platform: String? let deviceType: String? + /// Stable machine identity key advertised by the directory. Newer records + /// use `ed25519:`; older rows omit it and remain + /// eligible only for the legacy Relay adoption path. + let pubkey: String? let reachableEndpoints: [AccountMachineEndpoint] let lastSeenAt: Double? let createdAt: Double? @@ -40,6 +44,7 @@ struct AccountMachine: Codable, Equatable, Identifiable, Hashable { name = try container.decodeIfPresent(String.self, forKey: .name) platform = try container.decodeIfPresent(String.self, forKey: .platform) deviceType = try container.decodeIfPresent(String.self, forKey: .deviceType) + pubkey = try container.decodeIfPresent(String.self, forKey: .pubkey) reachableEndpoints = try container.decodeIfPresent([AccountMachineEndpoint].self, forKey: .reachableEndpoints) ?? [] lastSeenAt = try container.decodeIfPresent(Double.self, forKey: .lastSeenAt) createdAt = try container.decodeIfPresent(Double.self, forKey: .createdAt) @@ -47,7 +52,7 @@ struct AccountMachine: Codable, Equatable, Identifiable, Hashable { } private enum CodingKeys: String, CodingKey { - case machineKey, deviceId, name, platform, deviceType, reachableEndpoints, lastSeenAt, createdAt, online + case machineKey, deviceId, name, platform, deviceType, pubkey, reachableEndpoints, lastSeenAt, createdAt, online } /// Human display name — falls back to the platform or a generic label so a diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 844dcf699..00841c00f 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -1,4 +1,5 @@ import Combine +import CryptoKit import Foundation import Network import SwiftUI @@ -216,6 +217,134 @@ func adeJSONData(withJSONObject object: Any, options: JSONSerialization.WritingO return try JSONSerialization.data(withJSONObject: object, options: writingOptions) } +/// Byte-for-byte counterpart of +/// `apps/desktop/src/shared/sync/adoptChannelCrypto.ts`. +enum AdoptChannelCrypto { + static let context = "ade-adopt-v1" + static let helloOkContext = "ade-adopt-v1-hellook" + static let challengeTimeoutNanoseconds: UInt64 = 3_000_000_000 + static let maximumClockSkewMilliseconds: Double = 120_000 + + static func decodeCanonicalBase64(_ value: String, expectedBytes: Int? = nil) -> Data? { + guard !value.isEmpty, + let decoded = Data(base64Encoded: value), + decoded.base64EncodedString() == value, + expectedBytes.map({ decoded.count == $0 }) ?? true else { + return nil + } + return decoded + } + + static func signingPublicKey(fromDirectoryValue value: String) throws -> Curve25519.Signing.PublicKey { + let prefix = "ed25519:" + guard value.hasPrefix(prefix), + let raw = decodeCanonicalBase64(String(value.dropFirst(prefix.count)), expectedBytes: 32) else { + throw NSError( + domain: "ADE.AdoptChannel", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "The directory returned an invalid machine identity key."] + ) + } + return try Curve25519.Signing.PublicKey(rawRepresentation: raw) + } + + static func challengeSignatureInput( + hostDeviceId: String, + nonce: String, + clientEphemeralPublicKey: String, + hostEphemeralPublicKey: String, + timestampMilliseconds: Int64 + ) -> String { + [ + context, + hostDeviceId, + nonce, + clientEphemeralPublicKey, + hostEphemeralPublicKey, + String(timestampMilliseconds), + ].joined(separator: "|") + } + + static func helloAAD(hostDeviceId: String, clientDeviceId: String) -> Data { + Data("\(context)|\(hostDeviceId)|\(clientDeviceId)".utf8) + } + + static func helloOkAAD(hostDeviceId: String, clientDeviceId: String) -> Data { + Data("\(helloOkContext)|\(hostDeviceId)|\(clientDeviceId)".utf8) + } + + static func deriveSessionKey( + clientPrivateKey: Curve25519.KeyAgreement.PrivateKey, + hostPublicKeyRaw: Data, + nonce: Data + ) throws -> SymmetricKey { + guard hostPublicKeyRaw.count == 32, nonce.count == 32 else { + throw NSError( + domain: "ADE.AdoptChannel", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "The machine returned an invalid adoption challenge."] + ) + } + let hostPublicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: hostPublicKeyRaw) + let sharedSecret = try clientPrivateKey.sharedSecretFromKeyAgreement(with: hostPublicKey) + return sharedSecret.hkdfDerivedSymmetricKey( + using: SHA256.self, + salt: nonce, + sharedInfo: Data(context.utf8), + outputByteCount: 32 + ) + } + + static func seal(_ plaintext: Data, key: SymmetricKey, aad: Data) throws -> String { + let box = try ChaChaPoly.seal(plaintext, using: key, authenticating: aad) + return box.combined.base64EncodedString() + } + + static func unseal(_ blobBase64: String, key: SymmetricKey, aad: Data) throws -> Data { + guard let combined = decodeCanonicalBase64(blobBase64), combined.count >= 28 else { + throw NSError( + domain: "ADE.AdoptChannel", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "The sealed adoption payload is malformed."] + ) + } + let box = try ChaChaPoly.SealedBox(combined: combined) + return try ChaChaPoly.open(box, using: key, authenticating: aad) + } +} + +struct AccountAdoptionIdentityVerificationError: LocalizedError, Equatable { + let machineName: String + + var errorDescription: String? { + "Couldn't verify that \(machineName)'s identity. Open ADE on that Mac and try again." + } +} + +private struct AccountAdoptionRoutesExhaustedError: LocalizedError { + let machineName: String + let routeLabels: [String] + let finalFailure: Error? + + var errorDescription: String? { + let routeSummary: String + switch routeLabels.count { + case 0: + routeSummary = "any secure route" + case 1: + routeSummary = routeLabels[0] + case 2: + routeSummary = routeLabels.joined(separator: " and ") + default: + routeSummary = "\(routeLabels.dropLast().joined(separator: ", ")), and \(routeLabels.last ?? "")" + } + let failure = finalFailure.map { SyncUserFacingError.message(for: $0) }? + .trimmingCharacters(in: .whitespacesAndNewlines) + let suffix = failure.flatMap { $0.isEmpty ? nil : $0 }.map { " \($0)" } ?? "" + return "Couldn't connect to \(machineName) after trying \(routeSummary).\(suffix)" + } +} + private func adeIsValidJSONFragment(_ object: Any) -> Bool { if object is String || object is NSNumber || object is NSNull { return true @@ -522,6 +651,73 @@ enum SyncConnectionRouteKind: Int, Equatable { case relay = 2 } +private struct AccountAdoptionRoute: Equatable, Hashable { + let endpoint: SyncConnectionEndpointAttempt + let kind: AccountMachineEndpoint.Kind + let usesSealedCredentials: Bool + + var attemptLabel: String { + switch kind { + case .relay: return "ADE relay" + case .tailnet: return "Tailscale" + case .lan: return "local network" + } + } + + var stageLabel: String { + switch kind { + case .relay: return "Connecting through ADE relay…" + case .tailnet: return "Trying Tailscale…" + case .lan: return "Trying local network…" + } + } + + var connectedLabel: String { + switch kind { + case .relay: return "ADE relay" + case .tailnet: return "Tailscale" + case .lan: return "local network" + } + } + + var connectionRouteKind: SyncConnectionRouteKind { + switch kind { + case .relay: return .relay + case .tailnet: return .tailnet + case .lan: return .lan + } + } +} + +private func syncAccountAdoptionEndpointAttempt( + _ endpoint: AccountMachineEndpoint +) -> SyncConnectionEndpointAttempt? { + switch endpoint.kind { + case .relay: + guard let route = endpoint.url?.trimmingCharacters(in: .whitespacesAndNewlines), + !route.isEmpty, + syncIsFullWebSocketRoute(route), + URL(string: route)?.scheme?.lowercased() == "wss" else { + return nil + } + return SyncConnectionEndpointAttempt( + address: route, + port: syncParseRouteEndpoint(route)?.port + ?? endpoint.port + ?? SyncDirectHostPorts.defaultPort + ) + case .tailnet, .lan: + let host = endpoint.host.flatMap(syncEndpointHost) + ?? endpoint.url.flatMap(syncEndpointHost) + guard let host else { return nil } + let urlPort = endpoint.url.flatMap { URLComponents(string: $0)?.port } + return SyncConnectionEndpointAttempt( + address: host, + port: endpoint.port ?? urlPort ?? SyncDirectHostPorts.defaultPort + ) + } +} + /// Closed pairing failures the UI can act on without parsing host strings. /// Unknown host codes remain typed as `.other` so newer hosts degrade without /// collapsing into a misleading invalid-PIN state. @@ -2229,6 +2425,15 @@ final class SyncService: ObservableObject { @Published private(set) var currentAddress: String? @Published private(set) var lastConnectDurationMs: Int? @Published private(set) var lastConnectedRouteKind: SyncConnectionRouteKind? + /// Short, route-specific progress shown while a fresh account machine is + /// being adopted. This is intentionally separate from the general reconnect + /// state so background reconnects do not overwrite the guided account flow. + @Published private(set) var accountConnectStageLabel: String? + /// A brief success affordance shared by the access gate, Hub, and Settings. + @Published private(set) var accountConnectSuccessLabel: String? + /// Direct route to offer when a legacy (no identity pubkey) Relay adoption + /// fails and PIN pairing is the only safe fallback. + @Published private(set) var accountPairingPinFallbackHost: DiscoveredSyncHost? @Published private(set) var lastError: String? @Published private(set) var relayAuthorizationRequirement: SyncRelayAuthorizationRequirement? /// Host error CODE from the most recent pairing attempt (e.g. `pin_not_set`), @@ -2278,6 +2483,7 @@ final class SyncService: ObservableObject { return formatter }() @Published private(set) var workspaceSnapshotRevision = 0 + private var accountConnectSuccessClearTask: Task? // All-projects chat roster (mobile hub). `rosterProjects` is the machine-wide // projection of every project's lanes + chats; the hub reads it overlaid on // `projects` (the catalog). Mutated only by the roster apply path below. @@ -4476,6 +4682,278 @@ final class SyncService: ObservableObject { return true } + private struct AccountAdoptionChallengeSession { + let key: SymmetricKey + let hostDeviceId: String + } + + private func accountAdoptionRoutes( + for machine: AccountMachine, + hasSigningKey: Bool + ) -> [AccountAdoptionRoute] { + let orderedKinds: [AccountMachineEndpoint.Kind] = [.relay, .tailnet, .lan] + var seen = Set() + var routes: [AccountAdoptionRoute] = [] + for kind in orderedKinds { + if kind != .relay && !hasSigningKey { continue } + for directoryEndpoint in machine.reachableEndpoints where directoryEndpoint.kind == kind { + guard let endpoint = syncAccountAdoptionEndpointAttempt(directoryEndpoint), + seen.insert(endpoint).inserted else { + continue + } + routes.append(AccountAdoptionRoute( + endpoint: endpoint, + kind: kind, + usesSealedCredentials: hasSigningKey + )) + } + } + return routes + } + + private func pinFallbackHost(for machine: AccountMachine) -> DiscoveredSyncHost? { + let directEndpoints = machine.reachableEndpoints + .filter { $0.kind == .lan || $0.kind == .tailnet } + .compactMap { endpoint -> (AccountMachineEndpoint.Kind, SyncConnectionEndpointAttempt)? in + syncAccountAdoptionEndpointAttempt(endpoint).map { (endpoint.kind, $0) } + } + guard let preferred = directEndpoints.first(where: { $0.0 == .lan }) + ?? directEndpoints.first(where: { $0.0 == .tailnet }) else { + return nil + } + let lanAddresses = deduplicatedAddresses( + directEndpoints.compactMap { $0.0 == .lan ? $0.1.address : nil } + ) + let tailscaleAddress = directEndpoints.first(where: { $0.0 == .tailnet })?.1.address + return DiscoveredSyncHost( + id: "account-pin-\(machine.id)", + serviceName: "ADE account machine", + hostName: machine.displayName, + hostIdentity: syncNonEmpty(machine.deviceId), + port: preferred.1.port, + addresses: lanAddresses, + tailscaleAddress: tailscaleAddress, + lastResolvedAt: syncDateFormatter.string(from: Date()) + ) + } + + private func publishAccountConnectSuccess( + route: AccountAdoptionRoute, + attemptStartedAt: TimeInterval + ) { + let elapsed = max(0, ProcessInfo.processInfo.systemUptime - attemptStartedAt) + let latencyMs = Int((elapsed * 1_000).rounded()) + let latencySuffix = latencyMs <= 10_000 ? " · \(latencyMs)ms" : "" + accountConnectSuccessClearTask?.cancel() + accountConnectSuccessLabel = "Connected via \(route.connectedLabel)\(latencySuffix)" + accountConnectSuccessClearTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 4_000_000_000) + guard !Task.isCancelled else { return } + self?.accountConnectSuccessLabel = nil + self?.accountConnectSuccessClearTask = nil + } + } + + private func performAccountAdoptionChallenge( + expectedHostIdentity: String, + signingPublicKey: Curve25519.Signing.PublicKey, + machineName: String, + isCurrentCandidate: () -> Bool + ) async throws -> AccountAdoptionChallengeSession { + let clientPrivateKey = Curve25519.KeyAgreement.PrivateKey() + var generator = SystemRandomNumberGenerator() + var nonceBytes = [UInt8](repeating: 0, count: 32) + for index in nonceBytes.indices { + nonceBytes[index] = UInt8.random(in: .min ... .max, using: &generator) + } + let nonce = Data(nonceBytes) + let nonceBase64 = nonce.base64EncodedString() + let clientEphemeralPublicKey = clientPrivateKey.publicKey.rawRepresentation.base64EncodedString() + let requestId = makeRequestId() + let raw = try await awaitResponse( + requestId: requestId, + disconnectOnTimeout: false, + timeoutMessage: "That Mac did not answer the secure identity challenge.", + timeoutNanoseconds: AdoptChannelCrypto.challengeTimeoutNanoseconds + ) { + self.sendEnvelope(type: "account_challenge", requestId: requestId, payload: [ + "v": 1, + "nonce": nonceBase64, + "clientEphemeralPublicKey": clientEphemeralPublicKey, + ]) + } + guard isCurrentCandidate() else { + throw AccountPairingConnectionSupersededError() + } + guard let payload = raw as? [String: Any], + (payload["v"] as? NSNumber)?.intValue == 1, + let hostDeviceId = syncNonEmpty(payload["hostDeviceId"] as? String), + hostDeviceId == expectedHostIdentity, + let timestampNumber = payload["ts"] as? NSNumber, + let hostEphemeralPublicKeyBase64 = payload["hostEphemeralPublicKey"] as? String, + let hostEphemeralPublicKey = AdoptChannelCrypto.decodeCanonicalBase64( + hostEphemeralPublicKeyBase64, + expectedBytes: 32 + ), + let signatureBase64 = payload["signature"] as? String, + let signature = AdoptChannelCrypto.decodeCanonicalBase64(signatureBase64, expectedBytes: 64) else { + throw AccountAdoptionIdentityVerificationError(machineName: machineName) + } + let timestampValue = timestampNumber.doubleValue + guard timestampValue.isFinite, + timestampValue.rounded(.towardZero) == timestampValue, + timestampValue >= 0, + timestampValue <= Double(Int64.max) else { + throw AccountAdoptionIdentityVerificationError(machineName: machineName) + } + let timestampMilliseconds = Int64(timestampValue) + let nowMilliseconds = Date().timeIntervalSince1970 * 1_000 + guard abs(Double(timestampMilliseconds) - nowMilliseconds) + <= AdoptChannelCrypto.maximumClockSkewMilliseconds else { + throw AccountAdoptionIdentityVerificationError(machineName: machineName) + } + let canonical = AdoptChannelCrypto.challengeSignatureInput( + hostDeviceId: hostDeviceId, + nonce: nonceBase64, + clientEphemeralPublicKey: clientEphemeralPublicKey, + hostEphemeralPublicKey: hostEphemeralPublicKeyBase64, + timestampMilliseconds: timestampMilliseconds + ) + guard signingPublicKey.isValidSignature(signature, for: Data(canonical.utf8)) else { + throw AccountAdoptionIdentityVerificationError(machineName: machineName) + } + do { + return AccountAdoptionChallengeSession( + key: try AdoptChannelCrypto.deriveSessionKey( + clientPrivateKey: clientPrivateKey, + hostPublicKeyRaw: hostEphemeralPublicKey, + nonce: nonce + ), + hostDeviceId: hostDeviceId + ) + } catch { + throw AccountAdoptionIdentityVerificationError(machineName: machineName) + } + } + + private func performAccountAdoptionHello( + route: AccountAdoptionRoute, + expectedHostIdentity: String, + signingPublicKey: Curve25519.Signing.PublicKey?, + machineName: String, + owner: String, + authorization: AccountPairingAuthorization, + generation: UInt64, + isCurrentCandidate: @escaping () -> Bool + ) async throws -> Any { + guard isCurrentConnectAttempt(generation) else { + throw AccountPairingConnectionSupersededError() + } + let challenge: AccountAdoptionChallengeSession? + if route.usesSealedCredentials { + guard let signingPublicKey else { + throw AccountAdoptionIdentityVerificationError(machineName: machineName) + } + accountConnectStageLabel = "Verifying it's really \(machineName)…" + challenge = try await performAccountAdoptionChallenge( + expectedHostIdentity: expectedHostIdentity, + signingPublicKey: signingPublicKey, + machineName: machineName, + isCurrentCandidate: isCurrentCandidate + ) + } else { + guard route.kind == .relay else { + throw NSError( + domain: "ADE.AdoptChannel", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "Refusing to send account credentials over an unsealed direct route."] + ) + } + challenge = nil + } + + let relaySession = try await AccountService.shared.freshRelaySession( + expectedAuthorization: authorization + ) + guard isCurrentCandidate() else { + throw AccountPairingConnectionSupersededError() + } + guard relaySession.authorization.ownerId == owner, + let dpop = DpopKeyService.shared.buildProof( + deviceId: deviceId, + secret: relaySession.token + ) else { + throw NSError( + domain: "ADE", + code: 31, + userInfo: [NSLocalizedDescriptionKey: "This iPhone could not create a secure account proof."] + ) + } + + let legacyAccountAuth: [String: Any] = [ + "deviceId": deviceId, + "accountToken": relaySession.token, + "dpop": dpop, + ] + let auth: [String: Any] + if let challenge { + let plaintext = try adeJSONData(withJSONObject: legacyAccountAuth) + auth = [ + "kind": "account_sealed", + "v": 1, + "deviceId": deviceId, + "sealed": try AdoptChannelCrypto.seal( + plaintext, + key: challenge.key, + aad: AdoptChannelCrypto.helloAAD( + hostDeviceId: challenge.hostDeviceId, + clientDeviceId: deviceId + ) + ), + ] + } else { + var unsealedAuth = legacyAccountAuth + unsealedAuth["kind"] = "account" + auth = unsealedAuth + } + + let requestId = makeRequestId() + let raw = try await awaitResponse( + requestId: requestId, + timeoutMessage: "That Mac did not finish account connection. Try again." + ) { + self.sendEnvelope(type: "hello", requestId: requestId, payload: [ + "peer": self.currentPeerMetadata(), + "auth": auth, + ]) + } + guard isCurrentCandidate() else { + throw AccountPairingConnectionSupersededError() + } + guard let challenge else { return raw } + guard let sealedPayload = raw as? [String: Any], + (sealedPayload["v"] as? NSNumber)?.intValue == 1, + let sealed = sealedPayload["sealed"] as? String else { + throw AccountAdoptionIdentityVerificationError(machineName: machineName) + } + do { + let plaintext = try AdoptChannelCrypto.unseal( + sealed, + key: challenge.key, + aad: AdoptChannelCrypto.helloOkAAD( + hostDeviceId: challenge.hostDeviceId, + clientDeviceId: deviceId + ) + ) + guard let payload = try JSONSerialization.jsonObject(with: plaintext) as? [String: Any] else { + throw NSError(domain: "ADE.AdoptChannel", code: 5) + } + return payload + } catch { + throw AccountAdoptionIdentityVerificationError(machineName: machineName) + } + } + /// Connects a directory machine using the signed-in Clerk session. The /// bearer token is used over the directory-verified WSS relay to mint the /// same device-bound paired secret used by QR/PIN/SSH. Later direct reconnects @@ -4487,6 +4965,12 @@ final class SyncService: ObservableObject { authorization: AccountPairingAuthorization ) async -> Bool { prepareForUserConnectionChange() + accountConnectSuccessClearTask?.cancel() + accountConnectSuccessClearTask = nil + accountConnectSuccessLabel = nil + accountConnectStageLabel = nil + accountPairingPinFallbackHost = nil + defer { accountConnectStageLabel = nil } ProductAnalytics.shared.captureQuickConnect(.accountMachine) let owner = authorization.ownerId.trimmingCharacters(in: .whitespacesAndNewlines) let expectedHostIdentity = syncNonEmpty(machine.deviceId) @@ -4539,14 +5023,55 @@ final class SyncService: ObservableObject { return reconnected } - guard !relayRoutes.isEmpty else { - lastError = "That Mac is not ready for account connection yet. Open ADE on the Mac and try again." + let directoryPubkey = syncNonEmpty(machine.pubkey) + let signingPublicKey: Curve25519.Signing.PublicKey? + do { + signingPublicKey = try directoryPubkey.map { + try AdoptChannelCrypto.signingPublicKey(fromDirectoryValue: $0) + } + } catch { + let identityError = AccountAdoptionIdentityVerificationError(machineName: machine.displayName) + lastError = identityError.localizedDescription + connectionState = .error + ProductAnalytics.shared.captureMachineAdoptionOutcome(.failed) + ProductAnalytics.shared.captureError(.pairing) + return false + } + + let routes = accountAdoptionRoutes( + for: machine, + hasSigningKey: signingPublicKey != nil + ) + guard !routes.isEmpty else { + if signingPublicKey == nil { + lastError = "That Mac is not ready for account connection yet. Open ADE on the Mac and try again." + } else { + lastError = "That Mac did not advertise a secure account connection route. Open ADE on the Mac and try again." + } connectionState = .error ProductAnalytics.shared.captureMachineAdoptionOutcome(.failed) return false } + let classifiedDirectEndpoints = machine.reachableEndpoints.compactMap { + endpoint -> (kind: AccountMachineEndpoint.Kind, attempt: SyncConnectionEndpointAttempt)? in + guard endpoint.kind != .relay, + let attempt = syncAccountAdoptionEndpointAttempt(endpoint) else { + return nil + } + return (endpoint.kind, attempt) + } + let lanHosts = deduplicatedAddresses( + classifiedDirectEndpoints.compactMap { $0.kind == .lan ? $0.attempt.address : nil } + ) + let tailnetHosts = deduplicatedAddresses( + classifiedDirectEndpoints.compactMap { $0.kind == .tailnet ? $0.attempt.address : nil } + ) + let preferredDirectPort = classifiedDirectEndpoints.first(where: { $0.kind == .lan })?.attempt.port + ?? classifiedDirectEndpoints.first(where: { $0.kind == .tailnet })?.attempt.port + var pairingGeneration: UInt64? + var relayRouteFailed = false do { try ensureDatabaseReady() refreshPhoneTailnetInterfaceState() @@ -4559,16 +5084,23 @@ final class SyncService: ObservableObject { resetChatEventState(clearHistory: true) connectionState = .connecting + markConnectAttemptStarted(generation) var lastFailure: Error? - for relay in relayRoutes { + var triedRouteLabels: [String] = [] + for route in routes { var candidateSocket: URLSessionWebSocketTask? + let routeAttemptStartedAt = ProcessInfo.processInfo.systemUptime + accountConnectStageLabel = route.stageLabel + if !triedRouteLabels.contains(route.attemptLabel) { + triedRouteLabels.append(route.attemptLabel) + } do { guard AccountService.shared.isPairingCommitAuthorized(authorization) else { throw AccountPairingAuthorizationChangedError() } try await openSocket( - host: relay, - port: syncParseRouteEndpoint(relay)?.port ?? SyncDirectHostPorts.defaultPort, + host: route.endpoint.address, + port: route.endpoint.port, connectAttemptGeneration: generation ) guard let openedSocket = socket else { @@ -4582,41 +5114,15 @@ final class SyncService: ObservableObject { let isCurrentCandidate = { self.isCurrentConnectAttempt(generation) && self.socket === openedSocket } - let raw = try await performCurrentAccountPairingRelayHello( - refreshSession: { - try await AccountService.shared.freshRelaySession( - expectedAuthorization: authorization - ) - }, - isCurrentCandidate: isCurrentCandidate, - sendHello: { relaySession in - guard relaySession.authorization.ownerId == owner, - let dpop = DpopKeyService.shared.buildProof( - deviceId: self.deviceId, - secret: relaySession.token - ) else { - throw NSError( - domain: "ADE", - code: 31, - userInfo: [NSLocalizedDescriptionKey: "This iPhone could not create a secure account proof."] - ) - } - let requestId = self.makeRequestId() - return try await self.awaitResponse( - requestId: requestId, - timeoutMessage: "That Mac did not finish account connection. Try again." - ) { - self.sendEnvelope(type: "hello", requestId: requestId, payload: [ - "peer": self.currentPeerMetadata(), - "auth": [ - "kind": "account", - "deviceId": self.deviceId, - "accountToken": relaySession.token, - "dpop": dpop, - ], - ]) - } - } + let raw = try await performAccountAdoptionHello( + route: route, + expectedHostIdentity: expectedHostIdentity, + signingPublicKey: signingPublicKey, + machineName: machine.displayName, + owner: owner, + authorization: authorization, + generation: generation, + isCurrentCandidate: isCurrentCandidate ) _ = try await performAuthorizedAccountPairingCommit( authorization: authorization, @@ -4625,11 +5131,7 @@ final class SyncService: ObservableObject { guard let payload = raw as? [String: Any], let brain = payload["brain"] as? [String: Any], self.syncNonEmpty(brain["deviceId"] as? String) == expectedHostIdentity else { - throw NSError( - domain: "ADE", - code: 32, - userInfo: [NSLocalizedDescriptionKey: "The connected Mac did not match your account."] - ) + throw AccountAdoptionIdentityVerificationError(machineName: machine.displayName) } let pairing = payload["accountPairing"] as? [String: Any] guard self.syncNonEmpty(pairing?["deviceId"] as? String) == self.deviceId, @@ -4643,22 +5145,19 @@ final class SyncService: ObservableObject { let advertisedRelay = self.syncNonEmpty(payload["cloudRelayWssUrl"] as? String) let allRelays = self.deduplicatedAddresses(relayRoutes + (advertisedRelay.map { [$0] } ?? [])) - let relayPort = syncParseRouteEndpoint(relay)?.port ?? SyncDirectHostPorts.defaultPort let profile = HostConnectionProfile( hostIdentity: expectedHostIdentity, hostName: self.syncNonEmpty(brain["deviceName"] as? String) ?? machine.displayName, siteId: self.syncNonEmpty(brain["siteId"] as? String), - port: machine.directConnectTarget?.port ?? relayPort, + port: preferredDirectPort ?? route.endpoint.port, authKind: "paired", pairedDeviceId: self.deviceId, lastRemoteDbVersion: 0, lastHostDeviceId: expectedHostIdentity, - lastSuccessfulAddress: relay, + lastSuccessfulAddress: route.endpoint.address, savedAddressCandidates: directHosts, - discoveredLanAddresses: directHosts.filter { - !$0.contains(":") && $0 != "127.0.0.1" && !syncIsTailscaleRoute($0) - }, - tailscaleAddress: directHosts.first(where: syncIsTailscaleRoute), + discoveredLanAddresses: lanHosts, + tailscaleAddress: tailnetHosts.first, savedRelayCandidates: allRelays, accountOwnerId: owner, relayAccountOwnerId: owner @@ -4677,7 +5176,7 @@ final class SyncService: ObservableObject { self.saveProfile(prepared.profile) try self.applyHelloPayload( prepared.payload, - connectedHost: relay, + connectedHost: route.endpoint.address, port: prepared.profile.port, authKind: "paired", pairedDeviceId: self.deviceId, @@ -4686,27 +5185,37 @@ final class SyncService: ObservableObject { ) } ) + lastConnectedRouteKind = route.connectionRouteKind schedulePostHelloWork(for: generation) Task { await PushNotificationService.shared.enableIfPaired() } LiveActivityService.shared.start() + accountPairingPinFallbackHost = nil + publishAccountConnectSuccess( + route: route, + attemptStartedAt: routeAttemptStartedAt + ) ProductAnalytics.shared.captureMachineAdoptionOutcome(.adopted) return true } catch { lastFailure = error + if route.kind == .relay { + relayRouteFailed = true + } if let candidateSocket, socket === candidateSocket { teardownSocket() } - if error is AccountPairingAuthorizationChangedError + if error is AccountAdoptionIdentityVerificationError + || error is AccountPairingAuthorizationChangedError || error is AccountPairingConnectionSupersededError || !isCurrentConnectAttempt(generation) { throw error } } } - throw lastFailure ?? NSError( - domain: "ADE", - code: 34, - userInfo: [NSLocalizedDescriptionKey: "Could not reach that Mac."] + throw AccountAdoptionRoutesExhaustedError( + machineName: machine.displayName, + routeLabels: triedRouteLabels, + finalFailure: lastFailure ) } catch { if error is AccountPairingConnectionSupersededError @@ -4719,6 +5228,12 @@ final class SyncService: ObservableObject { setAutoReconnectPausedByUser(true) teardownSocket(reason: message) clearConnectTimingMetrics() + accountConnectSuccessClearTask?.cancel() + accountConnectSuccessClearTask = nil + accountConnectSuccessLabel = nil + accountPairingPinFallbackHost = signingPublicKey == nil && relayRouteFailed + ? pinFallbackHost(for: machine) + : nil lastError = message connectionState = .error setDomainStatus(SyncDomain.allCases, phase: .failed, error: message) @@ -13064,6 +13579,17 @@ final class SyncService: ObservableObject { try await handleIncoming(nested) } } + case "account_challenge_ok": + resolve(requestId: requestId, result: .success(payload)) + case "account_challenge_error": + let challengeError = payload as? [String: Any] + let message = syncNonEmpty(challengeError?["message"] as? String) + ?? "That route could not verify the Mac's identity." + resolve(requestId: requestId, result: .failure(NSError( + domain: "ADE.AdoptChannel", + code: 6, + userInfo: [NSLocalizedDescriptionKey: message] + ))) case "hello_ok": resolve(requestId: requestId, result: .success(payload)) case "relay_reauthorize_result": diff --git a/apps/ios/ADE/Views/Account/MobileAccessGateView.swift b/apps/ios/ADE/Views/Account/MobileAccessGateView.swift index c8e099b8b..6c4e1c0c3 100644 --- a/apps/ios/ADE/Views/Account/MobileAccessGateView.swift +++ b/apps/ios/ADE/Views/Account/MobileAccessGateView.swift @@ -10,6 +10,8 @@ struct MobileAccessGateView: View { @EnvironmentObject private var syncService: SyncService @State private var presentedSheet: MobileAccessGateSheet? @State private var accountConnectionError: String? + @State private var pinPreset: PinPreset? + @State private var pinSetupRoute: PinSetupRoute? var body: some View { NavigationStack { @@ -86,11 +88,30 @@ struct MobileAccessGateView: View { } .buttonStyle(.plain) - if let accountConnectionError { - Text(accountConnectionError) - .font(.footnote) - .foregroundStyle(ADEColor.danger) + if syncService.connectionState == .connecting, + let stage = syncService.accountConnectStageLabel { + Text(stage) + .font(.footnote.weight(.medium)) + .foregroundStyle(ADEColor.textSecondary) .multilineTextAlignment(.center) + .transition(.opacity) + } + + if let accountConnectionError { + VStack(spacing: 8) { + Text(accountConnectionError) + .font(.footnote) + .foregroundStyle(ADEColor.danger) + .multilineTextAlignment(.center) + if let fallbackHost = syncService.accountPairingPinFallbackHost { + Button("Pair with PIN instead") { + pinPreset = .discover(fallbackHost) + } + .font(.footnote.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .buttonStyle(.plain) + } + } } } .frame(maxWidth: 420) @@ -116,19 +137,46 @@ struct MobileAccessGateView: View { ConnectionSettingsView(syncService: syncService, pairingOnly: true) } } + .sheet(item: $pinPreset) { preset in + SettingsPinSheet( + preset: preset, + syncService: syncService, + onNeedsPinSetup: { route in + pinPreset = nil + pinSetupRoute = route + } + ) + .presentationDetents([.large]) + } + .sheet(item: $pinSetupRoute) { route in + SettingsPinSetupSheet( + route: route, + onTryAgain: { preset in + pinSetupRoute = nil + pinPreset = preset + } + ) + .presentationDetents([.large]) + } .onChange(of: syncService.requestedPairingQrNavigation?.id) { _, requestId in guard requestId != nil else { return } presentedSheet = .pairMachine } .onChange(of: syncService.hasPairedHost) { _, isPaired in - guard isPaired, presentedSheet == .pairMachine else { return } - presentedSheet = nil - onContinue() + guard isPaired else { return } + if presentedSheet == .pairMachine { + presentedSheet = nil + onContinue() + } else if pinPreset != nil { + pinPreset = nil + onContinue() + } } } private func connectToAccountMachine(_ machine: AccountMachine) { Task { @MainActor in + accountConnectionError = nil guard let authorization = AccountService.shared.currentPairingAuthorization else { accountConnectionError = "Your account session ended. Sign in again, then choose your Mac." return @@ -138,6 +186,7 @@ struct MobileAccessGateView: View { authorization: authorization ) if connected { + ADEHaptics.medium() onContinue() } else { accountConnectionError = syncService.lastError ?? "ADE could not connect to that Mac. Try again." diff --git a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift index d0627d1d5..fa679355e 100644 --- a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift +++ b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift @@ -38,6 +38,11 @@ struct ConnectionSettingsView: View { onDisconnect: { syncService.disconnectForUserConnectionChange() }, onReconnect: { Task { await syncService.reconnectForUserConnectionChange() } + }, + onPairWithPin: { + if let host = syncService.accountPairingPinFallbackHost { + pinPreset = .discover(host) + } } ) @@ -66,6 +71,11 @@ struct ConnectionSettingsView: View { onDisconnect: { syncService.disconnectForUserConnectionChange() }, onReconnect: { Task { await syncService.reconnectForUserConnectionChange() } + }, + onPairWithPin: { + if let host = syncService.accountPairingPinFallbackHost { + pinPreset = .discover(host) + } } ) } @@ -73,7 +83,12 @@ struct ConnectionSettingsView: View { // 3. Connections: your machines (top 3 + See all), then how to add. VStack(alignment: .leading, spacing: 16) { - SettingsMachinesSection(syncService: syncService) + SettingsMachinesSection( + syncService: syncService, + onPairWithPin: { host in + pinPreset = .discover(host) + } + ) SettingsPairingSection( snapshot: presentationModel.pairingSnapshot, @@ -141,6 +156,15 @@ struct ConnectionSettingsView: View { .presentationDetents([.large]) } .preferredColorScheme(colorSchemeChoice.preferredColorScheme) + .overlay(alignment: .top) { + if let label = syncService.accountConnectSuccessLabel { + AccountConnectStatusToast(label: label) + .padding(.horizontal, 20) + .padding(.top, 12) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.spring(response: 0.35, dampingFraction: 0.86), value: syncService.accountConnectSuccessLabel) .onAppear { presentationModel.bind(to: syncService) if let request = syncService.requestedPairingQrNavigation { @@ -228,6 +252,8 @@ struct SettingsConnectionSnapshot: Equatable { var pendingHostName: String? var canReconnectToSavedHost: Bool var errorMessage: String? + var accountConnectStageLabel: String? + var canPairWithPin = false var hostCompatibilityMode: SyncHostCompatibilityMode = .unknown var hostCompatibilityMissingActions: [String] = [] } @@ -311,6 +337,8 @@ private final class SettingsConnectionPresentationModel: ObservableObject { pendingHostName: health.transport == .connecting || health.transport == .unreachable ? hostDisplayName : nil, canReconnectToSavedHost: syncService.canReconnectToSavedHost, errorMessage: health.transport == .unreachable ? health.lastFailureMessage : nil, + accountConnectStageLabel: syncService.accountConnectStageLabel, + canPairWithPin: syncService.accountPairingPinFallbackHost != nil, hostCompatibilityMode: syncService.hostCompatibilityMode, hostCompatibilityMissingActions: syncService.hostCompatibilityMissingActions ) @@ -636,6 +664,7 @@ private func mobileUsageSettingsResetLabel(_ iso: String) -> String { /// tapped row (M14) rather than in a separate lower banner. struct SettingsMachinesSection: View { let syncService: SyncService + let onPairWithPin: (DiscoveredSyncHost) -> Void @ObservedObject private var account = AccountService.shared @State private var seeAllPresented = false @@ -832,9 +861,26 @@ struct SettingsMachinesSection: View { .accessibilityHint(tappable ? "Connect." : "") if let error = rowErrors[entry.id] { - Text(error) + VStack(alignment: .leading, spacing: 7) { + Text(error) + .font(.caption) + .foregroundStyle(ADEColor.danger) + .fixedSize(horizontal: false, vertical: true) + if let fallbackHost = pinFallbackHost(for: entry) { + Button("Pair with PIN instead") { + onPairWithPin(fallbackHost) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + .padding(.top, 6) + } else if isConnecting, let stage = syncService.accountConnectStageLabel { + Text(stage) .font(.caption) - .foregroundStyle(ADEColor.danger) + .foregroundStyle(ADEColor.textSecondary) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 12) .padding(.top, 6) @@ -857,6 +903,15 @@ struct SettingsMachinesSection: View { } } + private func pinFallbackHost(for entry: Entry) -> DiscoveredSyncHost? { + guard case .account(let machine) = entry.kind, + let fallback = syncService.accountPairingPinFallbackHost, + fallback.hostIdentity == machine.deviceId else { + return nil + } + return fallback + } + private func connect(_ entry: Entry) { guard connectingId == nil else { return } connectingId = entry.id diff --git a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift index d172d6c5a..63c01d6fd 100644 --- a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift +++ b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift @@ -28,6 +28,7 @@ struct SettingsConnectionHeader: View { let snapshot: SettingsConnectionSnapshot let onDisconnect: () -> Void let onReconnect: () -> Void + var onPairWithPin: (() -> Void)? @State private var pulsing = false @@ -85,7 +86,11 @@ struct SettingsConnectionHeader: View { if let errorMessage, !health.transport.isConnected { - SettingsInlineErrorBanner(message: errorMessage) + SettingsInlineErrorBanner( + message: errorMessage, + actionTitle: snapshot.canPairWithPin ? "Pair with PIN instead" : nil, + onAction: onPairWithPin + ) } if let compatibilityMessage { @@ -184,7 +189,7 @@ struct SettingsConnectionHeader: View { // Name the machine you're attached to, right under the status word. return snapshot.hostDisplayName case .connecting: - return "Connecting to saved machine" + return snapshot.accountConnectStageLabel ?? "Connecting to saved machine" case .unreachable: return "Unable to reach your machine" case .disconnected: @@ -357,16 +362,26 @@ private struct SettingsStatusDot: View { private struct SettingsInlineErrorBanner: View { let message: String + var actionTitle: String? + var onAction: (() -> Void)? var body: some View { HStack(alignment: .top, spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .font(.caption.weight(.semibold)) .foregroundStyle(ADEColor.danger) - Text(message) - .font(.caption) - .foregroundStyle(ADEColor.danger) - .fixedSize(horizontal: false, vertical: true) + VStack(alignment: .leading, spacing: 7) { + Text(message) + .font(.caption) + .foregroundStyle(ADEColor.danger) + .fixedSize(horizontal: false, vertical: true) + if let actionTitle, let onAction { + Button(actionTitle, action: onAction) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .buttonStyle(.plain) + } + } } .padding(.horizontal, 10) .padding(.vertical, 8) diff --git a/apps/ios/ADETests/PairingAndDpopTests.swift b/apps/ios/ADETests/PairingAndDpopTests.swift index d5c3b8782..ca71530b0 100644 --- a/apps/ios/ADETests/PairingAndDpopTests.swift +++ b/apps/ios/ADETests/PairingAndDpopTests.swift @@ -1,7 +1,19 @@ +import CryptoKit import XCTest import Security @testable import ADE +private func pairingTestData(hex: String) -> Data { + var data = Data() + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + data.append(UInt8(hex[index.. (HTTPURLResponse, Data))? @@ -114,6 +126,65 @@ final class PairingAndDpopTests: XCTestCase { XCTAssertEqual(refreshCount, 1) } + // MARK: - Sealed account adoption + + func testAdoptChannelMatchesTypeScriptChaChaPolyVector() throws { + // Fixed vector from + // apps/desktop/src/shared/sync/adoptChannelCrypto.test.ts. + let clientPrivateKey = try Curve25519.KeyAgreement.PrivateKey( + rawRepresentation: pairingTestData( + hex: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + ) + ) + XCTAssertEqual( + clientPrivateKey.publicKey.rawRepresentation, + pairingTestData( + hex: "8f40c5adb68f25624ae5b214ea767a6ec94d829d3d7b5e1ad1ba6f3e2138285f" + ) + ) + let hostPublicKey = pairingTestData( + hex: "358072d6365880d1aeea329adf9121383851ed21a28e3b75e965d0d2cd166254" + ) + let nonce = pairingTestData( + hex: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + ) + let sessionKey = try AdoptChannelCrypto.deriveSessionKey( + clientPrivateKey: clientPrivateKey, + hostPublicKeyRaw: hostPublicKey, + nonce: nonce + ) + let sessionKeyData = sessionKey.withUnsafeBytes { Data($0) } + XCTAssertEqual( + sessionKeyData, + pairingTestData( + hex: "73dd8c3462d2bd6af30580cd4147d5049e6b96d6e0caad0abb512e47ea9c056e" + ) + ) + + let sealed = + "AAECAwQFBgcICQoL2ZbKNSOix6tRlgt6GSfO7OGy0Pp8/04VMGCtmGI+2K5fKpJv27j0R8un/fPj0v1aEAizUH3l7uZ6nwS6WM8f+GJfCvAcH6bo1KfPq04vbY2jLUSXuYo=" + let plaintext = try AdoptChannelCrypto.unseal( + sealed, + key: sessionKey, + aad: Data("ade-adopt-v1|host-vector|client-vector".utf8) + ) + XCTAssertEqual( + String(decoding: plaintext, as: UTF8.self), + #"{"deviceId":"client-vector","accountToken":"token-vector","dpop":null}"# + ) + + XCTAssertEqual( + AdoptChannelCrypto.challengeSignatureInput( + hostDeviceId: "host-device", + nonce: "bm9uY2U=", + clientEphemeralPublicKey: "Y2xpZW50", + hostEphemeralPublicKey: "aG9zdA==", + timestampMilliseconds: 1_783_500_123_456 + ), + "ade-adopt-v1|host-device|bm9uY2U=|Y2xpZW50|aG9zdA==|1783500123456" + ) + } + // MARK: - Pairing QR codec func testParsesCanonicalSmartUrl() throws { From 8c6e87d7aa511f7034ccd4fdd9f9d2c60b21d346 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:03:11 -0400 Subject: [PATCH 05/11] =?UTF-8?q?feat(ios):=20sealed=20account=20adoption?= =?UTF-8?q?=20parity=20=E2=80=94=20fallback=20routes,=20stages,=20PIN=20fa?= =?UTF-8?q?llback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS mirrors ade-adopt-v1 (X25519+HKDF+ChaChaPoly with Ed25519 host identity verified against the directory pubkey, fail-closed on malformed keys), tries relay → Tailscale → LAN for adoption, and gains stage text, a guided pair-with-PIN fallback, connected-route toast, and accessibility support. Co-Authored-By: Claude Fable 5 --- apps/ios/ADE/App/ContentView.swift | 3 +- apps/ios/ADE/Services/SyncService.swift | 45 ++++++++++++----- .../Views/Account/MobileAccessGateView.swift | 1 + apps/ios/ADE/Views/Hub/HubQuickConnect.swift | 49 +++++++++++++++++-- .../Settings/ConnectionSettingsView.swift | 6 ++- .../Settings/SettingsConnectionHeader.swift | 1 + apps/ios/ADETests/PairingAndDpopTests.swift | 17 +++++++ 7 files changed, 104 insertions(+), 18 deletions(-) diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index c6d4839bc..886c85eeb 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -269,7 +269,8 @@ struct AccountConnectStatusToast: View { Text(label) .font(.system(.footnote, design: .rounded).weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) } .padding(.horizontal, 14) .padding(.vertical, 10) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 00841c00f..29d9a9ad3 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -248,6 +248,15 @@ enum AdoptChannelCrypto { return try Curve25519.Signing.PublicKey(rawRepresentation: raw) } + /// Only an absent directory field selects the legacy Relay path. A present + /// but malformed value must fail closed rather than downgrading credentials. + static func signingPublicKey( + fromOptionalDirectoryValue value: String? + ) throws -> Curve25519.Signing.PublicKey? { + guard let value else { return nil } + return try signingPublicKey(fromDirectoryValue: value) + } + static func challengeSignatureInput( hostDeviceId: String, nonce: String, @@ -2942,6 +2951,11 @@ final class SyncService: ObservableObject { /// in-project screen can keep rendering state owned by the previous machine. func prepareForUserConnectionChange() { projectHomePresented = true + accountConnectSuccessClearTask?.cancel() + accountConnectSuccessClearTask = nil + accountConnectSuccessLabel = nil + accountConnectStageLabel = nil + accountPairingPinFallbackHost = nil } func disconnectForUserConnectionChange() { @@ -4744,8 +4758,10 @@ final class SyncService: ObservableObject { let elapsed = max(0, ProcessInfo.processInfo.systemUptime - attemptStartedAt) let latencyMs = Int((elapsed * 1_000).rounded()) let latencySuffix = latencyMs <= 10_000 ? " · \(latencyMs)ms" : "" + let label = "Connected via \(route.connectedLabel)\(latencySuffix)" accountConnectSuccessClearTask?.cancel() - accountConnectSuccessLabel = "Connected via \(route.connectedLabel)\(latencySuffix)" + accountConnectSuccessLabel = label + announceAccountConnectStatus(label) accountConnectSuccessClearTask = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: 4_000_000_000) guard !Task.isCancelled else { return } @@ -4754,6 +4770,17 @@ final class SyncService: ObservableObject { } } + private func publishAccountConnectStage(_ label: String) { + guard accountConnectStageLabel != label else { return } + accountConnectStageLabel = label + announceAccountConnectStatus(label) + } + + private func announceAccountConnectStatus(_ label: String) { + guard UIAccessibility.isVoiceOverRunning else { return } + UIAccessibility.post(notification: .announcement, argument: label) + } + private func performAccountAdoptionChallenge( expectedHostIdentity: String, signingPublicKey: Curve25519.Signing.PublicKey, @@ -4854,7 +4881,7 @@ final class SyncService: ObservableObject { guard let signingPublicKey else { throw AccountAdoptionIdentityVerificationError(machineName: machineName) } - accountConnectStageLabel = "Verifying it's really \(machineName)…" + publishAccountConnectStage("Verifying it's really \(machineName)…") challenge = try await performAccountAdoptionChallenge( expectedHostIdentity: expectedHostIdentity, signingPublicKey: signingPublicKey, @@ -4965,11 +4992,6 @@ final class SyncService: ObservableObject { authorization: AccountPairingAuthorization ) async -> Bool { prepareForUserConnectionChange() - accountConnectSuccessClearTask?.cancel() - accountConnectSuccessClearTask = nil - accountConnectSuccessLabel = nil - accountConnectStageLabel = nil - accountPairingPinFallbackHost = nil defer { accountConnectStageLabel = nil } ProductAnalytics.shared.captureQuickConnect(.accountMachine) let owner = authorization.ownerId.trimmingCharacters(in: .whitespacesAndNewlines) @@ -5023,12 +5045,11 @@ final class SyncService: ObservableObject { return reconnected } - let directoryPubkey = syncNonEmpty(machine.pubkey) let signingPublicKey: Curve25519.Signing.PublicKey? do { - signingPublicKey = try directoryPubkey.map { - try AdoptChannelCrypto.signingPublicKey(fromDirectoryValue: $0) - } + signingPublicKey = try AdoptChannelCrypto.signingPublicKey( + fromOptionalDirectoryValue: machine.pubkey + ) } catch { let identityError = AccountAdoptionIdentityVerificationError(machineName: machine.displayName) lastError = identityError.localizedDescription @@ -5090,7 +5111,7 @@ final class SyncService: ObservableObject { for route in routes { var candidateSocket: URLSessionWebSocketTask? let routeAttemptStartedAt = ProcessInfo.processInfo.systemUptime - accountConnectStageLabel = route.stageLabel + publishAccountConnectStage(route.stageLabel) if !triedRouteLabels.contains(route.attemptLabel) { triedRouteLabels.append(route.attemptLabel) } diff --git a/apps/ios/ADE/Views/Account/MobileAccessGateView.swift b/apps/ios/ADE/Views/Account/MobileAccessGateView.swift index 6c4e1c0c3..427ba1a3e 100644 --- a/apps/ios/ADE/Views/Account/MobileAccessGateView.swift +++ b/apps/ios/ADE/Views/Account/MobileAccessGateView.swift @@ -109,6 +109,7 @@ struct MobileAccessGateView: View { } .font(.footnote.weight(.semibold)) .foregroundStyle(ADEColor.accent) + .frame(minHeight: 44) .buttonStyle(.plain) } } diff --git a/apps/ios/ADE/Views/Hub/HubQuickConnect.swift b/apps/ios/ADE/Views/Hub/HubQuickConnect.swift index 15b929800..c102f310e 100644 --- a/apps/ios/ADE/Views/Hub/HubQuickConnect.swift +++ b/apps/ios/ADE/Views/Hub/HubQuickConnect.swift @@ -23,6 +23,8 @@ struct HubQuickConnectSection: View { @State private var connectingId: String? @State private var errorText: String? + @State private var pinPreset: PinPreset? + @State private var pinSetupRoute: PinSetupRoute? private enum Target: Identifiable { case account(AccountMachine) @@ -105,13 +107,31 @@ struct HubQuickConnectSection: View { .font(.subheadline.bold()) .frame(minHeight: 44) } - if let errorText { - Text(errorText) - .font(.caption) - .foregroundStyle(ADEColor.danger) + if connectingId != nil, let stage = syncService.accountConnectStageLabel { + Text(stage) + .font(.caption.weight(.medium)) + .foregroundStyle(ADEColor.textSecondary) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) } + if let errorText { + VStack(spacing: 7) { + Text(errorText) + .font(.caption) + .foregroundStyle(ADEColor.danger) + .multilineTextAlignment(.center) + if let fallbackHost = syncService.accountPairingPinFallbackHost { + Button("Pair with PIN instead") { + pinPreset = .discover(fallbackHost) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .frame(minHeight: 44) + .buttonStyle(.plain) + } + } + .frame(maxWidth: .infinity) + } } } else if showsEmptyNote { HStack(spacing: 8) { @@ -129,6 +149,27 @@ struct HubQuickConnectSection: View { } .frame(maxWidth: 420) .task { await account.loadMachines() } + .sheet(item: $pinPreset) { preset in + SettingsPinSheet( + preset: preset, + syncService: syncService, + onNeedsPinSetup: { route in + pinPreset = nil + pinSetupRoute = route + } + ) + .presentationDetents([.large]) + } + .sheet(item: $pinSetupRoute) { route in + SettingsPinSetupSheet( + route: route, + onTryAgain: { preset in + pinSetupRoute = nil + pinPreset = preset + } + ) + .presentationDetents([.large]) + } } private func accountKey(_ machine: AccountMachine) -> String { "account-\(machine.id)" } diff --git a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift index fa679355e..c5952d594 100644 --- a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift +++ b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift @@ -210,10 +210,13 @@ struct ConnectionSettingsView: View { guard let authorization = AccountService.shared.currentPairingAuthorization else { return } - _ = await syncService.pairWithAccountMachine( + let connected = await syncService.pairWithAccountMachine( machine, authorization: authorization ) + if connected { + ADEHaptics.medium() + } } } @@ -872,6 +875,7 @@ struct SettingsMachinesSection: View { } .font(.caption.weight(.semibold)) .foregroundStyle(ADEColor.accent) + .frame(minHeight: 44) .buttonStyle(.plain) } } diff --git a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift index 63c01d6fd..343db7d7f 100644 --- a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift +++ b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift @@ -379,6 +379,7 @@ private struct SettingsInlineErrorBanner: View { Button(actionTitle, action: onAction) .font(.caption.weight(.semibold)) .foregroundStyle(ADEColor.accent) + .frame(minHeight: 44) .buttonStyle(.plain) } } diff --git a/apps/ios/ADETests/PairingAndDpopTests.swift b/apps/ios/ADETests/PairingAndDpopTests.swift index ca71530b0..492894562 100644 --- a/apps/ios/ADETests/PairingAndDpopTests.swift +++ b/apps/ios/ADETests/PairingAndDpopTests.swift @@ -185,6 +185,23 @@ final class PairingAndDpopTests: XCTestCase { ) } + func testAdoptChannelRejectsPresentMalformedDirectorySigningKey() throws { + XCTAssertNil( + try AdoptChannelCrypto.signingPublicKey(fromOptionalDirectoryValue: nil) + ) + XCTAssertThrowsError( + try AdoptChannelCrypto.signingPublicKey(fromOptionalDirectoryValue: "") + ) + XCTAssertThrowsError( + try AdoptChannelCrypto.signingPublicKey(fromOptionalDirectoryValue: " ") + ) + XCTAssertThrowsError( + try AdoptChannelCrypto.signingPublicKey( + fromOptionalDirectoryValue: "ed25519:not-canonical-base64" + ) + ) + } + // MARK: - Pairing QR codec func testParsesCanonicalSmartUrl() throws { From f1c0e970f9446ca91f16d1c1f9c40a29a3e4fdc2 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:03:57 -0400 Subject: [PATCH 06/11] docs: describe sealed adoption fallback, zombie detection, and honest relay publication Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 +++++++ docs/features/remote-runtime/README.md | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d1ac09f3..99b37d348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Machine connections + +- Detected "zombie" Relay control sockets with a relay-answered JSON keepalive, reconnecting within minutes when Cloudflare silently loses the host's registration (previously the host could look healthy for hours while every incoming connection was refused). +- Verified the full Relay path end-to-end by self-dialing this machine's own connect route after every control open and bridge validation, and published the Relay endpoint to the account directory only after that probe passes; `ade sync status` and `ade doctor` now report the honest end-to-end verdict. +- Introduced the sealed `ade-adopt-v1` adoption handshake: machines publish an ed25519 identity key in their directory row, sign the adopting client's challenge over an ephemeral X25519 exchange, and exchange account credentials ChaCha20-Poly1305-sealed — enabling account adoption to fall back from Relay to Tailscale and LAN routes on desktop, `ade code remote`, and iOS. +- Surfaced account-machine connect failures in the desktop Connections panel and iOS (previously silent), with live route stage text, a connected-via route/latency note, and a one-tap jump into Nearby + PIN pairing when the machine is discoverable locally. + ## [1.2.35] - 2026-07-22 ### Relay recovery diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index f87ce218a..8914ba59e 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -211,8 +211,20 @@ run a local repair against data owned by the remote machine. See removes this machine's own Bonjour advertisement, and merges routes that identify the same machine. Discovered paired-capable ADE desktops appear in Available; offline or unsupported machines remain visible in Unavailable. -2. Select a same-account Mac for the primary PIN-less flow; ADE adopts it over - the directory-verified Relay and saves the returned DPoP-bound credentials. +2. Select a same-account Mac for the primary PIN-less flow. ADE dials the + directory-verified Relay first; when the target publishes an ed25519 + identity key in its directory row (`pubkey`), adoption can also fall back + to Tailscale and LAN routes using the sealed `ade-adopt-v1` handshake — + the host signs the client's challenge nonce over an ephemeral X25519 + exchange, the client verifies that signature against the directory key + before releasing any account credential, and both the account attestation + and the returned paired credentials travel ChaCha20-Poly1305-sealed. A + host-identity verification failure aborts adoption immediately (no route + is retried); hosts without a published key remain relay-only-adoptable. + Successful adoption saves the returned DPoP-bound credentials either way. + While connecting, the machine row reports the route being tried, and a + failure surfaces inline with a one-tap jump into Nearby + PIN pairing + when the same machine is discoverable locally. Without an account, choose **Find nearby Macs**, select a discovered LAN or Tailscale machine, and enter the six-digit PIN shown on that Mac's **This Mac** Connections card. There is no desktop pairing-link paste/scan or From 1035ed5d7a9f408c5e0c488c163ad7f17595cfed Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:24:29 -0400 Subject: [PATCH 07/11] =?UTF-8?q?fix(sync):=20harden=20adoption=20from=20q?= =?UTF-8?q?uality=20review=20=E2=80=94=20DoS,=20self-probe=20cap,=20downgr?= =?UTF-8?q?ade=20edges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adopt-challenge issuance: only malformed/anomalous challenges feed the cross-tenant global rate limiter; well-formed signing requests count per-IP only, so a few cheap frames (or one failed multi-route adoption) can no longer lock out account adoption host-wide. - Relay self-probe: a CLOSE_TOO_MANY (at tunnel cap) rejection is proof the control socket is alive and serving clients — treat it as liveness, not a zombie verdict that would terminate control and drop the relay endpoint. - Directory pubkey: preserve a present-but-blank key so it fails closed at verification like iOS, instead of silently downgrading to plaintext relay. - Host challenge decline (cooldown) now surfaces the host's real reason and fails over to the next route instead of a misleading identity-abort. - Publisher uses the shared signing-key filename constant; drop dead keepalive sleep/wake branch; iOS connectedLabel reuses attemptLabel. Co-Authored-By: Claude Fable 5 --- .../account/accountMachinePublisherService.ts | 7 ++- .../src/services/sync/syncHostService.ts | 29 ++++++--- .../src/services/sync/syncRelaySelfProbe.ts | 12 +++- .../services/sync/syncTunnelClientService.ts | 38 +++++++++--- .../syncPairedMachineStore.test.ts | 61 ++++++++++++++++--- .../remoteRuntime/syncPairedMachineStore.ts | 13 +++- apps/desktop/src/shared/accountDirectory.ts | 6 +- apps/ios/ADE/Services/SyncService.swift | 10 +-- docs/features/remote-runtime/README.md | 10 +++ 9 files changed, 148 insertions(+), 38 deletions(-) diff --git a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts index d4c9f8daf..68f313e2d 100644 --- a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts +++ b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts @@ -21,7 +21,10 @@ import { getSharedAccountAuthService, resolveOfficialAccountDirectoryBaseUrl, } from "./sharedAccountAuthService"; -import { createMachineIdentitySigningStore } from "../sync/machineIdentitySigningStore"; +import { + createMachineIdentitySigningStore, + MACHINE_IDENTITY_SIGNING_FILE_NAME, +} from "../sync/machineIdentitySigningStore"; export const ACCOUNT_MACHINE_HEARTBEAT_MS = 30_000; export const ACCOUNT_MACHINE_RELAY_STATE_POLL_MS = 2_000; @@ -842,7 +845,7 @@ export function createBrainAccountMachinePublisherService(options: { logger: options.logger, }); const signingStore = createMachineIdentitySigningStore({ - filePath: path.join(options.secretsDir, "machine-identity-signing.json"), + filePath: path.join(options.secretsDir, MACHINE_IDENTITY_SIGNING_FILE_NAME), logger: options.logger, }); return createAccountMachinePublisherService({ diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 7ad4e7f22..c2e3013e7 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -2448,10 +2448,21 @@ export function createSyncHostService(args: SyncHostServiceArgs) { resetPairFailureEntry(globalAdoptChallengeIssuances); } }; - const registerAdoptChallengeIssuance = (ip: string | null): void => { + const registerAdoptChallengeIssuance = ( + ip: string | null, + options: { countsGlobally?: boolean } = {}, + ): void => { const now = Date.now(); pruneExpiredAdoptChallengeIssuances(now); - incrementPairFailureEntry(globalAdoptChallengeIssuances, now); + // A well-formed challenge only triggers a public host signature and guards + // no secret, so it must not feed the cross-tenant global counter — else a + // handful of cheap frames (or one failed multi-route adoption) would lock + // out account adoption host-wide. Only malformed/anomalous issuances, which + // are genuine abuse signals, count globally. All issuances still count + // per-IP so a single origin's signing load stays bounded. + if (options.countsGlobally) { + incrementPairFailureEntry(globalAdoptChallengeIssuances, now); + } if (ip) { const entry = adoptChallengeIssuances.get(ip) ?? { count: 0, cooldownUntilMs: 0, updatedAtMs: now }; @@ -6224,7 +6235,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return; } if (peer.adoptChallenge) { - registerAdoptChallengeIssuance(peer.remoteAddress); + registerAdoptChallengeIssuance(peer.remoteAddress, { countsGlobally: true }); send(peer.ws, "account_challenge_error", { message: "An account adoption challenge is already active on this connection.", }, envelope.requestId); @@ -6235,15 +6246,15 @@ export function createSyncHostService(args: SyncHostServiceArgs) { : null; if (!challenge) { peer.adoptChallenge = null; - registerAdoptChallengeIssuance(peer.remoteAddress); + registerAdoptChallengeIssuance(peer.remoteAddress, { countsGlobally: true }); send(peer.ws, "account_challenge_error", { message: "Invalid account adoption challenge.", }, envelope.requestId); return; } - // Count every unauthenticated signing operation, including requests - // whose peer key causes X25519 to reject. A successful sealed hello is - // the only operation that clears this per-IP/global issuance budget. + // A well-formed challenge only triggers a public signature, so it counts + // per-IP but never globally (see registerAdoptChallengeIssuance). A + // successful sealed hello is the only operation that clears the budget. registerAdoptChallengeIssuance(peer.remoteAddress); try { const identity = machineIdentitySigningStore.getOrCreate(); @@ -6279,6 +6290,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }, envelope.requestId); } catch (error) { peer.adoptChallenge = null; + // A signing/derivation failure here means a rejected peer ephemeral + // (malformed X25519 point) or missing host identity — an anomaly, so + // it counts globally in addition to the per-IP charge above. + registerAdoptChallengeIssuance(peer.remoteAddress, { countsGlobally: true }); args.logger.warn("sync_host.account_challenge_failed", { remoteAddress: peer.remoteAddress, reason: error instanceof Error ? error.message : String(error), diff --git a/apps/ade-cli/src/services/sync/syncRelaySelfProbe.ts b/apps/ade-cli/src/services/sync/syncRelaySelfProbe.ts index f49fc6fbc..3cbaf2b0a 100644 --- a/apps/ade-cli/src/services/sync/syncRelaySelfProbe.ts +++ b/apps/ade-cli/src/services/sync/syncRelaySelfProbe.ts @@ -2,10 +2,19 @@ import { WebSocket, type RawData } from "ws"; export const RELAY_SELF_PROBE_TIMEOUT_MS = 15_000; const RELAY_READY_VERSION = 2; +// Relay application close code for "machine already at MAX_TUNNELS_PER_MACHINE" +// (see apps/tunnel-relay/src/tunnelDo.ts CLOSE_TOO_MANY). The relay only sends +// it after confirming this machine's control socket is registered, so it is +// proof the control is alive — the probe just lost the race for a tunnel slot. +const RELAY_CLOSE_TOO_MANY = 4503; export type RelaySelfProbeResult = | { ok: true; roundTripMs: number } - | { ok: false; reason: string }; + // `atCapacity` marks a probe that could not complete only because the relay + // is already serving this machine's full tunnel quota. The relay — and this + // machine's control socket — are demonstrably alive, so callers must treat it + // as liveness proof, not a zombie/failure signal. + | { ok: false; reason: string; atCapacity?: boolean }; type RelaySelfProbeArgs = { relayWsBase: string; @@ -113,6 +122,7 @@ export function probeRelayEndToEnd( finish({ ok: false, reason: `Relay self-probe closed before ${accepted ? "ready" : "accepted"}${closeDetail(code, reason)}.`, + ...(code === RELAY_CLOSE_TOO_MANY ? { atCapacity: true } : {}), }); }; const onError = (error: Error): void => { diff --git a/apps/ade-cli/src/services/sync/syncTunnelClientService.ts b/apps/ade-cli/src/services/sync/syncTunnelClientService.ts index e3a7017d2..81fa5bb77 100644 --- a/apps/ade-cli/src/services/sync/syncTunnelClientService.ts +++ b/apps/ade-cli/src/services/sync/syncTunnelClientService.ts @@ -1135,6 +1135,31 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT return result; } + if (result.atCapacity) { + // The relay rejected our own probe only because this machine is at its + // tunnel quota — which proves the control socket is registered and the + // relay is serving real clients. Treat it as liveness (keep the relay + // endpoint published, do NOT declare a zombie or terminate control). + const verifiedAtMs = Date.now(); + const stateChanged = selfProbe.verifiedAtMs == null || selfProbe.lastFailure != null; + selfProbeRoundTripMs = null; + selfProbe = { + verifiedAtMs, + lastFailure: null, + lastFailureAtMs: null, + inFlight: false, + }; + const clearedFailure = clearBridgeOpenFailure(key, "self-probe"); + if (stateChanged && !clearedFailure) { + requestPublicationStatePublish("route-state-changed"); + } + log.debug?.("sync_tunnel.self_probe_at_capacity", { + machineKey: machineIdentity.machineKey, + reason: result.reason, + }); + return result; + } + const failureAtMs = Date.now(); selfProbeRoundTripMs = null; selfProbe = { @@ -1893,7 +1918,6 @@ function armControlJsonKeepalive( let stopped = false; let pongDeadline: NodeJS.Timeout | null = null; let initialPingTimer: NodeJS.Timeout | null = null; - let nextIntervalDueAt = Date.now() + normalizedIntervalMs; const clearPongDeadline = (): void => { if (!pongDeadline) return; @@ -1923,15 +1947,9 @@ function armControlJsonKeepalive( }, CONTROL_JSON_INITIAL_PING_DELAY_MS); initialPingTimer.unref?.(); const pingTimer = setInterval(() => { - const now = Date.now(); - // Sleep/wake may delay this tick well beyond two intervals. Sending now is - // the recovery action; no separate wake hook or catch-up burst is needed. - const delayedAfterSleep = now - nextIntervalDueAt > normalizedIntervalMs; - nextIntervalDueAt = now + normalizedIntervalMs; - if (delayedAfterSleep) { - sendPing(); - return; - } + // A tick delayed by sleep/wake needs no special handling: sending the ping + // now is itself the recovery action, and sendPing() no-ops if a deadline is + // already outstanding. sendPing(); }, normalizedIntervalMs); pingTimer.unref?.(); diff --git a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts index 1e9f50ca5..04c5660d6 100644 --- a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts @@ -854,7 +854,6 @@ describe("DesktopPairedMachineStore", () => { { name: "bad signature", mode: "bad_signature" as const }, { name: "device id mismatch", mode: "device_mismatch" as const }, { name: "stale timestamp", mode: "stale_timestamp" as const }, - { name: "challenge error", mode: "challenge_error" as const }, ])("aborts before hello when signed host verification fails: $name", async ({ mode }) => { const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-desktop-bad-host-")); process.env.ADE_HOME = adeHome; @@ -886,14 +885,6 @@ describe("DesktopPairedMachineStore", () => { const envelope = parseSyncEnvelope(wsDataToText(text)); sentTypes.push(envelope.type); if (envelope.type !== "account_challenge") return; - if (mode === "challenge_error") { - ws.receive(encodeSyncEnvelope({ - type: "account_challenge_error", - requestId: envelope.requestId, - payload: { message: "unsupported" }, - })); - return; - } const request = envelope.payload as { nonce: string; clientEphemeralPublicKey: string; @@ -939,6 +930,58 @@ describe("DesktopPairedMachineStore", () => { expect(sentTypes).toEqual(["account_challenge"]); }); + it("surfaces a host challenge decline as a route failure without leaking a hello", async () => { + // A host that declines to issue a challenge (e.g. rate-limit cooldown) is + // NOT an identity-proof failure: adoption must report the host's real reason + // and never send a sealed hello — but it must not be conflated with the + // "older ADE" identity-verification abort. + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-desktop-challenge-decline-")); + process.env.ADE_HOME = adeHome; + const signing = generateKeyPairSync("ed25519"); + const sentTypes: string[] = []; + const machine: AdeAccountMachine = { + machineKey: "machine-challenge-decline", + deviceId: "expected-host", + name: "Expected host", + platform: "macOS", + deviceType: "desktop", + pubkey: `ed25519:${rawPublicKeyFromSpki(signing.publicKey).toString("base64")}`, + online: true, + lastSeenAt: Date.now(), + reachableEndpoints: [ + { kind: "relay", url: "wss://relay.example/connect/machine-challenge-decline" }, + ], + }; + + const pairing = new DesktopPairedMachineStore().pairWithAccountMachine( + machine, + "must-not-leave-client", + "Laptop", + { + accountOwnerUserId: "account-user", + pairingTimeoutMs: 500, + relayBaseUrls: ["https://relay.example"], + createWebSocket: () => new FakeWebSocket((text, ws) => { + const envelope = parseSyncEnvelope(wsDataToText(text)); + sentTypes.push(envelope.type); + if (envelope.type !== "account_challenge") return; + ws.receive(encodeSyncEnvelope({ + type: "account_challenge_error", + requestId: envelope.requestId, + payload: { message: "Too many failed authentication attempts. Try again in 3 minutes." }, + })); + }) as unknown as WebSocket, + }, + ); + // The host's real reason is surfaced, not the identity-verification error. + await expect(pairing).rejects.toThrow(/Try again in 3 minutes/); + await expect(pairing).rejects.not.toMatchObject({ + code: "account_host_identity_verification_failed", + }); + // Credential safety invariant: no sealed hello ever left the client. + expect(sentTypes).toEqual(["account_challenge"]); + }); + it("aborts on a direct-route impostor without dialing later routes or sending hello", async () => { const signing = generateKeyPairSync("ed25519"); const openedEndpoints: string[] = []; diff --git a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts index 576fe0c8e..3813f00f2 100644 --- a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts +++ b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts @@ -782,7 +782,18 @@ export class DesktopPairedMachineStore { } const challengeEnvelope = await challengeResponse; if (challengeEnvelope.type === "account_challenge_error") { - throw hostIdentityVerificationError(); + // The host declined to issue a challenge (rate-limit cooldown, an + // already-active challenge, etc.). This is NOT an identity-proof + // failure — a MITM cannot forge a valid challenge on any other route + // either — so surface the host's real reason and fall through to the + // next route instead of aborting adoption with a misleading + // "older ADE" identity error. + const hostMessage = (challengeEnvelope.payload as { message?: unknown } | null)?.message; + throw new Error( + typeof hostMessage === "string" && hostMessage.trim() + ? boundedInlineText(hostMessage, 200) + : "The machine declined the account adoption challenge.", + ); } const challenge = challengeEnvelope.payload as Partial | null; diff --git a/apps/desktop/src/shared/accountDirectory.ts b/apps/desktop/src/shared/accountDirectory.ts index d0a268af3..c1f57ecf0 100644 --- a/apps/desktop/src/shared/accountDirectory.ts +++ b/apps/desktop/src/shared/accountDirectory.ts @@ -215,7 +215,11 @@ export function parseAccountMachine(value: unknown): AdeAccountMachine | null { name: optionalBoundedString(value.name, MAX_LABEL_CHARS), platform: optionalBoundedString(value.platform, MAX_LABEL_CHARS), deviceType: optionalBoundedString(value.deviceType, MAX_LABEL_CHARS), - pubkey: optionalBoundedString(value.pubkey, 128), + // A present host signing key must never collapse to "absent" — that would + // silently downgrade adoption to the plaintext relay path. Preserve any + // present string (even blank) verbatim so a malformed key fails closed at + // verification, matching the iOS client; only a truly missing field is null. + pubkey: typeof value.pubkey === "string" ? value.pubkey.slice(0, 128) : null, reachableEndpoints: endpoints, lastSeenAt, online: value.online === true, diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 29d9a9ad3..031ed464e 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -681,13 +681,9 @@ private struct AccountAdoptionRoute: Equatable, Hashable { } } - var connectedLabel: String { - switch kind { - case .relay: return "ADE relay" - case .tailnet: return "Tailscale" - case .lan: return "local network" - } - } + /// Same route word as `attemptLabel`; kept as a named accessor for the + /// "Connected via …" toast call site. + var connectedLabel: String { attemptLabel } var connectionRouteKind: SyncConnectionRouteKind { switch kind { diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index 8914ba59e..d00abdab0 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -225,6 +225,16 @@ run a local repair against data owned by the remote machine. See While connecting, the machine row reports the route being tried, and a failure surfaces inline with a one-tap jump into Nearby + PIN pairing when the same machine is discoverable locally. + + `ade-adopt-v1` protects the *credentials* exchanged during adoption (the + account bearer, the DPoP proof, and the minted paired secret are all + sealed), not the confidentiality of the ongoing session. After adoption + over a plaintext `ws://` LAN or tailnet route, the established sync stream + has the same on-path exposure as any other direct paired reconnect — an + attacker who can already read that LAN can observe the post-adoption + traffic, but never the sealed credentials. This matches the pre-existing + direct-route trust boundary; relay routes remain trusted-operator + plaintext-readable as documented above. Without an account, choose **Find nearby Macs**, select a discovered LAN or Tailscale machine, and enter the six-digit PIN shown on that Mac's **This Mac** Connections card. There is no desktop pairing-link paste/scan or From 39db1fcc417dc56ba610e925ffd77a722bde9c86 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:34:18 -0400 Subject: [PATCH 08/11] fix(sync): close residual adoption DoS and self-probe over-claim from re-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review showed the per-IP challenge limiter is degenerate over relay (every relay adopter reaches the host from 127.0.0.1 via the loopback bridge), so a few well-formed relay adoptions could still trip a shared cooldown. Well-formed challenges now feed no limiter at all — signing load is bounded by the per-connection single-active guard and the relay's connection cap — and only malformed/anomalous challenges (genuine abuse) count. Also: a self-probe CLOSE_TOO_MANY (4503) is inconclusive, not proof of health (an attacker could fill all 16 slots to mask a stale control), so it now renders no verdict — the JSON control keepalive independently owns zombie detection regardless of tunnel capacity, and prior publication state is left untouched. Tests updated to assert well-formed challenges are never throttled while malformed floods still are, and the 4503 atCapacity classification. Co-Authored-By: Claude Fable 5 --- .../src/services/sync/syncHostService.test.ts | 41 +++++++++++++++---- .../src/services/sync/syncHostService.ts | 38 ++++++++--------- .../services/sync/syncRelaySelfProbe.test.ts | 34 +++++++++++++++ .../services/sync/syncTunnelClientService.ts | 28 +++++-------- 4 files changed, 97 insertions(+), 44 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index d638ac3da..11e7b1e95 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -2037,13 +2037,40 @@ describe("sync host account authentication", () => { message: expect.stringMatching(/expired/i), }); - // The successful sealed hello above cleared its issuance budget. The - // expired challenge counts as one new unauthenticated signing operation; - // four more are admitted, and the following connection is throttled. - for (let index = 0; index < 4; index += 1) { - const rateClient = await openAccountClient(port); - clients.push(rateClient); - await issueChallenge(rateClient, `rate-${index}`); + // Well-formed challenges only trigger a public signature and are the + // normal adoption operation, so they must NEVER trip the abuse throttle — + // otherwise a few normal relay adoptions (all sharing the loopback origin) + // would lock every adopter out. Issue several in a row; all are admitted. + for (let index = 0; index < 8; index += 1) { + const okClient = await openAccountClient(port); + clients.push(okClient); + await issueChallenge(okClient, `well-formed-${index}`); + } + + // Only MALFORMED challenges (genuine abuse signals) count toward the + // limiter. Five trip the cooldown; the sixth is throttled — and once + // tripped, even a subsequent well-formed challenge is refused. + const sendMalformedChallenge = ( + client: Awaited>, + requestId: string, + ): void => { + client.ws.send(encodeSyncEnvelope({ + type: "account_challenge", + requestId, + payload: { v: 1, nonce: "not-32-bytes", clientEphemeralPublicKey: "bad" }, + })); + }; + for (let index = 0; index < 5; index += 1) { + const badClient = await openAccountClient(port); + clients.push(badClient); + sendMalformedChallenge(badClient, `malformed-${index}`); + await waitForValue( + () => badClient.envelopes.find((entry) => + entry.type === "account_challenge_error" + && entry.requestId === `malformed-${index}` + ), + `malformed-${index} rejection`, + ); } const throttledClient = await openAccountClient(port); clients.push(throttledClient); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index c2e3013e7..b89a30af6 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -2448,21 +2448,14 @@ export function createSyncHostService(args: SyncHostServiceArgs) { resetPairFailureEntry(globalAdoptChallengeIssuances); } }; - const registerAdoptChallengeIssuance = ( - ip: string | null, - options: { countsGlobally?: boolean } = {}, - ): void => { + // Only called for malformed/anomalous challenges (genuine abuse signals); + // well-formed challenges deliberately feed no limiter. Counts both per-IP and + // globally so a distributed malformed flood is bounded as well as a single + // origin's. + const registerAdoptChallengeIssuance = (ip: string | null): void => { const now = Date.now(); pruneExpiredAdoptChallengeIssuances(now); - // A well-formed challenge only triggers a public host signature and guards - // no secret, so it must not feed the cross-tenant global counter — else a - // handful of cheap frames (or one failed multi-route adoption) would lock - // out account adoption host-wide. Only malformed/anomalous issuances, which - // are genuine abuse signals, count globally. All issuances still count - // per-IP so a single origin's signing load stays bounded. - if (options.countsGlobally) { - incrementPairFailureEntry(globalAdoptChallengeIssuances, now); - } + incrementPairFailureEntry(globalAdoptChallengeIssuances, now); if (ip) { const entry = adoptChallengeIssuances.get(ip) ?? { count: 0, cooldownUntilMs: 0, updatedAtMs: now }; @@ -6235,7 +6228,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return; } if (peer.adoptChallenge) { - registerAdoptChallengeIssuance(peer.remoteAddress, { countsGlobally: true }); + registerAdoptChallengeIssuance(peer.remoteAddress); send(peer.ws, "account_challenge_error", { message: "An account adoption challenge is already active on this connection.", }, envelope.requestId); @@ -6246,16 +6239,21 @@ export function createSyncHostService(args: SyncHostServiceArgs) { : null; if (!challenge) { peer.adoptChallenge = null; - registerAdoptChallengeIssuance(peer.remoteAddress, { countsGlobally: true }); + registerAdoptChallengeIssuance(peer.remoteAddress); send(peer.ws, "account_challenge_error", { message: "Invalid account adoption challenge.", }, envelope.requestId); return; } - // A well-formed challenge only triggers a public signature, so it counts - // per-IP but never globally (see registerAdoptChallengeIssuance). A - // successful sealed hello is the only operation that clears the budget. - registerAdoptChallengeIssuance(peer.remoteAddress); + // A well-formed challenge only triggers one public signature and is the + // normal adoption operation, so it feeds no cooldown at all. Signing + // load is already bounded by the per-connection single-active-challenge + // guard above and the relay's per-machine connection cap. Crucially, + // every relay adopter reaches this host from 127.0.0.1 (the relay bridge + // dials the loopback listener), so charging well-formed challenges to + // any shared bucket would let a few normal adoptions lock out all relay + // adoption. Only malformed/anomalous challenges (the branches above and + // the catch below) count toward the abuse limiter. try { const identity = machineIdentitySigningStore.getOrCreate(); const hostDeviceId = readBrainMetadata().deviceId; @@ -6293,7 +6291,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // A signing/derivation failure here means a rejected peer ephemeral // (malformed X25519 point) or missing host identity — an anomaly, so // it counts globally in addition to the per-IP charge above. - registerAdoptChallengeIssuance(peer.remoteAddress, { countsGlobally: true }); + registerAdoptChallengeIssuance(peer.remoteAddress); args.logger.warn("sync_host.account_challenge_failed", { remoteAddress: peer.remoteAddress, reason: error instanceof Error ? error.message : String(error), diff --git a/apps/ade-cli/src/services/sync/syncRelaySelfProbe.test.ts b/apps/ade-cli/src/services/sync/syncRelaySelfProbe.test.ts index b81afe057..de4493847 100644 --- a/apps/ade-cli/src/services/sync/syncRelaySelfProbe.test.ts +++ b/apps/ade-cli/src/services/sync/syncRelaySelfProbe.test.ts @@ -74,6 +74,40 @@ describe("probeRelayEndToEnd", () => { }); }); + it("flags a too-many-tunnels (4503) close as atCapacity, not a plain failure", async () => { + const socket = new ProbeWebSocket(); + const probing = probeRelayEndToEnd({ + relayWsBase: "wss://relay.example", + machineKey: "machine-key", + createWebSocket: () => socket as unknown as WebSocket, + }); + + socket.open(); + socket.close(4503, "too many tunnels"); + + await expect(probing).resolves.toEqual({ + ok: false, + reason: "Relay self-probe closed before accepted (4503): too many tunnels.", + atCapacity: true, + }); + }); + + it("does not flag a non-4503 close as atCapacity", async () => { + const socket = new ProbeWebSocket(); + const probing = probeRelayEndToEnd({ + relayWsBase: "wss://relay.example", + machineKey: "machine-key", + createWebSocket: () => socket as unknown as WebSocket, + }); + + socket.open(); + socket.close(4501, "host offline"); + + const result = await probing; + expect(result.ok).toBe(false); + expect((result as { atCapacity?: boolean }).atCapacity).toBeUndefined(); + }); + it("times out and terminates the probe socket", async () => { vi.useFakeTimers(); const socket = new ProbeWebSocket(); diff --git a/apps/ade-cli/src/services/sync/syncTunnelClientService.ts b/apps/ade-cli/src/services/sync/syncTunnelClientService.ts index 81fa5bb77..e57239446 100644 --- a/apps/ade-cli/src/services/sync/syncTunnelClientService.ts +++ b/apps/ade-cli/src/services/sync/syncTunnelClientService.ts @@ -1136,23 +1136,17 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT } if (result.atCapacity) { - // The relay rejected our own probe only because this machine is at its - // tunnel quota — which proves the control socket is registered and the - // relay is serving real clients. Treat it as liveness (keep the relay - // endpoint published, do NOT declare a zombie or terminate control). - const verifiedAtMs = Date.now(); - const stateChanged = selfProbe.verifiedAtMs == null || selfProbe.lastFailure != null; - selfProbeRoundTripMs = null; - selfProbe = { - verifiedAtMs, - lastFailure: null, - lastFailureAtMs: null, - inFlight: false, - }; - const clearedFailure = clearBridgeOpenFailure(key, "self-probe"); - if (stateChanged && !clearedFailure) { - requestPublicationStatePublish("route-state-changed"); - } + // The probe could not get a tunnel slot because this machine is at its + // relay quota. That is inconclusive, not proof of health: a stale + // control could also be at capacity if clients filled every slot. So we + // render NO verdict here — neither claim end-to-end verified nor declare + // a zombie/terminate control. The JSON control keepalive independently + // owns zombie detection (it pings the control socket directly and does + // not depend on tunnel slots), so a genuinely dead control is still + // caught. Prior verification/publication state is left untouched: a + // relay that verified before filling up stays published; one that never + // verified stays unpublished. + selfProbe = { ...selfProbe, inFlight: false }; log.debug?.("sync_tunnel.self_probe_at_capacity", { machineKey: machineIdentity.machineKey, reason: result.reason, From d911d8367da0e79a19340c26afa8446cca797036 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:43:44 -0400 Subject: [PATCH 09/11] docs: sync/architecture/CLI parity for adoption + relay reliability Document the ade-adopt-v1 sealed handshake and envelope types, the JSON control keepalive + end-to-end self-probe with honest relay publication (pubkey + relayEndToEnd gating, 4503 at-capacity liveness), and the ade doctor relay self-probe verdict. Co-Authored-By: Claude Fable 5 --- apps/ade-cli/README.md | 3 +- docs/ARCHITECTURE.md | 4 +- docs/features/remote-runtime/README.md | 16 +- docs/features/sync-and-multi-device/README.md | 140 ++++++++++++++++-- 4 files changed, 140 insertions(+), 23 deletions(-) diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 26daa322b..35dfb2097 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -512,9 +512,10 @@ secure stores. - Linear readiness from the active project's `.ade/secrets` credential store (`linear.token.v1`), a legacy project-scoped encrypted token file, or headless environment variables. - Provider/model readiness from local ADE config, API-key provider references, and provider CLI availability. - Computer-use readiness from local platform capabilities. +- Sync and Relay readiness, including a relay end-to-end self-probe verdict (`relay self-probe`). When a local ADE brain is running the probe performs one round-trip through ADE Relay and reports `ready`, a `FAILED:` verdict, or a graceful skip; without a running brain (or without a validated relay bridge) it reports skipped/unavailable and never fails the run. `ade sync status` surfaces the same verdict on its `relay end-to-end` line. - Packaged/PATH status for the `ade` binary and concrete next actions. -Default doctor / auth checks do not call provider, GitHub, or Linear networks. They report presence and local readiness only, without printing secret values. +Default doctor / auth checks do not call provider, GitHub, or Linear networks. They report presence and local readiness only, without printing secret values. The one network touch is the optional relay end-to-end self-probe above, which runs only when a local brain and validated relay bridge are present. Agents starting an unfamiliar ADE session should begin with: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a95711073..4e6663bbc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -267,8 +267,8 @@ The `/open` route is the HTTPS half of the ADE deeplink scheme (`https://ade-app Four independent Cloudflare Workers, each its own npm package / lockfile / `wrangler.jsonc` with its own trust model. None is a runtime dependency of the desktop app; the brain talks to them over HTTPS/WebSocket. - **`apps/push-relay/`** — fans ADE agent-state transitions out to iPhones as APNs alert pushes and Live Activity updates (Worker + a single D1 database; free-plan compatible, no Durable Objects). The brain is the only publisher: it claims an unguessable 32–64-hex `machineKey` with a relay secret (`POST /machines/:key/claim`, first-writer-wins) and HMAC-signs every later call (`x-ade-push-signature: sha256=HMAC(secret, "...")`). It stores only device tokens and in-flight notification payloads — no chat/PR content. APNs auth is an ES256 provider JWT from the `.p8` (wrangler secrets `APNS_KEY` / `APNS_KEY_ID` / `APNS_TEAM_ID`). Brain-side publisher lives at `apps/ade-cli/src/services/push/`. See [features/sync-and-multi-device/push-notifications.md](./features/sync-and-multi-device/push-notifications.md). -- **`apps/tunnel-relay/`** — pipes ADE **sync** WebSocket frames between a controller and a brain when there is no direct LAN/Tailscale path (Worker + Durable Object with SQLite storage, one instance per `machineKey`, WebSocket Hibernation API). The brain holds a persistent HMAC-signed outbound control socket while the machine has a valid ADE account session; a controller dials `/connect/:machineKey`; the DO pairs it with a dedicated brain-side pipe socket and passes bytes through 1:1 with no frame wrapping, so the normal ADE hello / pairing / DPoP handshake is unchanged. Native 30-second ping / 10-second pong liveness avoids waking a hibernated DO with JSON keepalives. Failed bridge opens are rejected explicitly; application close codes and bounded sanitized reasons survive the phone/pipe/local boundaries. Early controller frames are bounded by both 64 frames and 256 KiB, and idle-sweep alarms run only while a client or pipe exists. Brain-side client is `apps/ade-cli/src/services/sync/syncTunnelClientService.ts`. There is no user relay toggle: sign-in starts and advertises Relay, while sign-out closes it. It remains the lowest-priority `relay` address candidate after LAN and Tailscale. TLS terminates at the Worker, so this is a trusted-operator plaintext path rather than end-to-end encryption; relay payload E2E encryption is planned security work. -- **`apps/account-directory/`** — Clerk-authenticated machine directory and OAuth device-authorization bridge (Worker + D1). The machine brain publishes a health-filtered registration through `accountMachinePublisherService.ts`: a 30-second heartbeat keeps the row inside the Worker's 90-second online window, while sign-in and publish-relevant relay-route changes trigger coalesced immediate writes and reset the heartbeat deadline. The Worker scopes rows by Clerk `sub`, selects at most the 500 most recently seen machines, then returns online-first order. Machine-list responses expose separate auth and D1 durations through `Server-Timing`, including auth failures. Authentication failures return only fixed classifications such as `token expired`, `invalid issuer`, and `invalid audience`; directory clients consume at most 512 response bytes before exposing the short reason in machine-list results and publisher health. Desktop, ADE Code, hosted web, and iOS use the compiled HTTPS Worker origin by default. Headless login binds each short-lived device code to a daemon secret, uses Clerk OAuth + PKCE in any browser, and atomically burns the approved token pair on redemption. +- **`apps/tunnel-relay/`** — pipes ADE **sync** WebSocket frames between a controller and a brain when there is no direct LAN/Tailscale path (Worker + Durable Object with SQLite storage, one instance per `machineKey`, WebSocket Hibernation API). The brain holds a persistent HMAC-signed outbound control socket while the machine has a valid ADE account session; a controller dials `/connect/:machineKey`; the DO pairs it with a dedicated brain-side pipe socket and passes bytes through 1:1 with no frame wrapping, so the normal ADE hello / pairing / DPoP handshake is unchanged. Native 30-second ping / 10-second pong transport liveness is the primary keepalive; because a hibernated or wedged DO can leave the edge answering those transport pings after the machine's control registration is dead, the brain adds a low-frequency application-level `{t:"ping"}`/`{t:"pong"}` keepalive (180 s interval, 30 s deadline) to catch such "zombie" controls, and verifies the path end-to-end with a self-probe (`syncRelaySelfProbe`) that dials `/connect/:machineKey?ready=2` like a real controller. The account directory advertises a `relay` endpoint only after that self-probe round-trips (honest relay publication); an at-capacity `4503` close is treated as liveness proof, not failure. Failed bridge opens are rejected explicitly; application close codes and bounded sanitized reasons survive the phone/pipe/local boundaries. Early controller frames are bounded by both 64 frames and 256 KiB, and idle-sweep alarms run only while a client or pipe exists. Brain-side client is `apps/ade-cli/src/services/sync/syncTunnelClientService.ts`. There is no user relay toggle: sign-in starts and advertises Relay, while sign-out closes it. It remains the lowest-priority `relay` address candidate after LAN and Tailscale. TLS terminates at the Worker, so this is a trusted-operator plaintext path rather than end-to-end encryption; relay payload E2E encryption is planned security work. +- **`apps/account-directory/`** — Clerk-authenticated machine directory and OAuth device-authorization bridge (Worker + D1). The machine brain publishes a health-filtered registration through `accountMachinePublisherService.ts`: a 30-second heartbeat keeps the row inside the Worker's 90-second online window, while sign-in and publish-relevant relay-route changes trigger coalesced immediate writes and reset the heartbeat deadline. The Worker scopes rows by Clerk `sub`, selects at most the 500 most recently seen machines, then returns online-first order. Machine-list responses expose separate auth and D1 durations through `Server-Timing`, including auth failures. Authentication failures return only fixed classifications such as `token expired`, `invalid issuer`, and `invalid audience`; directory clients consume at most 512 response bytes before exposing the short reason in machine-list results and publisher health. Desktop, ADE Code, hosted web, and iOS use the compiled HTTPS Worker origin by default. Headless login binds each short-lived device code to a daemon secret, uses Clerk OAuth + PKCE in any browser, and atomically burns the approved token pair on redemption. Each published row also carries the machine's long-lived Ed25519 identity as `pubkey`; a same-account desktop/iOS client verifies that key during the sealed `ade-adopt-v1` handshake to adopt a machine over a direct LAN/Tailscale route (relay → tailnet → LAN fallback) without exposing the account bearer in plaintext — see [features/sync-and-multi-device/README.md](./features/sync-and-multi-device/README.md). - **`apps/webhook-relay/`** — the pre-existing GitHub webhook relay (different trust model and lifecycle again). See its own docs. --- diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index d00abdab0..f6c2951df 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -54,13 +54,19 @@ relay payload E2E encryption is planned security work. See the trust boundary in current LAN/Tailscale set is now classified as `lan`/`tailscale` rather than the opaque `saved` kind, so LAN endpoints publish correctly instead of being dropped), a `tailnet` endpoint per reachable Tailscale address, and a `relay` - endpoint once the tunnel bridge is validated. Bridge validation is now + endpoint once the tunnel bridge is validated **and** an end-to-end self-probe + confirms the relay path round-trips (`relayEndToEndVerifiedAt` with no + failure). Bridge validation is now proactive (`syncTunnelClientService.validateCurrentBridge`, run on control-open and on listener-ready), so the relay endpoint appears in the - directory as soon as the machine is signed in and its listener is confirmed — - it no longer waits for an external client to open the first relay tunnel. - The published machine name is channel-suffixed (` · Beta` / ` · - Alpha`, stable left bare) so two channels on one Mac are distinguishable rows. + directory as soon as the machine is signed in, its listener is confirmed, and + the self-probe passes — it no longer waits for an external client to open the + first relay tunnel. The row also carries the host's Ed25519 identity as + `pubkey`, which same-account clients verify before a sealed `ade-adopt-v1` + adoption over a direct route (see the [Sync](../sync-and-multi-device/README.md) + security model). The published machine name is channel-suffixed (` · + Beta` / ` · Alpha`, stable left bare) so two channels on one Mac are + distinguishable rows. - `apps/ade-cli/src/tuiClient/remoteLauncher.ts` and `remoteBridge.ts` — `ade code remote` target resolution, legacy account-target migration, paired/SSH launch ordering, bounded cancellation, project/session selection, diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index a80d61541..79f67e2ea 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -239,9 +239,20 @@ Runtime support files outside `services/sync/`: because `syncPairingConnectInfo.buildAddressCandidates` now classifies the saved `lastHost` as `lan`/`tailscale` when it matches the current address set (instead of the opaque `saved` kind that was silently dropped from the - directory), a machine's LAN routes publish correctly. The `relay` endpoint is + directory), a machine's LAN routes publish correctly. Each row also carries + the machine's long-lived Ed25519 identity key as `pubkey` + (`ed25519:`, from `machineIdentitySigningStore`); the publisher fails + the publication closed (`machine_key_unavailable`) rather than advertise a + row with a missing key, because that key is what a client verifies before + trusting a sealed `ade-adopt-v1` adoption over a non-relay route (see + *Sealed account adoption* in the Security model). The `relay` endpoint is gated on `routeHealth.relay.relayBridgeValidated`, which the tunnel client now - sets proactively (see `syncTunnelClientService.ts`) so the relay route appears + sets proactively (see `syncTunnelClientService.ts`), **and** on the tunnel + client's end-to-end verdict — a present `relayEndToEndVerifiedAt` with no + `relayEndToEndFailure` — so a control that connects but cannot actually + round-trip through the relay never publishes a `relay` endpoint. A verified + route can be retained across a transient drop, but a route with a live + end-to-end failure is never retained. The relay route therefore appears in the directory without waiting for an external client to open the first tunnel. A 30-second heartbeat keeps the Worker row inside its 90-second online window. Failed publications retry after 1, 2, 5, 10, then 20 seconds so a @@ -311,7 +322,10 @@ Runtime support files outside `services/sync/`: `eventEpoch`, `gap`, and `oldestCursor` from `drain()` so clients can reset stale cursors when a daemon restarts or history was evicted. - `apps/ade-cli/src/multiProjectRpcServer.ts` — machine-level JSON-RPC - surface for `projects.*`, `sync.*`, `runtimeEvents.*`, project-scoped + surface for `projects.*`, `sync.*` (including `sync.runSelfProbe`, which + resolves the active sync host and runs the tunnel client's relay end-to-end + probe, or returns a skipped verdict when no host is active), + `runtimeEvents.*`, project-scoped `ade/actions/call`, and project-independent `personalChats.call` / `personalChats.streamEvents`. Runtime-event subscribe replies include the gap fields above; `projects.list` resolves at most 24 host-side icons within @@ -430,7 +444,17 @@ Canonical files (`apps/ade-cli/src/services/sync/`): rejection is attributed with the rejecting machine's `host: { deviceId, name }` — read from `readBrainMetadata()` — so a client can only ever drop a saved pairing when the rejection came from - the machine it is actually paired with), per-peer state, + the machine it is actually paired with), the sealed `ade-adopt-v1` + account-adoption handshake (`account_challenge` → `account_challenge_ok` → + a `hello` whose `auth.kind` is `account_sealed`: the host signs the client's + nonce over an ephemeral X25519 exchange with its `machineIdentitySigningStore` + key, derives the session key, unseals the account credentials from the sealed + hello, and returns the paired credentials in a sealed `hello_ok`; a completed + single-use challenge is required, well-formed challenges feed no rate limiter + while malformed/anomalous ones charge a per-IP + global cooldown, and unsealed + `account_sealed` adoption is the one account path allowed over a direct + LAN/tailnet route — plaintext `account` bearers still require the + `relay-bridge` transport origin), per-peer state, changeset fan-out + ack tracking (bounded, windowed exports and smaller-batch recovery from the last acknowledged cursor — see `crdt-model.md`), per-peer foreground-first scheduling (each peer has its own serialized @@ -695,6 +719,20 @@ Canonical files (`apps/ade-cli/src/services/sync/`): `~/.ade/secrets/sync-security.json` (chmod `0600`). Owns the `requireDpop` flag with the `ADE_SYNC_REQUIRE_DPOP=1|0` env override; both the per-project host and the brain ingress handler read it. +- `machineIdentitySigningStore.ts` — the machine's long-lived Ed25519 identity + keypair at `~/.ade/secrets/machine-identity-signing.json` (chmod `0600`, + lazily generated, cached per file path, regenerated if corrupt). The public + key is published as the directory row's `pubkey`; the private key signs the + `ade-adopt-v1` challenge so a client can verify it is talking to the machine + it selected before releasing any account credential. Shared by the host + service and the account-directory publisher. +- `apps/desktop/src/shared/sync/adoptChannelCrypto.ts` — the shared + `ade-adopt-v1` primitives used on both sides of sealed account adoption: + X25519 ephemeral key generation, the canonical challenge signature input, + HKDF-SHA256 session-key derivation over the X25519 shared secret + nonce, + ChaCha20-Poly1305 `seal`/`unseal` with context-bound AAD, and Ed25519 + sign/verify against the raw published key. Also imported by + `machineIdentitySigningStore.ts` for SPKI↔raw key conversion. - `syncCloudRelayStore.ts` — persists the cloud tunnel-relay identity at `~/.ade/secrets/sync-cloud-relay.json` (lazily-minted 32-hex `machineKey` + HMAC `secret`, chmod `0600`). The identity is stable in normal operation. @@ -733,9 +771,32 @@ Canonical files (`apps/ade-cli/src/services/sync/`): strictly serialized, and the port plus loopback identity are checked again before pipe creation so a listener change cannot reuse a stale result. The control socket uses native WebSocket ping frames every 30 - seconds with a 10-second pong deadline; a miss terminates it and enters the - guarded reconnect state machine without waking the hibernated Durable Object - with JSON traffic. Opens that fail validation, local-listener setup, or pipe + seconds with a 10-second pong deadline. Because a hibernated or wedged + Durable Object can leave the Cloudflare edge answering those transport-level + pings while the machine's control registration is already dead (a "zombie" + control), a **low-frequency application-level JSON keepalive** runs alongside + it: the client sends `{t:"ping"}` on the control every + `CONTROL_JSON_PING_INTERVAL_MS` (180 s, first ping after ~1 s) and expects a + `{t:"pong"}` within `CONTROL_JSON_PONG_DEADLINE_MS` (30 s). A miss on either + liveness path terminates the socket and enters the guarded reconnect state + machine; a JSON-keepalive miss additionally records a + `relay control unreachable at relay (zombie socket)` failure, drops + end-to-end verification, and logs `sync_tunnel.zombie_control_detected`. + Beyond liveness, the client verifies the relay path **end-to-end** with + `runSelfProbe()`: it dials the relay exactly like a ready-v2 controller + (`syncRelaySelfProbe.probeRelayEndToEnd`) and only treats the route as + verified once it sees `accepted`+`ready` v2 back through its own bridge. The + probe runs (debounced ~2 s) whenever the control reaches ready and whenever + the local bridge re-validates, and its verdict — `relayEndToEndVerifiedAt`, + `relayEndToEndFailure`, `relayEndToEndRoundTripMs` on the status — is what + the account-directory publisher additionally gates the `relay` endpoint on + (see `accountMachinePublisherService.ts`), so a control that connects but + whose bridge cannot actually round-trip stays unpublished. A probe failure + terminates the control as a zombie; an `atCapacity` close (relay code `4503` + `CLOSE_TOO_MANY`, sent only after the machine's control is confirmed + registered) is treated as liveness proof and renders no verdict, leaving + prior verification/publication state untouched. Opens that fail validation, + local-listener setup, or pipe setup send a bounded `{t:"reject"}` signal so the waiting controller closes immediately instead of hanging. A real pipe/local setup error or timeout is also a generation-scoped publication blocker until a fresh tunnel reaches @@ -748,11 +809,21 @@ Canonical files (`apps/ade-cli/src/services/sync/`): preserves the causal failure rather than replacing it with a generic WebSocket error: upgrade rejection captures the HTTP status and at most 512 sanitized response bytes; close telemetry records code, reason, and whether the socket opened. - `sync_tunnel.claimed`, `.claim_failed`, `.control_open`, `.control_error`, and - `.control_close` are the structured lifecycle events. `routeHealth.relay` - exposes `skipReason` / `lastControlError`, while + `sync_tunnel.claimed`, `.claim_failed`, `.control_open`, `.control_error`, + `.control_close`, `.self_probe_ok`, `.self_probe_failed`, + `.self_probe_at_capacity`, and `.zombie_control_detected` are the structured + lifecycle events. `routeHealth.relay` + exposes `skipReason` / `lastControlError` plus the end-to-end verdict, while `lastControlOpenAt` and `lastBridgeValidationAt` retain the two independent success histories. +- `syncRelaySelfProbe.ts` — `probeRelayEndToEnd`, the stateless relay + round-trip check used by the tunnel client's `runSelfProbe`. It opens + `wss:///connect/?ready=2`, requires an `accepted` v2 first + frame then a `ready` v2 second frame within a 15 s timeout, and reports + `{ ok, roundTripMs }` on success. A close before `ready` is a failure, but a + `4503` `CLOSE_TOO_MANY` close (the relay's at-capacity signal, sent only for a + registered control) is flagged `atCapacity` so the caller treats it as + liveness proof rather than a zombie/failure. - `relayAuthorization.ts` — lease renewal for already-authenticated Relay peers. A capable controller refreshes before expiry with a DPoP signature bound to the exact token bytes, device id, host challenge, timestamp, and @@ -838,7 +909,10 @@ iOS service files (`apps/ios/ADE/Services/`): snapshots, PR mobile snapshot persistence, and integration proposal fields mirrored from desktop schema. - `SyncService.swift` — WebSocket client, envelope encoding (zlib), - command routing, keychain integration, PIN-based pairing, lane + command routing, keychain integration, PIN-based pairing, the sealed + `ade-adopt-v1` account-adoption client (challenge/verify against the + directory `pubkey`, sealed `account_sealed` hello, and relay → Tailscale → + LAN route fallback with per-stage progress and a PIN-pairing fallback), lane presence announcements, terminal subscribe/unsubscribe tracking, terminal input/resize senders, mobile CLI launch/continuation, external-session list/import commands for Work, @@ -1125,7 +1199,10 @@ Envelopes are JSON with fields: { version: 1, type: "hello" | "hello_ok" | "hello_error" | "pairing_request" | - "pairing_result" | "changeset_batch" | "changeset_ack" | + "pairing_result" | + "account_challenge" | "account_challenge_ok" | + "account_challenge_error" | + "changeset_batch" | "changeset_ack" | "heartbeat" | "file_request" | "file_response" | "terminal_subscribe" | "terminal_unsubscribe" | "terminal_snapshot" | "terminal_data" | "terminal_exit" | @@ -1366,14 +1443,16 @@ feature is merged or because a deliberately isolated-port host is running. active), so a paired hello cannot skip proof-of-possession by racing a connection during a host restart. Keys are not restorable from device backups — a restored phone re-pairs with the PIN. -- **Account bearer transport**: every `hello.auth.kind = "account"` is +- **Account bearer transport (plaintext `account`)**: every + `hello.auth.kind = "account"` is accepted only when the shared listener verified that the socket came from ADE's in-process cloud-relay bridge. The tunnel client attaches a private, per-process 256-bit proof to its loopback WebSocket upgrade; the listener validates the decoded proof with a constant-time comparison and carries the resulting `relay-bridge` provenance through parked-socket host handoffs. Missing, forged, or stale proof fails closed as a direct connection. The - runtime rejects account auth on LAN, tailnet, loopback, and every other + runtime rejects the plaintext `account` bearer on LAN, tailnet, loopback, and + every other direct route before verifying the bearer, even when that device already has a pairing record. Existing devices use `auth.kind = "paired"` with their durable per-device secret and pinned DPoP key on direct routes; PIN pairing @@ -1382,6 +1461,27 @@ feature is merged or because a deliberately isolated-port host is running. bearer stolen outside ADE: generic bearer replay through a TLS relay remains possible until the account session/token is sender-constrained to a device key or equivalent platform attestation. +- **Sealed account adoption (`ade-adopt-v1`)**: the account credential can also + reach a machine over a **direct** LAN/tailnet route — not just the relay — + without ever exposing the bearer in plaintext, using a sealed handshake keyed + to the host's published `pubkey`. The client sends `account_challenge` with a + nonce and an ephemeral X25519 public key; the host replies + `account_challenge_ok` with its own X25519 ephemeral key and an Ed25519 + signature (from `machineIdentitySigningStore`) over the canonical + `ade-adopt-v1 | hostDeviceId | nonce | clientEph | hostEph | ts` string. The + client **verifies that signature against the directory-published `pubkey` + before releasing any credential**, so a machine cannot be impersonated on a + LAN. Both sides derive the same ChaCha20-Poly1305 key via HKDF-SHA256 over the + X25519 shared secret and nonce; the client then sends a `hello` with + `auth.kind = "account_sealed"` carrying the sealed account attestation, and + the host returns the minted paired credentials in a sealed `hello_ok`. The + challenge is single-use and TTL-bounded (60 s); it is required before a sealed + hello is accepted. `ade-adopt-v1` protects the exchanged *credentials* (bearer, + DPoP proof, minted secret), not the confidentiality of the subsequent session: + after adopting over a plaintext `ws://` route the ongoing sync stream has the + same on-path exposure as any other direct paired reconnect. See the client + connect-flow narrative in + [Remote Runtime](../remote-runtime/README.md#connect-flow). - **Pairing**: direct machine-to-machine Nearby and phone QR/Nearby pairing use the same user-approved PIN + DPoP flow. The desktop synthesizes its Nearby pairing input from discovery; it does not expose a Share link or manual @@ -1446,7 +1546,15 @@ feature is merged or because a deliberately isolated-port host is running. when signed in after this release. The live relay URL is also advertised to already-paired phones in `hello_ok` / `brain_status` (`cloudRelayWssUrl`), so devices paired before the relay existed learn - the route without re-scanning a QR. + the route without re-scanning a QR. Relay publication is **honest**: the host + advertises a `relay` endpoint in the account directory only after an + end-to-end self-probe (`syncRelaySelfProbe`) confirms a controller-shaped dial + actually round-trips back through its own bridge, and it keeps that route + live with an application-level `{t:"ping"}`/`{t:"pong"}` control keepalive + that catches "zombie" controls the Cloudflare edge still answers at the + transport layer after the Durable Object has died (see + `syncTunnelClientService.ts`). A control that connects but cannot round-trip, + or that goes zombie, is torn down and never publishes a relay route. - **Secret isolation**: each device stores its own pairing secret in its OS keychain. - **One-release trust reset**: the first packaged desktop launch carrying the @@ -1504,6 +1612,8 @@ feature is merged or because a deliberately isolated-port host is running. | QR pairing UX | Implemented (payload v3 smart URL + iOS camera scanner; PIN entered separately) | | Device-bound pairing (DPoP, Secure Enclave P-256) | Implemented (host + brain ingress; `requireDpop` / `ADE_SYNC_REQUIRE_DPOP`) | | Cloud tunnel relay (off-LAN transport, `relay` candidate) | Implemented whenever the host is signed in, with no separate toggle and with same-account per-connection proof (`syncTunnelClientService` + `apps/tunnel-relay`) | +| Relay end-to-end self-probe + zombie-control detection (honest relay publication) | Implemented (`syncRelaySelfProbe`, JSON control keepalive, `sync.runSelfProbe`, `ade doctor` relay check) | +| Sealed account adoption over direct routes (`ade-adopt-v1`, host `pubkey` identity, relay → tailnet → LAN fallback) | Implemented (`machineIdentitySigningStore` + `adoptChannelCrypto`; desktop + iOS clients) | | Push notifications + Live Activities (APNs relay) | Implemented (see `push-notifications.md`; on-device E2E needs a physical iPhone) | | Tailscale integration | Implemented (address candidate + mDNS TXT + per-node `tailscale serve` publication on the live sync port) | | Clean, published lane + Work chat handoff between connected desktops | Implemented ([contract](./cross-machine-session-handoff.md)) | From 7e6d119f7b4b28fc944300a76ca43ff76f01c673 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:52:15 -0400 Subject: [PATCH 10/11] fix(ci): allowlist crypto test-vector in gitleaks; add onPairMachineProgress to webclient account surface - .gitleaksignore: the ade-adopt-v1 HKDF session-key vector (derived from public RFC 7748 keys, asserted in code) trips generic-api-key in a doc comment. - webclient adapter completeness test: include the onPairMachineProgress method added to the browser account namespace. Co-Authored-By: Claude Fable 5 --- .gitleaksignore | 6 ++++++ .../renderer/webclient/adapter/__tests__/adapter.test.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/.gitleaksignore b/.gitleaksignore index e3cbc7527..1b1ba72e0 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -11,3 +11,9 @@ # Synthetic encrypted OpenSSH parser fixture. The current test constructs the # boundary at runtime; keep this exception scoped to its original PR commit. 73f8acc461fd37996a95e4765007f2c794df13ee:apps/ios/ADETests/SSHBootstrapTests.swift:private-key:25 + +# ade-adopt-v1 cross-platform crypto test vector. This is the HKDF-SHA256 output +# derived from the public RFC 7748 X25519 test keys (documented in-comment); it +# is not a real secret. The same value is asserted in code (line 123) and in the +# iOS mirror to lock TS<->Swift byte-compatibility. Scope to its original commit. +4809aba24a34362fd2014774abf67c85b1251cd0:apps/desktop/src/shared/sync/adoptChannelCrypto.test.ts:generic-api-key:83 diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index bcf1f8682..cea88feb4 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -184,6 +184,7 @@ describe("createAdeWebAdapter", () => { "cancelLogin", "getLocalMachineIdentity", "listMachines", + "onPairMachineProgress", "pairMachine", "pollLogin", "removeMachine", From b6120424460fda89c017fa6b682ad1ae0b6205e7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Jul 2026 02:07:13 -0400 Subject: [PATCH 11/11] fix(ios): avoid Int64(Double) trap on malformed adoption challenge timestamp Double(Int64.max) rounds up to 2^63, so a `<=` bound admitted a ts that traps in Int64(...), crashing before skew validation on a malformed challenge. Use strict `<`. (CodeRabbit) Co-Authored-By: Claude Fable 5 --- apps/ios/ADE/Services/SyncService.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 031ed464e..b18ba13ea 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -4823,10 +4823,14 @@ final class SyncService: ObservableObject { throw AccountAdoptionIdentityVerificationError(machineName: machineName) } let timestampValue = timestampNumber.doubleValue + // `Double(Int64.max)` rounds up to 2^63, so `<=` would admit a value that + // traps in `Int64(...)`. Strict `<` keeps a malformed challenge timestamp + // from crashing the app before skew validation; real ms timestamps are far + // below this bound. guard timestampValue.isFinite, timestampValue.rounded(.towardZero) == timestampValue, timestampValue >= 0, - timestampValue <= Double(Int64.max) else { + timestampValue < Double(Int64.max) else { throw AccountAdoptionIdentityVerificationError(machineName: machineName) } let timestampMilliseconds = Int64(timestampValue)