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/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/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..35dfb2097 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 @@ -507,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/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/accountMachineDirectoryService.test.ts b/apps/ade-cli/src/services/account/accountMachineDirectoryService.test.ts index af9f93594..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 { 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 }, @@ -366,14 +371,18 @@ describe("AccountMachineDirectoryService", () => { 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", 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", { @@ -381,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/ade-cli/src/services/account/accountMachinePublisherService.test.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts index 05a83b3cc..c531a040e 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, @@ -160,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", @@ -489,6 +522,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 +870,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..68f313e2d 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,10 @@ import { getSharedAccountAuthService, resolveOfficialAccountDirectoryBaseUrl, } from "./sharedAccountAuthService"; +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; @@ -34,7 +39,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 @@ -58,7 +63,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 & @@ -142,6 +153,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; @@ -183,6 +195,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); @@ -200,9 +214,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, }; } @@ -217,6 +231,9 @@ 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, + pubkey: registration.pubkey, reachableEndpoints, }); } @@ -226,6 +243,7 @@ export function createAccountMachinePublisherService(options: { getAccountStatus?: () => PublisherAccountStatus; getSnapshot: () => Promise; getMachineKey: () => string; + getMachineIdentitySigningPublicKey?: () => string; directoryBaseUrl?: () => string | null | undefined; isSyncEnabled?: () => boolean; subscribeToSignIn?: (listener: () => void) => (() => void); @@ -264,6 +282,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; @@ -434,10 +460,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", { @@ -488,7 +524,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 +542,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; @@ -713,10 +754,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; @@ -800,6 +844,10 @@ export function createBrainAccountMachinePublisherService(options: { projectRoots: options.projectRoots, logger: options.logger, }); + const signingStore = createMachineIdentitySigningStore({ + filePath: path.join(options.secretsDir, MACHINE_IDENTITY_SIGNING_FILE_NAME), + logger: options.logger, + }); return createAccountMachinePublisherService({ getAccessToken: (tokenOptions) => getSignedInAccountAccessToken( accountAuthService, @@ -817,6 +865,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..11e7b1e95 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,334 @@ 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), + }); + + // 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); + 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..b89a30af6 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,50 @@ 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); + } + }; + // 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); + 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 +3153,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { remotePort, transportOrigin, relayAuthorization: null, + adoptChallenge: null, subscribedSessionIds: new Set(), pendingTerminalSnapshots: new Map(), nextTerminalSnapshotGeneration: 0, @@ -6054,10 +6199,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 +6215,93 @@ 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; + } + // 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; + 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; + // 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); + 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 +6448,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 +6669,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 +6818,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 +6867,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 +6893,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/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..de4493847 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncRelaySelfProbe.test.ts @@ -0,0 +1,129 @@ +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("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(); + 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..3cbaf2b0a --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncRelaySelfProbe.ts @@ -0,0 +1,146 @@ +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 } + // `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; + 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)}.`, + ...(code === RELAY_CLOSE_TOO_MANY ? { atCapacity: true } : {}), + }); + }; + 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..e57239446 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,183 @@ 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; + } + + if (result.atCapacity) { + // 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, + }); + 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 +1288,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 +1428,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 +1505,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 +1533,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 +1545,8 @@ export function createSyncTunnelClientService(args: SyncTunnelClientArgs): SyncT accountGeneration += 1; clearControlReadyTimer(); clearBridgeValidation(); - clearBridgeOpenFailure(); + bridgeOpenFailure = null; + resetSelfProbeForControlGeneration(); } accountEligible = nextEligibility; if (!nextEligibility) { @@ -1216,7 +1578,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 +1640,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 +1679,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 +1702,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 +1901,72 @@ 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; + + 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(() => { + // 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?.(); + + 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) { 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..d3f6eec75 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,16 @@ vi.mock( return listMachines(); } - async pairMachine() { - throw new Error("not used"); + async pairMachine( + machineKey: string, + options?: { + onStage?: (stage: { + kind: "relay" | "tailnet" | "lan"; + phase: "connecting" | "verifying"; + }) => void; + }, + ) { + return pairMachine(machineKey, options); } async deleteMachine(machineKey: string) { @@ -423,6 +432,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 +504,93 @@ describe("desktop account machine lifecycle", () => { expect(deleteMachine).toHaveBeenCalledWith("machine-a"); }); + it("emits account machine progress at the bridge and per-call boundaries", async () => { + 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 }), + ).resolves.toMatchObject({ + targetId: "target-a", + machineKey: "machine-a", + }); + + const expectedProgress = [ + { + machineKey: "machine-a", + 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.objectContaining({ onStage: expect.any(Function) }), + ); + 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(5); + }); + 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..e4b4ad3cb 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,26 @@ 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 accountMachineNames = new Map(); const service = () => getSharedAccountAuthService({ @@ -194,21 +209,89 @@ 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 }); } 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. + } + } + }; + 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…", + }); + }, + }); + 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/main/services/remoteRuntime/syncPairedMachineStore.test.ts b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts index 0380edef2..04c5660d6 100644 --- a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts @@ -1,8 +1,9 @@ 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"; -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 { @@ -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; @@ -54,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-")); @@ -447,6 +544,556 @@ 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; + 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 }, + ], + }; + 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 }, + ])("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; + 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("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[] = []; + 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 f92bf5c5b..3813f00f2 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,15 +14,30 @@ 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 { + accountMachineAdoptionRoutes, accountMachinePairedSyncEndpoints, - accountMachineSecureSyncEndpoints, resolveAccountHelloPairing, + type AccountMachineAdoptionRoute, } from "../../../shared/accountDirectory"; import { buildDesktopPairedHello, @@ -57,12 +73,79 @@ export type PairWithAccountMachineOptions = Omit< authorizeAccountCommit?: ( expectedOwnerUserId: string, ) => boolean | Promise; + onStage?: (stage: { + kind: "relay" | "tailnet" | "lan"; + phase: "connecting" | "verifying"; + }) => void; }; 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; +} + +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(); } @@ -582,17 +665,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 accountRelayEndpoints = accountMachineSecureSyncEndpoints( + const hasDirectoryPubkey = typeof machine.pubkey === "string"; + const hostSigningPublicKey = hasDirectoryPubkey + ? parseDirectoryEd25519PublicKey(machine.pubkey!) + : null; + const adoptionRoutes = accountMachineAdoptionRoutes( machine, options.relayBaseUrls, ); - if (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, ); + if (accountAuthenticationRoutes.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 @@ -627,31 +720,151 @@ export class DesktopPairedMachineStore { capabilities: [], ...(options.appVersion?.trim() ? { appVersion: options.appVersion.trim() } : {}), }; - const failures: string[] = []; - for (const endpoint of accountRelayEndpoints) { + 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 { + 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" + ), + Math.min( + ADOPT_CHANNEL_CHALLENGE_TIMEOUT_MS, + options.pairingTimeoutMs ?? DEFAULT_PAIRING_TIMEOUT_MS, + ), + options.signal, + ); + try { + connection.send("account_challenge", { + v: 1, + nonce: nonceBase64, + clientEphemeralPublicKey, + }, challengeRequestId); + } catch (error) { + void challengeResponse.catch(() => {}); + throw error; + } + const challengeEnvelope = await challengeResponse; + if (challengeEnvelope.type === "account_challenge_error") { + // 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; + 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, + }); + 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, + 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 +884,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."); @@ -687,7 +926,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, ); @@ -716,7 +955,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(), @@ -724,13 +963,29 @@ export class DesktopPairedMachineStore { } catch (error) { throwIfAborted(options.signal); if (error instanceof AccountPairingAuthorizationError) throw error; - failures.push(error instanceof Error ? error.message : String(error)); + if (error instanceof AccountHostIdentityVerificationError) { + throw error; + } + failures.push({ + route, + reason: error instanceof Error ? error.message : String(error), + }); } finally { connection.close(1000, "Account pairing finished."); } } + 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/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 ? ( { "cancelLogin", "getLocalMachineIdentity", "listMachines", + "onPairMachineProgress", "pairMachine", "pollLogin", "removeMachine", diff --git a/apps/desktop/src/renderer/webclient/adapter/account.ts b/apps/desktop/src/renderer/webclient/adapter/account.ts index 81f3da152..0646f50f5 100644 --- a/apps/desktop/src/renderer/webclient/adapter/account.ts +++ b/apps/desktop/src/renderer/webclient/adapter/account.ts @@ -73,6 +73,7 @@ export function createAccountNamespace( pairMachine: async () => { 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/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 93d9a90ab..c1f57ecf0 100644 --- a/apps/desktop/src/shared/accountDirectory.ts +++ b/apps/desktop/src/shared/accountDirectory.ts @@ -215,6 +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), + // 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, @@ -549,9 +554,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, @@ -597,6 +604,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/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/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 6b66127f0..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; @@ -91,3 +93,9 @@ export type AdeAccountMachinePairResult = { deviceId: string; name: string; }; + +export type AdeAccountPairMachineProgress = { + machineKey: string; + stage: "relay" | "tailnet" | "lan" | "verifying" | "opening"; + label: string; +}; 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 diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index dc6815d41..886c85eeb 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,29 @@ 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) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .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..b18ba13ea 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,143 @@ 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) + } + + /// 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, + 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 +660,69 @@ 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…" + } + } + + /// 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 { + 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 +2430,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 +2488,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. @@ -2736,6 +2947,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() { @@ -4476,6 +4692,295 @@ 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" : "" + let label = "Connected via \(route.connectedLabel)\(latencySuffix)" + accountConnectSuccessClearTask?.cancel() + accountConnectSuccessLabel = label + announceAccountConnectStatus(label) + 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 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, + 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 + // `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 { + 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) + } + publishAccountConnectStage("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 +4992,7 @@ final class SyncService: ObservableObject { authorization: AccountPairingAuthorization ) async -> Bool { prepareForUserConnectionChange() + defer { accountConnectStageLabel = nil } ProductAnalytics.shared.captureQuickConnect(.accountMachine) let owner = authorization.ownerId.trimmingCharacters(in: .whitespacesAndNewlines) let expectedHostIdentity = syncNonEmpty(machine.deviceId) @@ -4539,14 +5045,54 @@ 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 signingPublicKey: Curve25519.Signing.PublicKey? + do { + signingPublicKey = try AdoptChannelCrypto.signingPublicKey( + fromOptionalDirectoryValue: machine.pubkey + ) + } 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 +5105,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 + publishAccountConnectStage(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 +5135,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 +5152,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 +5166,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 +5197,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 +5206,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 +5249,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 +13600,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..427ba1a3e 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,31 @@ 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) + .frame(minHeight: 44) + .buttonStyle(.plain) + } + } } } .frame(maxWidth: 420) @@ -116,19 +138,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 +187,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/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 d0627d1d5..c5952d594 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 { @@ -186,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() + } } } @@ -228,6 +255,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 +340,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 +667,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 +864,27 @@ 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) + .frame(minHeight: 44) + .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 +907,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..343db7d7f 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,27 @@ 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) + .frame(minHeight: 44) + .buttonStyle(.plain) + } + } } .padding(.horizontal, 10) .padding(.vertical, 8) diff --git a/apps/ios/ADETests/PairingAndDpopTests.swift b/apps/ios/ADETests/PairingAndDpopTests.swift index d5c3b8782..492894562 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,82 @@ 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" + ) + } + + 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 { 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 f87ce218a..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, @@ -211,8 +217,30 @@ 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. + + `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 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)) |