From 50837b900f7f4e82708277a39e8d8e49b7ffc23e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:58:26 -0400 Subject: [PATCH 1/2] Fix the account-pairing deadlock between iOS and a previously QR-paired Mac MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A signed-in iPhone could not connect to a Mac it had once paired with by QR/PIN. The Mac held a pairing record with `accountOwnerUserId: null` and refused to issue an account pairing, answering `hello_ok` with no `accountPairing`; iOS treated that as fatal. Neither side could break it — the Mac would not reissue because a manual record existed, the phone could not use that record because it no longer held the secret, and only the Mac could delete it. The transport was never at fault: the socket connected, the challenge passed, and the host replied. Host: - Adopt a legacy `owner:null` record when the same device authenticates by account and its ALREADY-PINNED DPoP key verifies. Keyless records, and records owned by a different account, are still refused. - Mark adopted records `localTrustOrigin`. The sign-out sweep demotes them back to `accountOwnerUserId: null` instead of deleting them — skipping the delete alone would leave a record that exists but that every reconnect path rejects on a stale owner. - Defer adoption while a PIN re-pair is staged, so write-through cannot destroy a rotation the device has not acknowledged. - Check pinned keys for validity, not truthiness, so a malformed key cannot fall through to the TOFU branch and trust a caller-supplied one. iOS: - Mirror the web client's `resolveAccountHelloPairing`: an omitted `accountPairing` means "keep the credential you hold". A present but mismatched one is still rejected. - Name the machine an attempt targets, not the last-connected one, in the connecting line, the unreachable line, and the Hub chip. - Restore the previous connection when a switch fails, and retarget the saved profile back when there was nothing to restore. - Decide the Hub transition from a baseline captured before `saveProfile` retargets the active machine; comparing after it compared a machine to itself and never fired. - Treat `.syncing` as attached so a link tapped mid-hydration stops re-pairing to the machine already connected. - Retire a row failure when it stops being true, not on any connect. Co-Authored-By: Claude Opus 5 --- .../src/services/sync/syncHostService.test.ts | 335 +++++++++++++++ .../src/services/sync/syncHostService.ts | 63 ++- .../services/sync/syncPairingStore.test.ts | 229 +++++++++- .../src/services/sync/syncPairingStore.ts | 61 ++- apps/ios/ADE.xcodeproj/project.pbxproj | 4 + apps/ios/ADE/Services/SyncService.swift | 400 ++++++++++++++++-- apps/ios/ADE/Views/Hub/HubComponents.swift | 35 +- .../Settings/ConnectionSettingsView.swift | 90 +++- .../Settings/SettingsConnectionHeader.swift | 32 +- apps/ios/ADETests/ADETests.swift | 140 ++++++ .../SyncAccountConnectRecoveryTests.swift | 215 ++++++++++ docs/ARCHITECTURE.md | 7 + docs/features/sync-and-multi-device/README.md | 71 +++- .../sync-and-multi-device/ios-companion.md | 97 ++++- docs/features/web-client/README.md | 6 +- 15 files changed, 1725 insertions(+), 60 deletions(-) create mode 100644 apps/ios/ADETests/SyncAccountConnectRecoveryTests.swift diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 7ada10cc8..64a883d44 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -3497,6 +3497,341 @@ describe("sync host account authentication", () => { } }); + // A phone that paired by QR/PIN and later lost its stored secret (an iOS + // security migration wipes saved profiles but keeps the Secure Enclave DPoP + // key) can only come back through account sign-in. The host used to answer + // that hello with hello_ok and NO `accountPairing`, so the client failed with + // "The Mac did not return saved connection details" and the only remedy was + // physically walking to the Mac. + describe("legacy manual pairing adoption", () => { + const legacyPeer = { + deviceId: "legacy-manual-phone", + deviceName: "iPhone", + platform: "iOS", + deviceType: "phone", + siteId: "legacy-manual-phone-site", + dbVersion: 0, + } satisfies SyncPeerMetadata; + + function createLegacyHarness(options?: { pin?: string }) { + const { projectRoot, cleanup } = createTempProjectRoot(); + const secretsDir = path.join(projectRoot, ".ade", "secrets"); + const pinStore = createSyncPinStore({ filePath: path.join(secretsDir, "sync-pin.json") }); + pinStore.setPin(options?.pin ?? "428193"); + const pairingSecretsPath = path.join(secretsDir, "sync-paired-devices.json"); + const pairingStore = createSyncPairingStore({ filePath: pairingSecretsPath, pinStore }); + const baseArgs = createHostArgs(projectRoot, []); + const listener = createSharedSyncListener({ bindHost: "127.0.0.1" }); + const host = createSyncHostService({ + ...baseArgs, + ...accountDependencies(), + pinStore, + pairingSecretsPath, + sharedListener: listener, + discoveryEnabled: false, + deviceRegistryService: { + ...baseArgs.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + } as unknown as Parameters[0]); + const clients: Array>> = []; + return { + baseArgs, + pairingStore, + pairingSecretsPath, + host, + listener, + clients, + cleanup: async () => { + for (const client of clients) client.ws.close(); + await host.dispose(); + await listener.close(); + cleanup(); + }, + }; + } + + /** Strips the field entirely, which is the true on-disk legacy shape. */ + function dropAccountOwnerField(pairingSecretsPath: string, deviceId: string): void { + const records = JSON.parse(fs.readFileSync(pairingSecretsPath, "utf8")) as Record< + string, + Record + >; + delete records[deviceId]?.accountOwnerUserId; + fs.writeFileSync(pairingSecretsPath, `${JSON.stringify(records, null, 2)}\n`); + } + + // The deferral is the one remaining path that answers hello_ok WITHOUT + // `accountPairing`, so it is also the only end-to-end exercise of the iOS + // stored-secret fallback. Adoption writes through, which would drop a PIN + // re-pair the device has not acknowledged yet and leave its + // `pairing_commit` with nothing to promote. + it("defers adoption while a PIN re-pair is still staged", async () => { + const harness = createLegacyHarness(); + const deviceKey = makeDpopKeyPair(); + harness.pairingStore.pairPeer(legacyPeer, "428193", { + dpopPublicKey: deviceKey.publicKeyX963, + }); + dropAccountOwnerField(harness.pairingSecretsPath, legacyPeer.deviceId); + // A second PIN pair stages a rotation the device has not proven it got. + const staged = harness.pairingStore.pairPeer(legacyPeer, "428193", { + dpopPublicKey: deviceKey.publicKeyX963, + }); + expect(harness.pairingStore.hasPendingRotation(legacyPeer.deviceId)).toBe(true); + try { + const port = await harness.host.waitUntilListening(); + const accountToken = await mintAccountToken(); + const relayClient = await openAccountClient(port, harness.listener.getRelayBridgeProof()); + harness.clients.push(relayClient); + sendAccountHello({ + ws: relayClient.ws, + peer: legacyPeer, + accountToken, + dpop: signAccountDpop({ + privateKey: deviceKey.privateKey, + publicKeyX963: deviceKey.publicKeyX963, + deviceId: legacyPeer.deviceId, + accountToken, + }), + }); + const helloOk = await waitForValue( + () => relayClient.envelopes.find((envelope) => envelope.type === "hello_ok"), + "deferred adoption hello_ok", + ); + // Authenticated, but deliberately no new credential. + expect((helloOk.payload as { accountPairing?: unknown }).accountPairing).toBeUndefined(); + // The staged re-pair is untouched and still promotable. + expect(harness.pairingStore.hasPendingRotation(legacyPeer.deviceId)).toBe(true); + expect(harness.pairingStore.verifySecret(legacyPeer.deviceId, staged.secret)).toBe("pending"); + expect(harness.baseArgs.logger.info).toHaveBeenCalledWith( + "sync_host.account_adoption_deferred_pending_rotation", + { deviceId: legacyPeer.deviceId }, + ); + } finally { + await harness.cleanup(); + } + }); + + it("adopts a keyed legacy pairing on an account hello and returns a usable secret", async () => { + const harness = createLegacyHarness(); + const deviceKey = makeDpopKeyPair(); + const legacyPairing = harness.pairingStore.pairPeer(legacyPeer, "428193", { + dpopPublicKey: deviceKey.publicKeyX963, + }); + dropAccountOwnerField(harness.pairingSecretsPath, legacyPeer.deviceId); + const legacyRecord = harness.pairingStore.getPairingRecord(legacyPeer.deviceId); + expect(legacyRecord?.accountOwnerUserId ?? null).toBeNull(); + expect(legacyRecord?.dpopPublicKey).toBe(deviceKey.publicKeyX963); + try { + const port = await harness.host.waitUntilListening(); + const accountToken = await mintAccountToken(); + const relayClient = await openAccountClient(port, harness.listener.getRelayBridgeProof()); + harness.clients.push(relayClient); + sendAccountHello({ + ws: relayClient.ws, + peer: legacyPeer, + accountToken, + dpop: signAccountDpop({ + privateKey: deviceKey.privateKey, + publicKeyX963: deviceKey.publicKeyX963, + deviceId: legacyPeer.deviceId, + accountToken, + }), + }); + const helloOk = await waitForValue( + () => relayClient.envelopes.find((envelope) => envelope.type === "hello_ok"), + "legacy adoption hello_ok", + ); + const adopted = (helloOk.payload as { + accountPairing?: { deviceId: string; secret: string }; + }).accountPairing; + expect(adopted).toMatchObject({ + deviceId: legacyPeer.deviceId, + secret: expect.stringMatching(/^[0-9a-f]{48}$/), + }); + expect(adopted?.secret).not.toBe(legacyPairing.secret); + + const upgraded = harness.pairingStore.getPairingRecord(legacyPeer.deviceId); + expect(upgraded?.accountOwnerUserId).toBe(ownerUserId); + // The pinned key is the identity proof; adoption must never swap it for + // the key the hello advertised inline. + expect(upgraded?.dpopPublicKey).toBe(deviceKey.publicKeyX963); + // `getPairingRecord` returns a committed view that strips + // `pendingRotation` unconditionally, so asserting its absence there + // proves nothing. `hasPendingRotation` reads the raw record. + expect(harness.pairingStore.hasPendingRotation(legacyPeer.deviceId)).toBe(false); + // Adoption must not hand the account power to delete a hand-made + // pairing when this Mac signs out. + expect(upgraded?.localTrustOrigin).toBe(true); + expect(harness.pairingStore.verifySecret(legacyPeer.deviceId, adopted!.secret)).toBe("committed"); + expect(harness.pairingStore.verifySecret(legacyPeer.deviceId, legacyPairing.secret)).toBeNull(); + expect(harness.baseArgs.logger.info).toHaveBeenCalledWith( + "sync_host.account_legacy_pairing_upgraded", + { deviceId: legacyPeer.deviceId }, + ); + + // The whole point: the phone reconnects on its own with what hello_ok + // handed it, without anyone touching the Mac. + const pairedClient = await openAccountClient(port); + harness.clients.push(pairedClient); + sendPairedHello({ + ws: pairedClient.ws, + peer: legacyPeer, + secret: adopted!.secret, + dpop: signPairedDpop({ + privateKey: deviceKey.privateKey, + publicKeyX963: deviceKey.publicKeyX963, + deviceId: legacyPeer.deviceId, + secret: adopted!.secret, + }), + }); + await waitForValue( + () => pairedClient.envelopes.find((envelope) => envelope.type === "hello_ok"), + "adopted paired hello_ok", + ); + } finally { + await harness.cleanup(); + } + }); + + it("still refuses a legacy pairing that has no device key on record", async () => { + const harness = createLegacyHarness(); + const deviceKey = makeDpopKeyPair(); + const legacyPairing = harness.pairingStore.pairPeer(legacyPeer, "428193"); + dropAccountOwnerField(harness.pairingSecretsPath, legacyPeer.deviceId); + expect(harness.pairingStore.getPairingRecord(legacyPeer.deviceId)?.dpopPublicKey ?? null) + .toBeNull(); + try { + const port = await harness.host.waitUntilListening(); + const accountToken = await mintAccountToken(); + const relayClient = await openAccountClient(port, harness.listener.getRelayBridgeProof()); + harness.clients.push(relayClient); + sendAccountHello({ + ws: relayClient.ws, + peer: legacyPeer, + accountToken, + dpop: signAccountDpop({ + privateKey: deviceKey.privateKey, + publicKeyX963: deviceKey.publicKeyX963, + deviceId: legacyPeer.deviceId, + accountToken, + }), + }); + const rejected = await waitForValue( + () => relayClient.envelopes.find((envelope) => envelope.type === "hello_error"), + "keyless legacy adoption rejection", + ); + expect(rejected.payload).toMatchObject({ + code: "auth_failed", + message: expect.stringMatching(/predates device-key security/i), + }); + const untouched = harness.pairingStore.getPairingRecord(legacyPeer.deviceId); + expect(untouched?.accountOwnerUserId ?? null).toBeNull(); + expect(untouched?.dpopPublicKey ?? null).toBeNull(); + expect(harness.pairingStore.verifySecret(legacyPeer.deviceId, legacyPairing.secret)) + .toBe("committed"); + expect(harness.baseArgs.logger.info).not.toHaveBeenCalledWith( + "sync_host.account_legacy_pairing_upgraded", + expect.anything(), + ); + } finally { + await harness.cleanup(); + } + }); + + it("still refuses a pairing already owned by a different ADE account", async () => { + const harness = createLegacyHarness(); + const deviceKey = makeDpopKeyPair(); + const otherUserId = "user_someone_else"; + const otherAttestation = await verifyClerkAccountAttestation({ + token: await mintAccountToken(otherUserId), + expectedUserId: otherUserId, + config: { issuer, jwksUrl, oauthClientId }, + }); + const otherOwnerPairing = harness.pairingStore.pairPeerViaAccount(legacyPeer, otherAttestation, { + dpopPublicKey: deviceKey.publicKeyX963, + }); + try { + const port = await harness.host.waitUntilListening(); + const accountToken = await mintAccountToken(); + const relayClient = await openAccountClient(port, harness.listener.getRelayBridgeProof()); + harness.clients.push(relayClient); + sendAccountHello({ + ws: relayClient.ws, + peer: legacyPeer, + accountToken, + dpop: signAccountDpop({ + privateKey: deviceKey.privateKey, + publicKeyX963: deviceKey.publicKeyX963, + deviceId: legacyPeer.deviceId, + accountToken, + }), + }); + const rejected = await waitForValue( + () => relayClient.envelopes.find((envelope) => envelope.type === "hello_error"), + "cross-account adoption rejection", + ); + expect(rejected.payload).toMatchObject({ + code: "auth_failed", + message: expect.stringMatching(/already paired to this machine under a different ADE account/i), + }); + expect(harness.pairingStore.getPairingRecord(legacyPeer.deviceId)?.accountOwnerUserId) + .toBe(otherUserId); + expect(harness.pairingStore.verifySecret(legacyPeer.deviceId, otherOwnerPairing.secret)) + .toBe("committed"); + } finally { + await harness.cleanup(); + } + }); + + it.each([ + { label: "a missing proof", useProof: false, message: /did not present its security key/i }, + { label: "a wrong-key proof", useProof: true, message: /could not prove it holds the security key/i }, + ])("still refuses to adopt a legacy pairing on $label", async ({ useProof, message }) => { + const harness = createLegacyHarness(); + const deviceKey = makeDpopKeyPair(); + const attackerKey = makeDpopKeyPair(); + const legacyPairing = harness.pairingStore.pairPeer(legacyPeer, "428193", { + dpopPublicKey: deviceKey.publicKeyX963, + }); + dropAccountOwnerField(harness.pairingSecretsPath, legacyPeer.deviceId); + try { + const port = await harness.host.waitUntilListening(); + const accountToken = await mintAccountToken(); + const relayClient = await openAccountClient(port, harness.listener.getRelayBridgeProof()); + harness.clients.push(relayClient); + sendAccountHello({ + ws: relayClient.ws, + peer: legacyPeer, + accountToken, + dpop: useProof + ? signAccountDpop({ + privateKey: attackerKey.privateKey, + publicKeyX963: attackerKey.publicKeyX963, + deviceId: legacyPeer.deviceId, + accountToken, + }) + : null, + }); + const rejected = await waitForValue( + () => relayClient.envelopes.find((envelope) => envelope.type === "hello_error"), + "DPoP-failed legacy adoption rejection", + ); + expect(rejected.payload).toMatchObject({ + code: "auth_failed", + message: expect.stringMatching(message), + }); + const untouched = harness.pairingStore.getPairingRecord(legacyPeer.deviceId); + expect(untouched?.accountOwnerUserId ?? null).toBeNull(); + expect(untouched?.dpopPublicKey).toBe(deviceKey.publicKeyX963); + expect(harness.pairingStore.verifySecret(legacyPeer.deviceId, legacyPairing.secret)) + .toBe("committed"); + } finally { + await harness.cleanup(); + } + }); + }); + it("revokes account-owned trust and closes Relay plus account peers when the host lease expires", 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 eba7aa249..16a777515 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -144,7 +144,7 @@ import type { createComputerUseArtifactBrokerService } from "../../../../desktop import type { AdeDb } from "../../../../desktop/src/main/services/state/kvDb"; import { hasNullByte, normalizeRelative, nowIso, resolvePathWithinRoot, safeJsonParse, toOptionalString, uniqueStrings, writeTextAtomic } from "../../../../desktop/src/main/services/shared/utils"; import type { DeviceRegistryService } from "./deviceRegistryService"; -import { createSyncPairingStore, type SyncPairingRecord } from "./syncPairingStore"; +import { createSyncPairingStore, isValidDpopPublicKey, type SyncPairingRecord } from "./syncPairingStore"; import { createPairFailureTracker, type PairFailureSubject, @@ -7293,7 +7293,12 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } const existingPairingRecord = pairingStore.getPairingRecord(accountAuth.deviceId); - if (existingPairingRecord && !existingPairingRecord.dpopPublicKey) { + // Validity, not truthiness. `evaluatePairedHelloDpop` resolves its + // stored key with `.trim() || null`, so a whitespace-only field + // would pass a truthy check here and then fall into that + // function's TOFU branch — verifying the proof against the key the + // CALLER supplied. Both guards have to agree on what a key is. + if (existingPairingRecord && !isValidDpopPublicKey(existingPairingRecord.dpopPublicKey ?? "")) { args.logger.warn("sync_host.account_existing_keyless_rejected", { deviceId: accountAuth.deviceId, }); @@ -7328,12 +7333,55 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return false; } connectionAttemptReserved = true; - if (existingPairingRecord && !existingAccountOwner) { - // A manual/PIN/SSH pairing stays local. Same-account Relay may - // use it, but account sign-in never converts or rotates it. + // A legacy manual (QR/PIN/SSH) record for this same deviceId used + // to end the handshake right here: the host kept the local record + // and answered hello_ok with no `accountPairing`, so a signed-in + // device that no longer held the manual secret could never + // reconnect without physically returning to the Mac. It is + // adopted instead — the DPoP proof above already established, + // against the key pinned on THIS record, that the caller is the + // same physical device, and the account attestation was verified + // and re-captured under the commit lock. The record gains an + // owner and the device gets a usable secret. + // + // NOT identical to first-time adoption, and the differences are + // the point: the pinned key is kept rather than taken from the + // hello, `createdAt` survives, and `localTrustOrigin` marks the + // record so signing out of this Mac cannot delete a pairing the + // user made by hand. + const upgradingLegacyPairing = Boolean(existingPairingRecord) && !existingAccountOwner; + // A PIN re-pair the device has not acknowledged yet is staged on + // this record. Adoption writes through, which would drop that + // staged secret and leave the device's `pairing_commit` with + // nothing to promote ("the staged pairing expired"). The staging + // window exists to make a re-pair survive a lost reply, so it + // wins: keep the old no-`accountPairing` behaviour for this one + // hello. That is no longer a dead end — every client now falls + // back to the secret it already holds, and a device mid-re-pair + // has one by definition. + if (upgradingLegacyPairing && pairingStore.hasPendingRotation(accountAuth.deviceId)) { + args.logger.info("sync_host.account_adoption_deferred_pending_rotation", { + deviceId: accountAuth.deviceId, + }); authenticatedPairingRecord = existingPairingRecord; return false; } + // Written through rather than staged. Staging exists to protect a + // credential the device is still holding across two more round + // trips, and only PIN pairing has that shape: the device + // acknowledges with `pairing_commit`, which the host only ever + // arms on the `pairing_request` path (`pairingCommitOfferedForDeviceId`). + // An account hello has no acknowledgement to arm, and staging + // deliberately withholds elevations — `writeNewPairingRecord` + // keeps the committed `accountOwnerUserId` until promotion — so a + // staged adoption would leave the record local for exactly as + // long as the bug it fixes. + // + // A device holding a working secret is not blocked from taking + // this path, so write-through can invalidate one that was in use; + // the guard above covers the case where that secret is a staged + // re-pair, and otherwise a lost reply costs one more account + // hello rather than a walk back to the Mac. const paired = pairingStore.pairPeerViaAccount(hello.peer, attestation, { dpopPublicKey: existingPairingRecord ? null @@ -7351,6 +7399,11 @@ export function createSyncHostService(args: SyncHostServiceArgs) { if (!authenticatedPairingRecord) { return authFail("This machine could not save the new pairing for this device. Try again."); } + if (upgradingLegacyPairing) { + args.logger.info("sync_host.account_legacy_pairing_upgraded", { + deviceId: accountAuth.deviceId, + }); + } return false; }); } catch (error) { diff --git a/apps/ade-cli/src/services/sync/syncPairingStore.test.ts b/apps/ade-cli/src/services/sync/syncPairingStore.test.ts index 5d83ea541..2fccf7a89 100644 --- a/apps/ade-cli/src/services/sync/syncPairingStore.test.ts +++ b/apps/ade-cli/src/services/sync/syncPairingStore.test.ts @@ -1,8 +1,15 @@ import fs from "node:fs"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { SyncPeerMetadata } from "../../../../desktop/src/shared/types"; +import { + verifyClerkAccountAttestation, + type VerifiedAccountAttestation, +} from "../account/accountAttestationVerifier"; import { createSyncPairingStore, PAIRING_ROTATION_WINDOW_MS } from "./syncPairingStore"; import { createSyncPinStore } from "./syncPinStore"; @@ -395,3 +402,223 @@ describe("staged re-pair privilege direction", () => { expect(store.getPairingRecord(desktopPeer.deviceId)?.runtimeHostGranted).toBe(true); }); }); + +// Account sign-in used to throw on any record it had not created itself, which +// permanently stranded a device that paired by QR/PIN and later lost its stored +// secret. Adoption is now allowed, but only for a record carrying the pinned +// device key that the caller's DPoP proof was checked against. +describe("account adoption of a legacy manual pairing", () => { + const roots: string[] = []; + const ISSUER = "https://pairing-store-clerk.test"; + const OAUTH_CLIENT_ID = "pairing-store-client"; + const OWNER_USER_ID = "user_adoption_owner"; + const OTHER_USER_ID = "user_adoption_other"; + const PIN = "428193"; + const OTHER_DPOP_PUBLIC_KEY = Buffer.concat([ + Buffer.from([0x04]), + Buffer.alloc(64, 0x02), + ]).toString("base64"); + let jwksServer: Server; + let jwksUrl = ""; + let signingKey: Awaited>["privateKey"]; + + beforeAll(async () => { + const keyPair = await generateKeyPair("RS256", { extractable: true }); + signingKey = keyPair.privateKey; + const publicJwk = await exportJWK(keyPair.publicKey); + const jwks = { keys: [{ ...publicJwk, alg: "RS256", kid: "pairing-store-key", use: "sig" }] }; + jwksServer = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(jwks)); + }); + await new Promise((resolve, reject) => { + jwksServer.once("error", reject); + jwksServer.listen(0, "127.0.0.1", resolve); + }); + jwksUrl = `http://127.0.0.1:${(jwksServer.address() as AddressInfo).port}/jwks`; + }); + + afterAll(async () => { + // A `beforeAll` failure leaves this undefined; without the guard the + // teardown throws a second, unrelated error that buries the real one. + if (!jwksServer) return; + await new Promise((resolve, reject) => { + jwksServer.close((error) => error ? reject(error) : resolve()); + }); + }); + + afterEach(() => { + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + /** The verifier brands its result, so only a real token can produce one. */ + async function attestationFor(userId: string): Promise { + const now = Math.floor(Date.now() / 1000); + const token = await new SignJWT({}) + .setProtectedHeader({ alg: "RS256", kid: "pairing-store-key" }) + .setIssuer(ISSUER) + .setSubject(userId) + .setAudience(OAUTH_CLIENT_ID) + .setIssuedAt(now) + .setExpirationTime(now + 600) + .sign(signingKey); + return verifyClerkAccountAttestation({ + token, + expectedUserId: userId, + config: { issuer: ISSUER, jwksUrl, oauthClientId: OAUTH_CLIENT_ID }, + }); + } + + function createStore() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-account-adoption-")); + roots.push(root); + const filePath = path.join(root, "paired.json"); + const pinStore = createSyncPinStore({ filePath: path.join(root, "pin.json") }); + pinStore.setPin(PIN); + return { filePath, pinStore, store: createSyncPairingStore({ filePath, pinStore }) }; + } + + const peer = { + deviceId: "legacy-manual-store-phone", + deviceName: "iPhone", + platform: "iOS", + deviceType: "phone", + siteId: "legacy-manual-store-site", + dbVersion: 0, + } satisfies SyncPeerMetadata; + + /** Removes the field entirely, which is the true on-disk legacy shape. */ + function dropAccountOwnerField(filePath: string, deviceId: string): void { + const records = JSON.parse(fs.readFileSync(filePath, "utf8")) as Record< + string, + Record + >; + delete records[deviceId]?.accountOwnerUserId; + fs.writeFileSync(filePath, `${JSON.stringify(records, null, 2)}\n`); + } + + it("adopts a keyed local pairing, keeping its pinned key and creation time", async () => { + const { filePath, store } = createStore(); + const legacy = store.pairPeer(peer, PIN, { dpopPublicKey: VALID_DPOP_PUBLIC_KEY }); + dropAccountOwnerField(filePath, peer.deviceId); + const before = store.getPairingRecord(peer.deviceId); + expect(before).not.toHaveProperty("accountOwnerUserId"); + + const adopted = store.pairPeerViaAccount(peer, await attestationFor(OWNER_USER_ID), { + // A hello may advertise a key inline; adoption must ignore it in favour + // of the key already pinned on the record. + dpopPublicKey: OTHER_DPOP_PUBLIC_KEY, + }); + + expect(adopted.pendingRotationExpiresAtMs).toBeNull(); + const after = store.getPairingRecord(peer.deviceId); + expect(after?.accountOwnerUserId).toBe(OWNER_USER_ID); + expect(after?.dpopPublicKey).toBe(VALID_DPOP_PUBLIC_KEY); + expect(after?.createdAt).toBe(before?.createdAt); + expect(store.verifySecret(peer.deviceId, adopted.secret)).toBe("committed"); + expect(store.verifySecret(peer.deviceId, legacy.secret)).toBeNull(); + }); + + it("refuses to adopt a local pairing that has no pinned device key", async () => { + const { filePath, store } = createStore(); + const legacy = store.pairPeer(peer, PIN); + dropAccountOwnerField(filePath, peer.deviceId); + const attestation = await attestationFor(OWNER_USER_ID); + + expect(() => store.pairPeerViaAccount(peer, attestation, { + dpopPublicKey: VALID_DPOP_PUBLIC_KEY, + })).toThrow(/device key/i); + expect(store.getPairingRecord(peer.deviceId)?.accountOwnerUserId ?? null).toBeNull(); + expect(store.verifySecret(peer.deviceId, legacy.secret)).toBe("committed"); + }); + + it("refuses to adopt a pairing owned by a different account", async () => { + const { store } = createStore(); + const owned = store.pairPeerViaAccount(peer, await attestationFor(OTHER_USER_ID), { + dpopPublicKey: VALID_DPOP_PUBLIC_KEY, + }); + const attestation = await attestationFor(OWNER_USER_ID); + + expect(() => store.pairPeerViaAccount(peer, attestation, { + dpopPublicKey: VALID_DPOP_PUBLIC_KEY, + })).toThrow(/different ADE account/i); + expect(store.getPairingRecord(peer.deviceId)?.accountOwnerUserId).toBe(OTHER_USER_ID); + expect(store.verifySecret(peer.deviceId, owned.secret)).toBe("committed"); + }); + + it("lets a PIN re-pair declassify an adopted pairing back to local immediately", async () => { + const { filePath, store } = createStore(); + store.pairPeer(peer, PIN, { dpopPublicKey: VALID_DPOP_PUBLIC_KEY }); + dropAccountOwnerField(filePath, peer.deviceId); + store.pairPeerViaAccount(peer, await attestationFor(OWNER_USER_ID)); + expect(store.getPairingRecord(peer.deviceId)?.accountOwnerUserId).toBe(OWNER_USER_ID); + + // Declassification is a reduction, so it lands on the committed record even + // though the replacement secret only stages. + const repaired = store.pairPeer(peer, PIN, { dpopPublicKey: VALID_DPOP_PUBLIC_KEY }); + + expect(store.hasPendingRotation(peer.deviceId)).toBe(true); + expect(store.getPairingRecord(peer.deviceId)?.accountOwnerUserId).toBeNull(); + expect(store.authenticate(peer.deviceId, repaired.secret)).toBe(true); + expect(store.getPairingRecord(peer.deviceId)?.accountOwnerUserId).toBeNull(); + expect(store.revokeAccountOwnedExcept(null)).toEqual([]); + }); + + // Adoption grants the account a way to USE a hand-made pairing; it must not + // hand the account power to DESTROY it. Without `localTrustOrigin` the + // adopted record joins the set `revokeAccountOwnedExcept` deletes, so one + // account hello would silently make a QR/PIN/SSH pairing disappear the next + // time the Mac signed out — stranding the device with no recovery except + // walking back to the machine, which is the entire failure this change exists + // to remove. + it("keeps an adopted manual pairing alive when the Mac signs out", async () => { + const { filePath, pinStore, store } = createStore(); + store.pairPeer(peer, PIN, { dpopPublicKey: VALID_DPOP_PUBLIC_KEY }); + dropAccountOwnerField(filePath, peer.deviceId); + const adopted = store.pairPeerViaAccount(peer, await attestationFor(OWNER_USER_ID)); + expect(store.getPairingRecord(peer.deviceId)?.accountOwnerUserId).toBe(OWNER_USER_ID); + expect(store.getPairingRecord(peer.deviceId)?.localTrustOrigin).toBe(true); + + // Sign-out. Surviving is not enough: every reconnect path rejects a record + // whose owner no longer matches the signed-in account, so the record has to + // come back DEMOTED to pure local trust or it is intact and unusable — + // the same dead end, moved. + expect(store.revokeAccountOwnedExcept(null)).toEqual([]); + expect(store.getPairingRecord(peer.deviceId)?.accountOwnerUserId).toBeNull(); + expect(store.getPairingRecord(peer.deviceId)?.localTrustOrigin).toBe(true); + expect(store.verifySecret(peer.deviceId, adopted.secret)).toBe("committed"); + + // And it must be durable, not just correct in memory. + const reopened = createSyncPairingStore({ filePath, pinStore }); + expect(reopened.getPairingRecord(peer.deviceId)?.accountOwnerUserId).toBeNull(); + + // A switch to a different account leaves the now-local record alone. + expect(store.revokeAccountOwnedExcept("user_someone_else")).toEqual([]); + expect(store.verifySecret(peer.deviceId, adopted.secret)).toBe("committed"); + }); + + it("still revokes an account-first pairing on sign-out", async () => { + const { store } = createStore(); + store.pairPeerViaAccount(peer, await attestationFor(OWNER_USER_ID)); + expect(store.getPairingRecord(peer.deviceId)?.localTrustOrigin).not.toBe(true); + expect(store.revokeAccountOwnedExcept(null)).toEqual([peer.deviceId]); + expect(store.getPairingRecord(peer.deviceId)).toBeNull(); + }); + + it("refuses to adopt a local pairing whose pinned key is only whitespace", async () => { + const { filePath, store } = createStore(); + store.pairPeer(peer, PIN, { dpopPublicKey: VALID_DPOP_PUBLIC_KEY }); + dropAccountOwnerField(filePath, peer.deviceId); + // A whitespace field is not a pinned key. Treating it as one would let the + // DPoP check fall through to trusting whatever key the caller presented. + const records = JSON.parse(fs.readFileSync(filePath, "utf8")); + records[peer.deviceId].dpopPublicKey = " "; + fs.writeFileSync(filePath, JSON.stringify(records)); + + const attestation = await attestationFor(OWNER_USER_ID); + expect(() => store.pairPeerViaAccount(peer, attestation)) + .toThrow(/without a device key cannot be adopted/); + }); +}); diff --git a/apps/ade-cli/src/services/sync/syncPairingStore.ts b/apps/ade-cli/src/services/sync/syncPairingStore.ts index fc6b84dac..427d4a40c 100644 --- a/apps/ade-cli/src/services/sync/syncPairingStore.ts +++ b/apps/ade-cli/src/services/sync/syncPairingStore.ts @@ -28,6 +28,15 @@ export type SyncPairingRecord = { * null are deliberately local/manual for backward compatibility. */ accountOwnerUserId?: string | null; + /** + * This pairing was established by QR/PIN/SSH and only later adopted into an + * account. Ownership lets the account gate re-use it; this flag records that + * the underlying trust is still the user's own physical act at the Mac, so + * `revokeAccountOwnedExcept` must not delete it when the Mac signs out or + * switches accounts. Signing in must never retroactively destroy a machine + * the user paired by hand. + */ + localTrustOrigin?: boolean; /** * A PIN re-pair that the device has not proven it received. See * `writeNewPairingRecord`: the fields above stay live and authoritative until @@ -222,15 +231,31 @@ export function createSyncPairingStore(args: SyncPairingStoreArgs) { const existing = records[peer.deviceId] ?? null; const existingAccountOwnerUserId = normalizeAccountOwnerUserId(existing?.accountOwnerUserId); let accountOwnerUserId: string | null = null; + // Sticky once set: a later re-pair or account switch never erases the fact + // that this trust started at the Mac. + let localTrustOrigin = existing?.localTrustOrigin === true; if (trust.kind === "account") { const requestedOwnerUserId = trust.userId.trim(); if (!requestedOwnerUserId) { throw pairingError("account_not_verified", "Account identity is required."); } - if (existing && !existingAccountOwnerUserId) { + // Adopting a legacy manual (QR/PIN/SSH) record into the account. This + // used to throw outright, which stranded any signed-in device that had + // lost its stored secret: the record on the Mac stayed local forever and + // the only escape was walking back to the Mac. Adoption is safe because + // the caller has already verified a DPoP proof against the key pinned on + // THIS record — that pinned key is the sole evidence that the signing-in + // device is the same physical device that paired manually. A record with + // no pinned key carries no such evidence, so it is still refused here + // (the sync host refuses it earlier too; this is the store-level backstop + // for any other caller). + // Validity, not truthiness — a whitespace-only field is not a pinned key, + // and treating it as one would let the DPoP check fall back to TOFU + // against a caller-supplied key. Matches the host-side guard. + if (existing && !existingAccountOwnerUserId && !isValidDpopPublicKey(existing.dpopPublicKey ?? "")) { throw pairingError( "account_not_verified", - "A local pairing cannot be replaced through account sign-in.", + "A local pairing without a device key cannot be adopted through account sign-in.", ); } if (existingAccountOwnerUserId && existingAccountOwnerUserId !== requestedOwnerUserId) { @@ -240,6 +265,14 @@ export function createSyncPairingStore(args: SyncPairingStoreArgs) { ); } accountOwnerUserId = requestedOwnerUserId; + // Adoption grants the account a way to USE this pairing; it does not + // convert who created it. Without this the record would join the set + // `revokeAccountOwnedExcept` deletes on sign-out, so one account hello + // would silently make a hand-paired machine destructible — the exact + // failure this whole change exists to remove. + if (existing && !existingAccountOwnerUserId) { + localTrustOrigin = true; + } } const runtimeHostGranted = peer.deviceType === "desktop" && ( @@ -263,6 +296,7 @@ export function createSyncPairingStore(args: SyncPairingStoreArgs) { peerPlatform: peer.platform, peerDeviceType: peer.deviceType, runtimeHostGranted, + localTrustOrigin, // A gated re-pair may introduce or rotate the key when its caller allows // that. Omitting a key preserves the existing binding without downgrade. dpopPublicKey, @@ -353,6 +387,11 @@ export function createSyncPairingStore(args: SyncPairingStoreArgs) { return writeNewPairingRecord(peer, options, { kind: "pin" }, true); }, + /** + * Mints (or rotates) a pairing from a verified same-account attestation. + * This also ADOPTS a legacy manual record for the same deviceId — see + * `writeNewPairingRecord` for why that is safe and what it still refuses. + */ pairPeerViaAccount( peer: SyncPeerMetadata, attestation: VerifiedAccountAttestation, @@ -556,14 +595,30 @@ export function createSyncPairingStore(args: SyncPairingStoreArgs) { const currentOwner = currentOwnerUserId?.trim() || null; const records = readRecords(); const removed: string[] = []; + let demoted = false; for (const [deviceId, record] of Object.entries(records)) { const owner = normalizeAccountOwnerUserId(record?.accountOwnerUserId); // Missing/null provenance is legacy or explicitly local trust. if (!owner || owner === currentOwner) continue; + // Adopted-from-manual pairings must survive, but SURVIVING IS NOT + // ENOUGH: every reconnect path rejects a record whose owner no longer + // matches the signed-in account, so merely skipping the delete would + // leave it intact and permanently unusable — the same dead end, moved. + // Demote it back to the pure local trust it came from. The owner gate + // then has nothing to reject, and a later account hello re-adopts it. + if (record?.localTrustOrigin === true) { + if (records[deviceId]) { + records[deviceId] = { ...records[deviceId], accountOwnerUserId: null }; + demoted = true; + } + continue; + } delete records[deviceId]; removed.push(deviceId); } - if (removed.length > 0) writeRecords(records); + // A demotion is a real mutation even when nothing was deleted, so the + // write cannot be gated on `removed` alone. + if (removed.length > 0 || demoted) writeRecords(records); return removed; }, }; diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 76fb21f92..1f260b6e4 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -63,6 +63,7 @@ 28CFE3D489EA1B208D231519 /* SyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66B5024B0A05F3D9754101F1 /* SyncService.swift */; }; B70000000000000000000002 /* SyncRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70000000000000000000001 /* SyncRecoveryPolicy.swift */; }; B70000000000000000000004 /* SyncRecoveryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70000000000000000000003 /* SyncRecoveryPolicyTests.swift */; }; + B70000000000000000000099 /* SyncAccountConnectRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70000000000000000000098 /* SyncAccountConnectRecoveryTests.swift */; }; B90000000000000000000002 /* SyncTransportSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90000000000000000000001 /* SyncTransportSelectionTests.swift */; }; B80000000000000000000002 /* SyncConnectionRace.swift in Sources */ = {isa = PBXBuildFile; fileRef = B80000000000000000000001 /* SyncConnectionRace.swift */; }; B80000000000000000000004 /* SyncTerminalInputQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = B80000000000000000000003 /* SyncTerminalInputQueue.swift */; }; @@ -447,6 +448,7 @@ 66B5024B0A05F3D9754101F1 /* SyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncService.swift; path = ADE/Services/SyncService.swift; sourceTree = ""; }; B70000000000000000000001 /* SyncRecoveryPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncRecoveryPolicy.swift; path = ADE/Services/SyncRecoveryPolicy.swift; sourceTree = ""; }; B70000000000000000000003 /* SyncRecoveryPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncRecoveryPolicyTests.swift; path = ADETests/SyncRecoveryPolicyTests.swift; sourceTree = ""; }; + B70000000000000000000098 /* SyncAccountConnectRecoveryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncAccountConnectRecoveryTests.swift; path = ADETests/SyncAccountConnectRecoveryTests.swift; sourceTree = ""; }; B90000000000000000000001 /* SyncTransportSelectionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncTransportSelectionTests.swift; path = ADETests/SyncTransportSelectionTests.swift; sourceTree = ""; }; B80000000000000000000001 /* SyncConnectionRace.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncConnectionRace.swift; path = ADE/Services/SyncConnectionRace.swift; sourceTree = ""; }; B80000000000000000000003 /* SyncTerminalInputQueue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncTerminalInputQueue.swift; path = ADE/Services/SyncTerminalInputQueue.swift; sourceTree = ""; }; @@ -1055,6 +1057,7 @@ 14C0DF7FEB4C2EB854BAC888 /* ADETests.swift */, AD0000000000000000000A06 /* AccountEmailAuthFlowTests.swift */, B70000000000000000000003 /* SyncRecoveryPolicyTests.swift */, + B70000000000000000000098 /* SyncAccountConnectRecoveryTests.swift */, B90000000000000000000001 /* SyncTransportSelectionTests.swift */, AF00000000000000000000A4 /* PairingAndDpopTests.swift */, AC1000000000000000000008 /* ClipPairingHandoffTests.swift */, @@ -1556,6 +1559,7 @@ 7B70BE6839672E5D2D006B28 /* ADETests.swift in Sources */, AD0000000000000000000B06 /* AccountEmailAuthFlowTests.swift in Sources */, B70000000000000000000004 /* SyncRecoveryPolicyTests.swift in Sources */, + B70000000000000000000099 /* SyncAccountConnectRecoveryTests.swift in Sources */, B90000000000000000000002 /* SyncTransportSelectionTests.swift in Sources */, AF00000000000000000000C4 /* PairingAndDpopTests.swift in Sources */, AC1100000000000000000008 /* ClipPairingHandoffTests.swift in Sources */, diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index cb4eedd5c..acfa57bae 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -833,6 +833,27 @@ enum SyncConnectionRouteKind: Int, Equatable { case relay = 2 } +/// The machine a user-initiated connect is aimed at, which is NOT the same +/// thing as the machine we are attached to. Connection copy used to derive +/// every name from the active profile — the last machine that connected — so +/// dialling a second machine reported the first one's name in the progress +/// line, the unreachable line, and the Hub chip all at once. The attempt owns +/// its own name from the moment it starts, and keeps it after it fails so the +/// failure can say which machine actually failed. +struct SyncConnectAttemptTarget: Equatable { + let machineName: String + let machineIdentity: String? +} + +/// Why the most recent user-initiated attempt failed. Separate from `lastError` +/// because a failed switch restores the connection it interrupted, and that +/// restore's `hello_ok` clears `lastError` — which would erase the explanation +/// for the failure the user just watched. Scoped to one attempt: every new +/// attempt clears it, so it never needs to carry which machine it was about. +struct SyncConnectAttemptFailure: Equatable { + let message: String +} + private struct AccountAdoptionRoute: Equatable, Hashable { let endpoint: SyncConnectionEndpointAttempt let kind: AccountMachineEndpoint.Kind @@ -2622,7 +2643,11 @@ func syncAccountMachineNavigationIsCurrent( activeHostIdentity: String?, connectionState: RemoteConnectionState ) -> Bool { - guard connectionState == .connected, + // `.syncing` is attached — it is what every connect path settles into before + // it reaches `.connected`. Demanding `.connected` exactly meant a link tapped + // mid-hydration decided we were on the wrong machine and re-paired to the + // machine we were already talking to, tearing down a healthy connection. + guard connectionState == .connected || connectionState == .syncing, let targetDeviceId = targetDeviceId? .trimmingCharacters(in: .whitespacesAndNewlines), !targetDeviceId.isEmpty, @@ -2634,6 +2659,56 @@ func syncAccountMachineNavigationIsCurrent( return targetDeviceId == activeHostIdentity } +/// Whether a landing `hello_ok` means the user must be returned to the Hub. +/// +/// A project belongs to the machine it lives on, so attaching to a different +/// machine has to clear it. The subtlety is WHICH identity to compare against: +/// every switch path calls `saveProfile(target)` before the hello arrives, and +/// `saveProfile` overwrites `activeProjectHostIdentity` with the target's +/// identity. Comparing against that field alone compares B to B, never fires, +/// and leaves the user inside a project the new host has never heard of. +/// `baselineHostIdentity` is captured before that write and is authoritative +/// when present. +func syncHubTransitionIsOwed( + baselineHostIdentity: String?, + incomingHostIdentity: String?, + hasActiveProject: Bool +) -> Bool { + guard hasActiveProject, let incoming = incomingHostIdentity else { return false } + return baselineHostIdentity != incoming +} + +/// Which paired secret an account `hello_ok` leaves us holding. +/// +/// Mirrors the web client's `resolveAccountHelloPairing` +/// (apps/desktop/src/shared/accountDirectory.ts). A host that OMITS +/// `accountPairing` is saying "keep the credential you already have" — it does +/// not reissue one on every hello. iOS used to treat that as fatal, so a phone +/// whose profile had been cleared (an app reinstall keeps Keychain items but +/// wipes UserDefaults) could never recover a secret it was still holding, and +/// the only remedy on offer was walking back to the Mac. +/// +/// A `accountPairing` that is PRESENT but does not match this device is a +/// different matter and is still rejected outright — falling back there would +/// let a partial or mismatched host response silently pass as success. +func syncResolveAccountHelloPairedSecret( + payload: [String: Any], + expectedDeviceId: String, + storedSecret: String? +) -> String? { + guard let expected = syncNormalizedCommandScopeValue(expectedDeviceId) else { return nil } + // Absent key only. An explicit null is a response, not an omission. + guard payload.index(forKey: "accountPairing") != nil else { + return syncNormalizedCommandScopeValue(storedSecret) + } + guard let pairing = payload["accountPairing"] as? [String: Any], + syncNormalizedCommandScopeValue(pairing["deviceId"] as? String) == expected, + let secret = syncNormalizedCommandScopeValue(pairing["secret"] as? String) else { + return nil + } + return secret +} + /// One decision table shared by the app root and both possible consumers. The /// active project's persisted row wins even when a copied link carries lane or /// branch hints, preserving compatibility with hosts that predate the roster. @@ -3185,9 +3260,27 @@ func syncPreferredRecoveryActionName( @MainActor final class SyncService: ObservableObject { - @Published private(set) var connectionState: RemoteConnectionState = .disconnected + @Published private(set) var connectionState: RemoteConnectionState = .disconnected { + didSet { + guard connectionState != oldValue else { return } + // Reaching an attached state hands naming back to the real host identity. + // Clearing here — rather than at each of the several places that flip the + // state — is what stops a stale attempt name from outliving the attempt. + if isAttached { + connectAttemptTarget = nil + } + } + } @Published private(set) var hostName: String? + /// Attached to a machine. `.syncing` counts: it is what every connect path + /// settles into before `.connected`, and treating it as "not attached" is + /// what made a link tapped mid-hydration re-pair to the machine we were + /// already talking to. + var isAttached: Bool { + connectionState == .connected || connectionState == .syncing + } + /// Human-facing name of the connected machine, or a neutral "your Mac" /// fallback. Shared by Linear connect/status copy (and available to other /// surfaces that otherwise re-derive the same fallback). @@ -3224,6 +3317,21 @@ final class SyncService: ObservableObject { @Published private(set) var accountConnectStageLabel: String? /// A brief success affordance shared by the access gate, Hub, and Settings. @Published private(set) var accountConnectSuccessLabel: String? + /// Names the machine the in-flight (or most recently failed) user-initiated + /// connect is aimed at. Survives the failure on purpose: "Cannot reach X" + /// has to name the machine the user actually chose, not whichever one the + /// saved profile happens to point at. Cleared on success and on any user + /// connection change. + @Published private(set) var connectAttemptTarget: SyncConnectAttemptTarget? + /// The most recent user-initiated attempt failure, kept even when the + /// previous connection is successfully restored afterwards. Cleared when a + /// new attempt starts. + @Published private(set) var lastConnectAttemptFailure: SyncConnectAttemptFailure? + /// Identity of the machine a user-initiated transition is leaving, captured + /// before `saveProfile` retargets the active machine. `applyHelloPayload` + /// compares against this rather than `activeProjectHostIdentity`, which the + /// switch itself has already overwritten. Nil outside a transition. + private var pendingHubTransitionBaselineHostIdentity: String? private var accountNavigationInFlight: ( id: UUID, machineKey: String, @@ -3849,20 +3957,50 @@ final class SyncService: ObservableObject { projectHubPresented = false } - /// A user-requested machine transition always returns the UI to the Hub - /// before the socket changes. The active project remains cached, but no - /// in-project screen can keep rendering state owned by the previous machine. + /// Resets the transient affordances a fresh user-initiated attempt owns. + /// + /// This used to also force `projectHubPresented = true`, on the theory that a + /// machine transition must not leave in-project screens rendering the old + /// machine's state. That rule is right but was applied at the wrong moment: + /// merely *tapping* a machine yanked the user out of their project before + /// anyone knew whether the new machine would answer, so a failed switch cost + /// them their place for nothing. `applyHelloPayload` already enforces the + /// same rule at the honest moment — when a hello actually lands from a + /// different machine than the active project belongs to. func prepareForUserConnectionChange() { - projectHubPresented = true accountConnectSuccessClearTask?.cancel() accountConnectSuccessClearTask = nil accountConnectSuccessLabel = nil accountConnectStageLabel = nil accountPairingPinFallbackHost = nil + connectAttemptTarget = nil + lastConnectAttemptFailure = nil + // Owned here rather than by the callers. Requiring each switch path to + // remember a second call is how the Clip-handoff and SSH-bootstrap paths + // silently kept the bug this baseline exists to fix: they call this, then + // `saveProfile` — which overwrites the identity the Hub decision reads — + // and nothing captured what they were leaving. + pendingHubTransitionBaselineHostIdentity = syncNormalizedCommandScopeValue( + activeHostProfile?.hostIdentity ?? activeHostProfile?.lastHostDeviceId + ) + } + + /// Names the machine a user-initiated attempt is aimed at, for connection + /// copy. Purely cosmetic — the Hub baseline is captured by + /// `prepareForUserConnectionChange()` so it cannot be forgotten here. + private func setConnectAttemptTarget(machineName: String?, machineIdentity: String?) { + guard let name = syncNonEmpty(machineName) else { return } + connectAttemptTarget = SyncConnectAttemptTarget( + machineName: name, + machineIdentity: syncNonEmpty(machineIdentity) + ) } func disconnectForUserConnectionChange() { prepareForUserConnectionChange() + // An explicit disconnect has no hello to arrive later, so the Hub + // transition has to happen here or not at all. + projectHubPresented = true disconnect() } @@ -5504,6 +5642,23 @@ final class SyncService: ObservableObject { .first } + /// The paired secret held for a machine identity, found without needing a + /// saved profile. The account adoption path is reached precisely when the + /// profile lookup came up empty, and "no profile" does not have to mean "no + /// credential" — a profile store that was cleared or migrated can leave a + /// perfectly good Keychain entry behind. (Note a full reinstall is NOT one of + /// those cases: `MobileTrustResetPolicy` keys its one-shot flag off + /// UserDefaults, so a reinstall re-fires it and clears the tokens too.) + private func storedPairedSecret(forHostIdentity hostIdentity: String) -> String? { + guard let identity = syncNonEmpty(hostIdentity) else { return nil } + if let profile = loadSavedProfiles().values.first(where: { + $0.hostIdentity == identity || $0.lastHostDeviceId == identity + }), let token = tokenForProfile(profile) { + return token + } + return keychain.loadToken(hostKey: "machine:\(identity.lowercased())") + } + private func tokenForProfile(_ profile: HostConnectionProfile?) -> String? { guard let profile else { return nil } if let key = profileStorageKey(profile), let token = keychain.loadToken(hostKey: key) { @@ -5623,8 +5778,17 @@ final class SyncService: ObservableObject { ) } - func reconnect(toSavedHost host: DiscoveredSyncHost) async { + /// Returns whether THIS host was reached. Callers must not infer that from + /// `connectionState` any more: a failed attempt now restores the connection + /// it interrupted, so an attached state after this returns may well belong to + /// the previous machine rather than the one that was asked for. + @discardableResult + func reconnect(toSavedHost host: DiscoveredSyncHost) async -> Bool { prepareForUserConnectionChange() + setConnectAttemptTarget( + machineName: host.hostName, + machineIdentity: host.hostIdentity + ) ProductAnalytics.shared.captureQuickConnect(.pairedMachine) let profiles = loadSavedProfiles() let candidates = profiles.values.filter { profile in @@ -5636,15 +5800,43 @@ final class SyncService: ObservableObject { } return matchesDiscoveredHost(host, profile: profile) } + // Same hazard as the account paths: `saveProfile` retargets the active + // machine before anything has answered, so a saved row that no longer + // responds must not take the live connection down with it. Captured above + // the credential guard too — that guard used to force `.error` over a + // still-open socket, which leaves the app attached to the previous machine + // but unable to send it anything (`canSendLiveRequests()` reads this state). + let previousProfile = activeHostProfile + let wasAttached = isAttached guard let profile = candidates.sorted(by: { $0.updatedAt > $1.updatedAt }).first, tokenForProfile(profile) != nil else { clearConnectTimingMetrics() - lastError = "This saved machine no longer has pairing credentials. Pair again from Settings." - connectionState = .error - return + let message = "This saved machine no longer has pairing credentials. Pair again from Settings." + lastConnectAttemptFailure = SyncConnectAttemptFailure(message: message) + // Nothing was touched, so a live connection stays live and honest. + if !wasAttached { + lastError = message + connectionState = .error + } + return false } saveProfile(profile) await reconnectIfPossible(userInitiated: true) + // Attached AND attached to the machine that was asked for. Testing the + // state alone would report success for a restore of the PREVIOUS machine, + // which is exactly what the doc comment above warns callers against. + let targetKey = profileStorageKey(profile) + let reachedTarget = isAttached && activeHostProfile.flatMap(profileStorageKey) == targetKey + if reachedTarget { return true } + lastConnectAttemptFailure = SyncConnectAttemptFailure(message: lastError ?? "ADE could not reconnect to \(host.hostName).") + if let previousProfile, profileStorageKey(previousProfile) != targetKey { + if wasAttached { + await restorePreviousConnection(previousProfile) + } else if tokenForProfile(previousProfile) != nil { + saveProfile(previousProfile) + } + } + return false } /// The saved profile paired to this QR's machine (matched by host identity) @@ -5671,6 +5863,10 @@ final class SyncService: ObservableObject { ) async -> Bool { guard var profile = savedProfileForPairingQr(hostIdentity: hostIdentity) else { return false } prepareForUserConnectionChange() + setConnectAttemptTarget( + machineName: profile.hostName, + machineIdentity: profile.hostIdentity ?? profile.lastHostDeviceId + ) let directHosts = directCandidates.compactMap { syncEndpointHost($0) } let relayHosts = deduplicatedAddresses(relayCandidates.filter(syncIsFullWebSocketRoute)) profile.savedAddressCandidates = deduplicatedAddresses(profile.savedAddressCandidates + directHosts) @@ -6396,7 +6592,11 @@ final class SyncService: ObservableObject { throw fail(error) } if ownership.failed(candidateId: candidateId) == .exhausted { - group.cancelAll() + // Drain, don't just cancel. A candidate that authenticates in the + // same tick the group is cancelled still returns `.adopted`, and + // dropping that result on the floor leaks an authenticated socket + // to the machine we just gave up on. + try await drainAndCancel(&group) throw fail(AccountAdoptionRoutesExhaustedError( machineName: machineName, routeLabels: triedRouteLabels, @@ -6435,6 +6635,10 @@ final class SyncService: ObservableObject { authorization: AccountPairingAuthorization ) async -> Bool { prepareForUserConnectionChange() + setConnectAttemptTarget( + machineName: machine.displayName, + machineIdentity: machine.deviceId + ) defer { accountConnectStageLabel = nil } ProductAnalytics.shared.captureQuickConnect(.accountMachine) let owner = authorization.ownerId.trimmingCharacters(in: .whitespacesAndNewlines) @@ -6452,6 +6656,47 @@ final class SyncService: ObservableObject { return false } + // Already attached to this exact machine: reconnecting would tear down a + // healthy connection to rebuild the same one, and (over Relay) race our own + // live tunnel for the same Durable Object. This has to precede the + // saved-profile branch below, which would otherwise re-save and redial the + // machine we are already talking to. + if isAttached, + syncNonEmpty(activeHostProfile?.hostIdentity ?? activeHostProfile?.lastHostDeviceId) + == expectedHostIdentity { + // Returning early skips the saved-profile branch, so run the checks that + // branch owns rather than silently dropping them: the profile must still + // belong to this account, and freshly-advertised routes are still worth + // learning even though we are not redialling. + if var existing = activeHostProfile { + guard existing.accountOwnerId == nil || existing.accountOwnerId == owner else { + lastError = "This saved Mac belongs to a different signed-in account." + connectionState = .error + ProductAnalytics.shared.captureMachineAdoptionOutcome(.failed) + return false + } + let learnedDirect = deduplicatedAddresses(machine.reachableEndpoints.compactMap { endpoint in + guard endpoint.kind != .relay else { return nil } + if let host = syncNonEmpty(endpoint.host) { return host } + return endpoint.url.flatMap(syncEndpointHost) + }) + let learnedRelays = deduplicatedAddresses(machine.reachableEndpoints.compactMap { endpoint in + guard endpoint.kind == .relay, + let url = syncNonEmpty(endpoint.url), + syncIsFullWebSocketRoute(url), + URL(string: url)?.scheme?.lowercased() == "wss" else { return nil } + return url + }) + existing.savedAddressCandidates = deduplicatedAddresses(existing.savedAddressCandidates + learnedDirect) + existing.savedRelayCandidates = deduplicatedAddresses((existing.savedRelayCandidates ?? []) + learnedRelays) + existing.relayAccountOwnerId = owner + existing.updatedAt = syncDateFormatter.string(from: Date()) + saveProfile(existing) + } + ProductAnalytics.shared.captureMachineAdoptionOutcome(.reconnected) + return true + } + let directHosts = deduplicatedAddresses(machine.reachableEndpoints.compactMap { endpoint in guard endpoint.kind != .relay else { return nil } if let host = syncNonEmpty(endpoint.host) { return host } @@ -6481,10 +6726,28 @@ final class SyncService: ObservableObject { existing.savedRelayCandidates = deduplicatedAddresses((existing.savedRelayCandidates ?? []) + relayRoutes) existing.relayAccountOwnerId = owner existing.updatedAt = syncDateFormatter.string(from: Date()) + // Same hazard as the adoption path below: `saveProfile` makes the target + // the active machine before anything has proven it answers, so a failed + // reconnect used to leave the user pointed at a machine they are not on + // and disconnected from the one they were. + let previousProfile = activeHostProfile + let wasAttached = isAttached saveProfile(existing) await reconnectIfPossible(userInitiated: true) - let reconnected = connectionState == .connected || connectionState == .syncing + let reconnected = isAttached ProductAnalytics.shared.captureMachineAdoptionOutcome(reconnected ? .reconnected : .failed) + if !reconnected { + lastConnectAttemptFailure = SyncConnectAttemptFailure(message: lastError ?? "ADE could not reach \(machine.displayName).") + if let previousProfile, + syncNonEmpty(previousProfile.hostIdentity ?? previousProfile.lastHostDeviceId) + != expectedHostIdentity { + if wasAttached { + await restorePreviousConnection(previousProfile) + } else if tokenForProfile(previousProfile) != nil { + saveProfile(previousProfile) + } + } + } return reconnected } @@ -6534,6 +6797,25 @@ final class SyncService: ObservableObject { let preferredDirectPort = classifiedDirectEndpoints.first(where: { $0.kind == .lan })?.attempt.port ?? classifiedDirectEndpoints.first(where: { $0.kind == .tailnet })?.attempt.port + // What to fall back to if this attempt fails. + // + // A machine that never answers must not cost the user the connection they + // already had. The obvious way to get that is to keep the old socket up + // during the race — and it does not work: `beginConnectAttempt()` bumps the + // attempt generation and `.connecting` makes `canSendLiveRequests()` false, + // and the old connection's liveness machinery is keyed to both. Its + // heartbeat loop exits permanently, its post-hello restoration abandons + // without completing, and a transient socket blip downgrades from + // "recover the transport" to a silent teardown with auto-reconnect off. + // A half-alive connection is worse than an honestly closed one. + // + // So the old connection is still surrendered up front, and the recovery is + // explicit: on failure we reconnect to it, which rebuilds all of that + // machinery through the normal path. The user sees a reconnect rather than + // a seamless hold, and stays where they were either way. + let previousProfile = activeHostProfile + let wasAttached = isAttached + var pairingGeneration: UInt64? var relayRouteFailed = false do { @@ -6601,13 +6883,15 @@ final class SyncService: ObservableObject { self.syncNonEmpty(brain["deviceId"] as? String) == expectedHostIdentity else { throw AccountAdoptionIdentityVerificationError(machineName: machine.displayName) } - let pairing = payload["accountPairing"] as? [String: Any] - guard self.syncNonEmpty(pairing?["deviceId"] as? String) == self.deviceId, - let pairedSecret = self.syncNonEmpty(pairing?["secret"] as? String) else { + guard let pairedSecret = syncResolveAccountHelloPairedSecret( + payload: payload, + expectedDeviceId: self.deviceId, + storedSecret: self.storedPairedSecret(forHostIdentity: expectedHostIdentity) + ) else { throw NSError( domain: "ADE", code: 33, - userInfo: [NSLocalizedDescriptionKey: "The Mac did not return saved connection details. Remove this iPhone from the Mac and try again."] + userInfo: [NSLocalizedDescriptionKey: "This Mac would not hand back a connection for this iPhone. Open ADE on the Mac, remove this iPhone under Settings → Devices, then connect again."] ) } @@ -6683,15 +6967,51 @@ final class SyncService: ObservableObject { accountPairingPinFallbackHost = signingPublicKey == nil && relayRouteFailed ? pinFallbackHost(for: machine) : nil + lastConnectAttemptFailure = SyncConnectAttemptFailure(message: message) + ProductAnalytics.shared.captureMachineAdoptionOutcome(.failed) + ProductAnalytics.shared.captureError(.pairing) lastError = message connectionState = .error setDomainStatus(SyncDomain.allCases, phase: .failed, error: message) - ProductAnalytics.shared.captureMachineAdoptionOutcome(.failed) - ProductAnalytics.shared.captureError(.pairing) + // Choosing a machine that turns out to be unreachable must not cost the + // user the machine they were already on, and must not silently leave the + // app pointed at the machine that just refused. Restoring is only + // possible when there was a live connection to restore; retargeting the + // saved profile back is owed either way, or every later auto-reconnect + // aims at the machine that just proved unreachable. + if let previousProfile, + syncNonEmpty(previousProfile.hostIdentity ?? previousProfile.lastHostDeviceId) + != expectedHostIdentity { + if wasAttached { + await restorePreviousConnection(previousProfile) + } else if tokenForProfile(previousProfile) != nil { + saveProfile(previousProfile) + } + } return false } } + /// Reconnects to the machine an attempt interrupted. `lastError` is + /// deliberately allowed to be cleared by the restore's own `hello_ok` — + /// `lastConnectAttemptFailure` is what keeps the failed attempt explainable + /// once we are attached somewhere else again. + private func restorePreviousConnection(_ profile: HostConnectionProfile) async { + guard tokenForProfile(profile) != nil else { return } + saveProfile(profile) + // Re-point the attempt at the machine we are actually dialling now. + // Leaving it on the machine that just failed would make the connecting copy + // name the wrong Mac for the whole restore — precisely the bug this type + // was introduced to kill, inverted. + setConnectAttemptTarget( + machineName: profile.hostName, + machineIdentity: profile.hostIdentity ?? profile.lastHostDeviceId + ) + allowAutoReconnect = true + setAutoReconnectPausedByUser(false) + await reconnectIfPossible(userInitiated: true) + } + /// Ensures a notification/Attention deeplink is resolved against its owning /// account machine before any project/session/PR lookup runs. A nil key is a /// legacy local link and needs no machine transition. @@ -6732,7 +7052,12 @@ final class SyncService: ObservableObject { ) } guard let machine else { - lastError = "That Mac is not available in your ADE account." + // A blocked navigation used to abort with nothing on screen: the tap + // simply did not work. Record it the same way a failed connect does so + // the reason is available to whatever surface the user is looking at. + let message = "That Mac is not available in your ADE account." + lastError = message + lastConnectAttemptFailure = SyncConnectAttemptFailure(message: message) return false } @@ -6748,7 +7073,9 @@ final class SyncService: ObservableObject { return true } guard let authorization = AccountService.shared.currentPairingAuthorization else { - lastError = "Sign in again to open work from that Mac." + let message = "Sign in again to open work from that Mac." + lastError = message + lastConnectAttemptFailure = SyncConnectAttemptFailure(message: message) return false } return await pairWithAccountMachine(machine, authorization: authorization) @@ -7393,6 +7720,7 @@ final class SyncService: ObservableObject { relayCandidates: [String] = [] ) async { prepareForUserConnectionChange() + setConnectAttemptTarget(machineName: hostName, machineIdentity: hostIdentity) lastPairingErrorCode = nil lastPairingFailure = nil relayAuthorizationRequirement = nil @@ -7681,6 +8009,10 @@ final class SyncService: ObservableObject { func disconnect(clearCredentials: Bool = false, suspendAutoReconnect: Bool = true) { beginConnectAttempt() clearConnectTimingMetrics() + // The attempt is over. Leaving its target set would let a later background + // reconnect to a DIFFERENT machine be labelled with this one's name. + connectAttemptTarget = nil + pendingHubTransitionBaselineHostIdentity = nil autoReconnectAwaitingLiveDiscovery = false if suspendAutoReconnect { setAutoReconnectPausedByUser(true) @@ -12442,6 +12774,11 @@ final class SyncService: ObservableObject { if let profile, let data = try? encoder.encode(profile) { if syncStableHostIdentityChanged(previous: previousProfile, next: profile) { resetTerminalSubscriptionState(clearHistory: true) + // Chat is machine-scoped for the same reason terminals are. Only the + // account-adoption and PIN paths used to clear it explicitly, so a + // saved-profile or QR switch carried the previous machine's chat events + // and history into the new machine's session. + resetChatEventState(clearHistory: true) } UserDefaults.standard.set(data, forKey: profileKey) if let key = profileStorageKey(profile) { @@ -15784,13 +16121,22 @@ final class SyncService: ObservableObject { let remoteHostSiteId = brain?["siteId"] as? String let incomingHostIdentity = syncNormalizedCommandScopeValue(remoteHostIdentity) ?? syncNormalizedCommandScopeValue(expectedHostIdentity) - if activeProjectId != nil, - let incomingHostIdentity, - ( - syncNormalizedCommandScopeValue(activeProjectHostIdentity) - ?? syncNormalizedCommandScopeValue(activeHostProfile?.hostIdentity) - ?? syncNormalizedCommandScopeValue(activeHostProfile?.lastHostDeviceId) - ) != incomingHostIdentity { + // A user-initiated transition records the machine it LEFT before + // `saveProfile` retargets everything below, so prefer it. Without that + // baseline this comparison reads a field the switch itself already + // overwrote with the incoming machine's identity — it would compare B to B, + // never fire, and strand the user inside the previous machine's project + // while attached to a host that has never heard of it. + let hubTransitionBaseline = pendingHubTransitionBaselineHostIdentity + ?? syncNormalizedCommandScopeValue(activeProjectHostIdentity) + ?? syncNormalizedCommandScopeValue(activeHostProfile?.hostIdentity) + ?? syncNormalizedCommandScopeValue(activeHostProfile?.lastHostDeviceId) + pendingHubTransitionBaselineHostIdentity = nil + if syncHubTransitionIsOwed( + baselineHostIdentity: hubTransitionBaseline, + incomingHostIdentity: incomingHostIdentity, + hasActiveProject: activeProjectId != nil + ) { setActiveProjectId(nil) projectHubPresented = true } diff --git a/apps/ios/ADE/Views/Hub/HubComponents.swift b/apps/ios/ADE/Views/Hub/HubComponents.swift index 962c351b4..a1edd7c84 100644 --- a/apps/ios/ADE/Views/Hub/HubComponents.swift +++ b/apps/ios/ADE/Views/Hub/HubComponents.swift @@ -101,13 +101,29 @@ struct HubConnectionPill: View { } } + /// The machine this pill should name: the one we're attached to, or — while + /// an attempt is in flight or has just failed — the one that attempt targeted. + private var machineName: String? { + syncConnectionSubjectMachineName( + transport: syncService.connectionHealth.transport, + attemptMachineName: syncService.connectAttemptTarget.flatMap { + accountMachinePresentationName( + hostIdentity: $0.machineIdentity, + fallback: $0.machineName, + machines: account.machines + ) + }, + hostDisplayName: accountMachinePresentationName( + hostIdentity: syncService.activeHostProfile?.hostIdentity, + fallback: syncService.hostName, + machines: account.machines + ) + ) + } + private var label: String { - if let host = accountMachinePresentationName( - hostIdentity: syncService.activeHostProfile?.hostIdentity, - fallback: syncService.hostName, - machines: account.machines - ) { - return host + if let machineName { + return machineName } switch syncService.connectionHealth.transport { case .connected: return "Connected" @@ -907,7 +923,12 @@ struct HubNoMachineState: View { private var statusText: String { if syncService.connectionState == .error { - return "Cannot reach \(machineDisplayName ?? "machine")" + let subject = syncConnectionSubjectMachineName( + transport: .unreachable, + attemptMachineName: syncService.connectAttemptTarget?.machineName, + hostDisplayName: machineDisplayName + ) + return "Cannot reach \(subject ?? "machine")" } if hasSavedMachine { return "Disconnected from \(machineDisplayName ?? "saved machine")" diff --git a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift index c716b10ab..3d8c871e1 100644 --- a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift +++ b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift @@ -367,8 +367,11 @@ struct SettingsConnectionSnapshot: Equatable { var health: SyncConnectionHealth var connectionState: RemoteConnectionState var routeKind: SyncConnectionRouteKind? + /// The machine this phone is attached to, or was last attached to. var hostDisplayName: String? - var pendingHostName: String? + /// The machine the in-flight or just-failed attempt is aimed at. Kept apart + /// from `hostDisplayName` because these are only the same Mac by coincidence. + var connectAttemptHostName: String? var canReconnectToSavedHost: Bool var errorMessage: String? var accountConnectStageLabel: String? @@ -401,7 +404,7 @@ private final class SettingsConnectionPresentationModel: ObservableObject { connectionState: .disconnected, routeKind: nil, hostDisplayName: nil, - pendingHostName: nil, + connectAttemptHostName: nil, canReconnectToSavedHost: false, errorMessage: nil ) @@ -459,6 +462,15 @@ private final class SettingsConnectionPresentationModel: ObservableObject { fallback: Self.trimmedNonEmpty(syncService.hostName) ?? Self.trimmedNonEmpty(activeProfile?.hostName), machines: AccountService.shared.machines ) + // Resolved through the directory too, so a machine the user renamed reads + // the same while you're reaching for it as it does once you're on it. + let attemptHostName = syncService.connectAttemptTarget.flatMap { target in + accountMachinePresentationName( + hostIdentity: target.machineIdentity, + fallback: target.machineName, + machines: AccountService.shared.machines + ) + } let address = Self.trimmedNonEmpty(syncService.currentAddress) ?? Self.trimmedNonEmpty(activeProfile?.lastSuccessfulAddress) let displayedDiscovery = syncDiscoveredHostsForDisplay( savedHosts: syncService.savedReconnectHosts, @@ -472,7 +484,13 @@ private final class SettingsConnectionPresentationModel: ObservableObject { connectionState: syncService.connectionState, routeKind: health.transport.isConnected ? syncService.lastConnectedRouteKind : nil, hostDisplayName: hostDisplayName, - pendingHostName: health.transport == .connecting || health.transport == .unreachable ? hostDisplayName : nil, + connectAttemptHostName: health.transport == .connecting || health.transport == .unreachable + ? syncConnectionSubjectMachineName( + transport: health.transport, + attemptMachineName: attemptHostName, + hostDisplayName: hostDisplayName + ) + : nil, canReconnectToSavedHost: syncService.canReconnectToSavedHost, errorMessage: health.transport == .unreachable ? health.lastFailureMessage : nil, accountConnectStageLabel: syncService.accountConnectStageLabel, @@ -800,6 +818,42 @@ private func mobileUsageSettingsResetLabel(_ iso: String) -> String { // MARK: - Machines section (M5 / M14) +/// The message a failed row should carry. +/// +/// `lastError` describes the CONNECTION, and after a failed switch the +/// connection is fine — it is the previous machine's, restored — so `lastError` +/// is nil exactly when the user most needs to be told why the machine they +/// picked would not answer. `lastConnectAttemptFailure` outlives that restore +/// and is the only source that still knows. +func settingsMachineRowErrorMessage( + attemptFailure: SyncConnectAttemptFailure?, + lastError: String?, + fallback: String +) -> String { + func nonEmpty(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { return nil } + return trimmed + } + return nonEmpty(attemptFailure?.message) ?? nonEmpty(lastError) ?? fallback +} + +/// A row failure describes one attempt against one machine, so it survives +/// exactly as long as it stays true: attaching to a machine disproves that +/// machine's failure. Failures against OTHER machines are left alone +/// deliberately — a failed switch restores the connection it interrupted, so +/// "connected to the Studio, MacBook row explaining why it would not answer" is +/// the honest steady state rather than the contradiction it used to be. +func settingsMachineRowErrorsRetiring( + _ existing: [String: String], + attachedEntryId: String? +) -> [String: String] { + guard let attachedEntryId else { return existing } + var remaining = existing + remaining.removeValue(forKey: attachedEntryId) + return remaining +} + /// The CONNECTIONS machine list: a unified, deduplicated roster of the Macs a /// phone can reach — machines on the signed-in account plus previously-paired /// machines — ranked current → online → offline. Shows the top three inline @@ -833,6 +887,12 @@ struct SettingsMachinesSection: View { syncService.connectionState == .connected || syncService.connectionState == .syncing } + /// Row id of the machine currently attached, so its stale failure — and only + /// its — can be retired the moment it is disproven. + private var currentEntryId: String? { + entries.first(where: \.isCurrent)?.id + } + private var currentIdentity: String? { let value = syncService.activeHostProfile?.hostIdentity?.trimmingCharacters(in: .whitespacesAndNewlines) return (value?.isEmpty == false) ? value : nil @@ -959,6 +1019,9 @@ struct SettingsMachinesSection: View { } } .task { await account.loadMachines() } + .onChange(of: currentEntryId) { _, entryId in + rowErrors = settingsMachineRowErrorsRetiring(rowErrors, attachedEntryId: entryId) + } .sheet(isPresented: $seeAllPresented) { allMachinesSheet } @@ -1096,7 +1159,7 @@ struct SettingsMachinesSection: View { private func connect(_ entry: Entry) { guard !entry.isCurrent, connectingId == nil else { return } connectingId = entry.id - rowErrors[entry.id] = nil + rowErrors = [:] Task { @MainActor in switch entry.kind { case .account(let machine): @@ -1114,17 +1177,28 @@ struct SettingsMachinesSection: View { ADEHaptics.success() } else { ADEHaptics.error() - rowErrors[entry.id] = syncService.lastError ?? "ADE could not connect to that Mac. Try again." + rowErrors[entry.id] = settingsMachineRowErrorMessage( + attemptFailure: syncService.lastConnectAttemptFailure, + lastError: syncService.lastError, + fallback: "ADE could not connect to that Mac. Try again." + ) } case .saved(let host): - await syncService.reconnect(toSavedHost: host) + // Ask the call what happened. `connectionState` can be attached here + // because a failed attempt restored the PREVIOUS machine, which is not + // the same thing as this row succeeding. + let reconnected = await syncService.reconnect(toSavedHost: host) connectingId = nil - if syncService.connectionState == .connected || syncService.connectionState == .syncing { + if reconnected { ADEHaptics.success() } else { ADEHaptics.error() - rowErrors[entry.id] = syncService.lastError ?? "ADE could not reconnect to \(host.hostName)." + rowErrors[entry.id] = settingsMachineRowErrorMessage( + attemptFailure: syncService.lastConnectAttemptFailure, + lastError: syncService.lastError, + fallback: "ADE could not reconnect to \(host.hostName)." + ) } } } diff --git a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift index a6030e061..31589b3e6 100644 --- a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift +++ b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift @@ -31,6 +31,32 @@ func settingsConnectedRouteChipText( return "Connected in \(durationLabel)s" } +/// Which machine a connection line should name. While connected the attached +/// host is the only truth, but during an attempt — and after it fails — the +/// machine the user aimed at is: an account can hold several Macs, and naming +/// the last-connected one blames a machine that took no part in the failure. +func syncConnectionSubjectMachineName( + transport: SyncTransportHealth, + attemptMachineName: String?, + hostDisplayName: String? +) -> String? { + let host = syncTrimmedMachineName(hostDisplayName) + switch transport { + case .connecting, .unreachable: + return syncTrimmedMachineName(attemptMachineName) ?? host + case .connected, .disconnected: + return host + } +} + +private func syncTrimmedMachineName(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed +} + struct SettingsConnectionHeader: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -181,7 +207,7 @@ struct SettingsConnectionHeader: View { } private var pendingHostName: String? { - snapshot.pendingHostName + snapshot.connectAttemptHostName } private var compatibilityMessage: String? { @@ -208,7 +234,9 @@ struct SettingsConnectionHeader: View { // Name the machine you're attached to, right under the status word. return snapshot.hostDisplayName case .connecting: - return snapshot.accountConnectStageLabel ?? "Connecting to saved machine" + // Never claim the target is a *saved* machine — an account adoption can + // be reaching a Mac this phone has never paired with. + return snapshot.accountConnectStageLabel ?? "Connecting to your machine" case .unreachable: return "Unable to reach your machine" case .disconnected: diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index c2197bb08..3fe5bde24 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -24319,3 +24319,143 @@ final class WorkChatTranscriptLoadStateTests: XCTestCase { ) } } + +final class SyncConnectionSubjectMachineNameTests: XCTestCase { + func testConnectedCopyNamesTheAttachedHostNotTheAttemptTarget() { + XCTAssertEqual( + syncConnectionSubjectMachineName( + transport: .connected, + attemptMachineName: "MacBook Pro (97)", + hostDisplayName: "Arul's Mac Studio" + ), + "Arul's Mac Studio" + ) + } + + func testPendingCopyNamesTheMachineTheAttemptTargets() { + XCTAssertEqual( + syncConnectionSubjectMachineName( + transport: .connecting, + attemptMachineName: "MacBook Pro (97)", + hostDisplayName: "Arul's Mac Studio" + ), + "MacBook Pro (97)" + ) + XCTAssertEqual( + syncConnectionSubjectMachineName( + transport: .unreachable, + attemptMachineName: "MacBook Pro (97)", + hostDisplayName: "Arul's Mac Studio" + ), + "MacBook Pro (97)", + "A failed attempt must keep naming the machine it targeted" + ) + } + + func testPendingCopyFallsBackToTheSavedHostWithoutAnAttemptTarget() { + XCTAssertEqual( + syncConnectionSubjectMachineName( + transport: .connecting, + attemptMachineName: nil, + hostDisplayName: "Arul's Mac Studio" + ), + "Arul's Mac Studio" + ) + XCTAssertEqual( + syncConnectionSubjectMachineName( + transport: .unreachable, + attemptMachineName: " ", + hostDisplayName: "Arul's Mac Studio" + ), + "Arul's Mac Studio", + "A blank target name is no name at all" + ) + XCTAssertNil( + syncConnectionSubjectMachineName( + transport: .connecting, + attemptMachineName: nil, + hostDisplayName: nil + ) + ) + } + + func testDisconnectedCopyStillNamesWhereYouLeftOff() { + XCTAssertEqual( + syncConnectionSubjectMachineName( + transport: .disconnected, + attemptMachineName: "MacBook Pro (97)", + hostDisplayName: "Arul's Mac Studio" + ), + "Arul's Mac Studio", + "\"Last connected to\" is a fact about the previous host, not the next attempt" + ) + } +} + +final class SettingsMachineRowErrorLifetimeTests: XCTestCase { + private let errors = ["account-1": "The Mac did not return saved connection details."] + + func testFailureSurvivesWhileNothingHasDisprovenIt() { + XCTAssertEqual( + settingsMachineRowErrorsRetiring(errors, attachedEntryId: nil), + errors + ) + } + + /// Attaching to a machine disproves that machine's failure. + func testAttachingToAMachineRetiresItsOwnFailure() { + XCTAssertTrue( + settingsMachineRowErrorsRetiring(errors, attachedEntryId: "account-1").isEmpty + ) + } + + /// The steady state after a failed switch: still attached to the machine that + /// works, with the machine that refused still explaining itself. Clearing + /// this on any connection would delete the only answer the user has. + func testFailureAgainstAnotherMachineSurvivesBeingConnectedElsewhere() { + XCTAssertEqual( + settingsMachineRowErrorsRetiring(errors, attachedEntryId: "account-2"), + errors + ) + } +} + +final class SettingsMachineRowErrorMessageTests: XCTestCase { + /// The regression this exists for: a failed switch restores the previous + /// connection, whose `hello_ok` clears `lastError`, so the row would have + /// fallen back to a generic "try again" and thrown away the real reason. + func testAttemptFailureIsPreferredOverAClearedLastError() { + XCTAssertEqual( + settingsMachineRowErrorMessage( + attemptFailure: SyncConnectAttemptFailure( + message: "This Mac would not hand back a connection for this iPhone." + ), + lastError: nil, + fallback: "ADE could not connect to that Mac. Try again." + ), + "This Mac would not hand back a connection for this iPhone." + ) + } + + func testLastErrorIsUsedWhenNoAttemptFailureWasRecorded() { + XCTAssertEqual( + settingsMachineRowErrorMessage( + attemptFailure: nil, + lastError: "Sign in again, then try connecting.", + fallback: "ADE could not connect to that Mac. Try again." + ), + "Sign in again, then try connecting." + ) + } + + func testFallbackIsUsedWhenNothingExplainsTheFailure() { + XCTAssertEqual( + settingsMachineRowErrorMessage( + attemptFailure: nil, + lastError: " ", + fallback: "ADE could not connect to that Mac. Try again." + ), + "ADE could not connect to that Mac. Try again." + ) + } +} diff --git a/apps/ios/ADETests/SyncAccountConnectRecoveryTests.swift b/apps/ios/ADETests/SyncAccountConnectRecoveryTests.swift new file mode 100644 index 000000000..180ae1b5a --- /dev/null +++ b/apps/ios/ADETests/SyncAccountConnectRecoveryTests.swift @@ -0,0 +1,215 @@ +import XCTest +@testable import ADE + +/// Regressions for the account-connect deadlock and the machine-switch +/// lifecycle. Both were reported from the field: a phone that could reach a Mac +/// perfectly well (socket up, challenge passed, `hello_ok` received) still +/// refused to connect, and choosing a second machine cost the user the machine +/// they were already on. +final class SyncAccountConnectRecoveryTests: XCTestCase { + + // MARK: - syncResolveAccountHelloPairedSecret + + /// The bug: a host that reuses an existing pairing OMITS `accountPairing` + /// from `hello_ok` rather than reissuing one. iOS treated that as fatal, so a + /// phone holding a perfectly good secret was told to go remove itself from + /// the Mac. The web client has always fallen back here. + func testOmittedAccountPairingFallsBackToStoredSecret() { + let secret = syncResolveAccountHelloPairedSecret( + payload: ["brain": ["deviceId": "host-1"]], + expectedDeviceId: "phone-1", + storedSecret: "stored-secret" + ) + XCTAssertEqual(secret, "stored-secret") + } + + func testOmittedAccountPairingWithNoStoredSecretIsRejected() { + XCTAssertNil(syncResolveAccountHelloPairedSecret( + payload: ["brain": ["deviceId": "host-1"]], + expectedDeviceId: "phone-1", + storedSecret: nil + )) + } + + func testOmittedAccountPairingTreatsBlankStoredSecretAsAbsent() { + XCTAssertNil(syncResolveAccountHelloPairedSecret( + payload: [:], + expectedDeviceId: "phone-1", + storedSecret: " " + )) + } + + func testPresentAccountPairingWins() { + let secret = syncResolveAccountHelloPairedSecret( + payload: ["accountPairing": ["deviceId": "phone-1", "secret": "fresh-secret"]], + expectedDeviceId: "phone-1", + storedSecret: "stored-secret" + ) + XCTAssertEqual(secret, "fresh-secret") + } + + /// The safety half of the fallback: a host that DID answer, with something + /// that is not for this device, must never be quietly papered over with a + /// stored credential. + func testPresentAccountPairingForAnotherDeviceIsRejectedDespiteStoredSecret() { + XCTAssertNil(syncResolveAccountHelloPairedSecret( + payload: ["accountPairing": ["deviceId": "someone-else", "secret": "fresh-secret"]], + expectedDeviceId: "phone-1", + storedSecret: "stored-secret" + )) + } + + func testPresentAccountPairingWithEmptySecretIsRejected() { + XCTAssertNil(syncResolveAccountHelloPairedSecret( + payload: ["accountPairing": ["deviceId": "phone-1", "secret": ""]], + expectedDeviceId: "phone-1", + storedSecret: "stored-secret" + )) + } + + /// An explicit null is a response, not an omission — only a missing key means + /// "keep what you have". + func testExplicitNullAccountPairingIsRejected() { + XCTAssertNil(syncResolveAccountHelloPairedSecret( + payload: ["accountPairing": NSNull()], + expectedDeviceId: "phone-1", + storedSecret: "stored-secret" + )) + } + + func testMalformedAccountPairingIsRejected() { + XCTAssertNil(syncResolveAccountHelloPairedSecret( + payload: ["accountPairing": "not-an-object"], + expectedDeviceId: "phone-1", + storedSecret: "stored-secret" + )) + } + + func testBlankExpectedDeviceIdIsRejected() { + XCTAssertNil(syncResolveAccountHelloPairedSecret( + payload: [:], + expectedDeviceId: " ", + storedSecret: "stored-secret" + )) + } + + func testDeviceIdComparisonIgnoresSurroundingWhitespace() { + let secret = syncResolveAccountHelloPairedSecret( + payload: ["accountPairing": ["deviceId": " phone-1 ", "secret": " fresh-secret "]], + expectedDeviceId: "phone-1", + storedSecret: nil + ) + XCTAssertEqual(secret, "fresh-secret") + } + + // MARK: - syncHubTransitionIsOwed + + /// The regression: every switch path calls `saveProfile(target)` before the + /// hello lands, and `saveProfile` overwrites `activeProjectHostIdentity` with + /// the target's identity. Comparing against that field compares B to B, so + /// the Hub transition never fired and the user was left inside the previous + /// machine's project while attached to a host that has never heard of it. + func testTransitionIsOwedWhenTheBaselineMachineDiffersFromTheIncomingOne() { + XCTAssertTrue(syncHubTransitionIsOwed( + baselineHostIdentity: "studio", + incomingHostIdentity: "macbook", + hasActiveProject: true + )) + } + + /// The exact shape of the bug: a stale baseline that already equals the + /// incoming machine must not be what the decision reads. + func testTransitionIsNotOwedWhenReconnectingToTheSameMachine() { + XCTAssertFalse(syncHubTransitionIsOwed( + baselineHostIdentity: "studio", + incomingHostIdentity: "studio", + hasActiveProject: true + )) + } + + func testTransitionIsNotOwedWithNoProjectOpen() { + XCTAssertFalse(syncHubTransitionIsOwed( + baselineHostIdentity: "studio", + incomingHostIdentity: "macbook", + hasActiveProject: false + )) + } + + /// A first-ever connection has no baseline; there is no project to strand. + func testTransitionIsOwedWhenThereIsNoBaselineButAProjectIsOpen() { + XCTAssertTrue(syncHubTransitionIsOwed( + baselineHostIdentity: nil, + incomingHostIdentity: "macbook", + hasActiveProject: true + )) + } + + func testTransitionIsNotOwedWithoutAnIncomingIdentity() { + XCTAssertFalse(syncHubTransitionIsOwed( + baselineHostIdentity: "studio", + incomingHostIdentity: nil, + hasActiveProject: true + )) + } + + // MARK: - syncAccountMachineNavigationIsCurrent + + /// The bug: `.syncing` is an attached state that every connect path passes + /// through, but this gate demanded `.connected` exactly. A link tapped during + /// hydration therefore decided we were on the wrong machine and re-paired to + /// the machine we were already talking to, tearing down a healthy connection. + func testSyncingCountsAsAttachedToTheTargetMachine() { + XCTAssertTrue(syncAccountMachineNavigationIsCurrent( + targetDeviceId: "host-1", + activeHostIdentity: "host-1", + connectionState: .syncing + )) + } + + func testConnectedCountsAsAttachedToTheTargetMachine() { + XCTAssertTrue(syncAccountMachineNavigationIsCurrent( + targetDeviceId: "host-1", + activeHostIdentity: "host-1", + connectionState: .connected + )) + } + + func testAttachedToADifferentMachineIsNotCurrent() { + XCTAssertFalse(syncAccountMachineNavigationIsCurrent( + targetDeviceId: "host-2", + activeHostIdentity: "host-1", + connectionState: .syncing + )) + } + + /// Still-connecting is not attached: the navigation must wait for, or force, + /// a real machine transition rather than assume one. + func testConnectingIsNotCurrent() { + XCTAssertFalse(syncAccountMachineNavigationIsCurrent( + targetDeviceId: "host-1", + activeHostIdentity: "host-1", + connectionState: .connecting + )) + } + + func testDisconnectedIsNotCurrent() { + XCTAssertFalse(syncAccountMachineNavigationIsCurrent( + targetDeviceId: "host-1", + activeHostIdentity: "host-1", + connectionState: .disconnected + )) + } + + func testMissingIdentitiesAreNotCurrent() { + XCTAssertFalse(syncAccountMachineNavigationIsCurrent( + targetDeviceId: nil, + activeHostIdentity: "host-1", + connectionState: .connected + )) + XCTAssertFalse(syncAccountMachineNavigationIsCurrent( + targetDeviceId: "host-1", + activeHostIdentity: " ", + connectionState: .connected + )) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f27815e0c..d5801c475 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1334,6 +1334,13 @@ The sync subsystem is **owned by the ADE runtime** (`apps/ade-cli/src/services/s - Device-bound direct pairing secrets survive account sign-out and are removed only by explicit machine forget or the versioned trust-reset policy; Relay authorization always requires a fresh matching account proof. +- A pairing made by hand at the machine (QR/Nearby-PIN/SSH) can be adopted into + an account when the same device presents a DPoP proof against the key already + pinned on that record plus a verified same-account attestation; a keyless + record or one owned by another account is refused. Adoption records + `localTrustOrigin`, so signing out or switching accounts demotes the record + back to purely local trust rather than deleting it. Signing in never makes a + hand-paired machine destructible. - Linear creds, GitHub tokens, provider API keys stay on the host. - Commands from non-host devices validated and executed by the host only. - The release's versioned trust reset clears only saved connection grants: diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 97db52833..a118caabb 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -960,7 +960,18 @@ Canonical files (`apps/ade-cli/src/services/sync/`): `crdt-model.md` for the underlying suppression mechanism. - `syncPairingStore.ts` — validates `pairing_request` envelopes against `syncPinStore`, mints the durable per-device secret, and - persists it into the `paired_devices` row (SQLite). + persists it into the `paired_devices` row (SQLite). Each + `SyncPairingRecord` carries its provenance: `accountOwnerUserId` (null for a + QR/Nearby-PIN/SSH pairing made at the Mac) and the sticky `localTrustOrigin` + flag set when such a record is later adopted into an account. + `pairPeerViaAccount` mints or rotates from a verified same-account + attestation and performs that adoption; `isValidDpopPublicKey` is the shared + validity test both this store and `syncHostService` use so a blank pinned key + can never be mistaken for a real one. `revokeAccountOwnedExcept` is the + sign-out / account-switch sweep: it deletes records owned by another account + but demotes `localTrustOrigin` records back to `accountOwnerUserId: null`, + and writes whenever it deleted **or** demoted. See *Adopting a manual pairing + into an account* for the gate. - `syncPinStore.ts` — on-disk storage for the user-set 6-digit pairing PIN at `~/.ade/secrets/sync-pin.json`, chmodded `0600`. The runtime never rotates the PIN; the operator sets or clears it from @@ -1507,6 +1518,55 @@ a DPoP-bound fresh token; terminal identity/proof failures close the peer, while expiry/verifier-unavailable results can retry inside the advertised grace. Older peers close exactly when their initial token expires. +### Adopting a manual pairing into an account + +A device can hold a pairing record that predates the account: `SyncPairingRecord` +with `accountOwnerUserId: null` is a QR, Nearby/PIN, or SSH pairing made by hand +at the Mac. When that same `deviceId` presents an account-authenticated hello, +the host **adopts** the record instead of leaving it local: it sets the account +owner, mints a fresh device-bound secret, and returns it in `accountPairing`. +This is what lets a signed-in device that no longer holds its manual secret +recover over the network rather than requiring a physical trip back to the Mac. + +Adoption is an authorization decision and it is gated on evidence, not on the +caller's claim: + +- The hello must carry a DPoP proof that verifies against the P-256 key already + **pinned on that record**. The pinned key is the only evidence that the + signing-in device is the same physical device that paired manually, so a + record with no valid pinned key is refused outright (`sync_host` logs + `sync_host.account_existing_keyless_rejected`). Both the host guard and + `syncPairingStore.writeNewPairingRecord` test key *validity* rather than + truthiness via `isValidDpopPublicKey`, so a blank or whitespace-only field + cannot slip past one guard and land in `evaluatePairedHelloDpop`'s + legacy TOFU branch, where the proof would be checked against a + caller-supplied key. +- The Clerk attestation must be verified and re-captured under the commit lock, + and a record already owned by a **different** account is still refused. +- Unlike first-time adoption, the pinned key and `createdAt` are preserved; the + hello's offered key is ignored. + +Adoption is **deferred** while a PIN re-pair is staged on the record and the +device has not acknowledged it (`pairingStore.hasPendingRotation`). Adoption +writes through rather than staging, which would discard the staged secret and +leave the device's `pairing_commit` with nothing to promote. That hello answers +`hello_ok` without `accountPairing` and logs +`sync_host.account_adoption_deferred_pending_rotation`; a device mid-re-pair +holds a working secret by definition, and every client treats an omitted +`accountPairing` as "keep the credential you already have". + +Adoption grants the account a way to *use* a pairing; it does not rewrite who +created it. `SyncPairingRecord.localTrustOrigin` records that the underlying +trust started as the user's own physical act at the Mac, and is sticky once set. +The sign-out / account-switch sweep (`revokeAccountOwnedExcept`) therefore does +not delete such a record — it **demotes** it back to `accountOwnerUserId: null` +so it returns to pure local trust and stays usable on LAN/Tailscale. Demotion, +not merely skipping the delete, is what keeps it usable: every reconnect path +rejects a record whose owner no longer matches the signed-in account, so a +surviving-but-stale owner is the same dead end in a different place. A later +same-account hello re-adopts the demoted record through the same gate. A +successful adoption logs `sync_host.account_legacy_pairing_upgraded`. + ## Device discovery - **Machine-to-machine**: pair or connect from **Connections > Machines** with @@ -2016,7 +2076,13 @@ feature is merged or because a deliberately isolated-port host is running. available for direct LAN/Tailscale reconnect regardless of whether it was minted after PIN pairing or sealed same-account adoption. A verified same-owner account hello with the pinned DPoP key may rotate the paired secret - so a lost credential-delivery response can be retried safely. + so a lost credential-delivery response can be retried safely, and may adopt a + still-local QR/PIN/SSH record for the same device into the account. That + adoption is authorized by the DPoP proof against the key already pinned on + that record plus a verified same-account attestation; a record with no valid + pinned key, and a record owned by a different account, are both refused. It + never converts provenance — `localTrustOrigin` keeps the record out of the + sign-out delete sweep, which demotes it back to local trust instead. The `machineKey` is an unguessable 32-hex identifier and the tunnel upgrades are HMAC-signed with a per-machine secret. Relay availability now follows the host's account session: sign-in starts and advertises it, and @@ -2099,6 +2165,7 @@ feature is merged or because a deliberately isolated-port host is running. | Relay tunnel + account-directory publisher gated on the machine sync-host lease | Implemented (`syncHostSingleton` authority registry, `relayTunnelAuthorityGate`, `runServe` publisher gate) | | Relay eviction (`4505`) suppression + surfaced outage | Implemented (bounded re-attempts, 10-minute re-arm, `routeHealth.relay.relayControlSuppressed*`, `ade doctor` relay row, desktop `relay-offline` banner) | | Sealed account adoption over direct routes (`ade-adopt-v1`, host `pubkey` identity, LAN → tailnet → Relay fallback, negotiated ChaCha20-Poly1305 / AES-256-GCM AEAD) | Implemented (`machineIdentitySigningStore` + `adoptChannelCrypto`; desktop + iOS clients) | +| Legacy manual-pairing adoption into an account (DPoP-gated) + `localTrustOrigin` demotion on sign-out | Implemented (`syncPairingStore.pairPeerViaAccount` / `revokeAccountOwnedExcept`, `syncHostService` account hello) | | 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)) | diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index e8348fe4d..8564f58bc 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -50,6 +50,21 @@ returned per-device secret and DPoP key for direct reconnects, and adds a fresh in-memory account token to every later Relay hello. The account token is never saved with the machine. +An account `hello_ok` does not reissue a credential every time, so the phone +resolves which secret it is left holding through +`syncResolveAccountHelloPairedSecret`, the iOS mirror of the web client's +`resolveAccountHelloPairing` (`apps/desktop/src/shared/accountDirectory.ts`). +A host that **omits** `accountPairing` means "keep the credential you already +have", and the phone falls back to the secret it holds for that machine +identity — found through the saved profile or, when no profile survives, +directly from Keychain under `machine:`. That fallback is what makes a +signed-in phone recoverable over the network instead of only at the Mac; the +host side of the same contract is *Adopting a manual pairing into an account* in +[the sync README](./README.md). An `accountPairing` that is **present** but +carries another device's id, a missing secret, or an explicit null is still +rejected outright, so a partial or mismatched host response can never pass as +success. + For that sealed adoption, iOS advertises the AEADs its CryptoKit runtime supports (`chacha20-poly1305` and `aes-256-gcm`). The host selects the first mutual option, echoes it in `account_challenge_ok`, and signs the selected AEAD @@ -563,6 +578,15 @@ With a saved machine the primary action is **Reconnect** (calls "Connection settings" link; unpaired phones keep the single "Connect Machine" button into Settings. +Every machine name in these lines is chosen by `syncConnectionSubjectMachineName` +rather than being read off the active profile: while connecting or unreachable +it names the machine the current attempt targets, and only while connected or +disconnected does it name the attached host. With several Macs on one account, +deriving the name from the last-connected profile blamed a machine that took no +part in the failure. Settings passes the same value through the snapshot's +`connectAttemptHostName` (populated only in the connecting/unreachable states), +which is deliberately separate from `hostDisplayName`. + The connected Hub top bar uses the same health value. `HubConnectionPill` renders the account custom name when available and, only after an authenticated connection, a compact observed-route line: `via LAN`, `via Tailscale`, or @@ -905,10 +929,21 @@ Sources: `apps/ios/ADE/Services/SyncService.swift` and later therefore reconnects without navigation or a user tap. A successful hello restores the active project, chat and terminal subscriptions, tracked lane presence, and pending safe operations without rebuilding the current - navigation stack. A user-initiated machine transition from Settings first - presents the Hub, then disconnects, reconnects, pairs, or adopts the selected - machine. The cached active project can remain available for recovery, but no - in-project screen keeps rendering state owned by the previous machine. + navigation stack. A user-initiated machine transition from Settings + disconnects, reconnects, pairs, or adopts the selected machine, and returns + the UI to the Hub when — and only when — a `hello_ok` actually lands from a + machine other than the one the active project belongs to. Merely tapping a + machine does not eject the user from their project, because the tap does not + yet know whether the new machine will answer. `prepareForUserConnectionChange` + captures the identity being left *before* `saveProfile` retargets the active + machine, and `syncHubTransitionIsOwed` compares the landing hello against that + baseline; comparing against `activeProjectHostIdentity` alone would compare + the target to itself and strand the user inside a project the new host has + never heard of. An explicit disconnect has no later hello, so it presents the + Hub immediately. The cached active project can remain available for recovery, + but no in-project screen keeps rendering state owned by the previous machine: + a machine change also resets terminal **and** chat subscription state and + history, on every switch path rather than only the account/PIN ones. Disconnects (including the connecting-state Cancel button) also cancel scheduled reconnect work and leave the phone disconnected until the user reconnects or pairs again. Ordinary transport recovery does not force Hub @@ -923,6 +958,57 @@ Sources: `apps/ios/ADE/Services/SyncService.swift` and `lanes.presence.release` when the user leaves a lane surface and re-announces on a 30 s heartbeat (runtime-side TTL is 60 s). +### Switching machines, and what a failed switch costs + +An account can hold several Macs, so "the machine we are attached to" and "the +machine this attempt is aimed at" are different facts and the service keeps them +apart. `SyncConnectAttemptTarget` names the machine a user-initiated connect +targets, is set the moment the attempt starts, survives the failure so the +failure can say which machine failed, and is cleared on success (when the state +reaches an attached state), on the next attempt, and on disconnect. Every +connection line resolves its subject through +`syncConnectionSubjectMachineName`: while `connecting` or `unreachable` the +attempt target wins, and while `connected` or `disconnected` the attached host +does. The attempt name is resolved through the account directory like any other, +so a renamed machine reads the same while you are reaching for it as it does +once you are on it. The Hub pill, the Hub "Cannot reach ⟨machine⟩" capsule, and +the Settings connection header all read from that one helper. + +Choosing a machine that does not answer must not cost the user the machine they +were already on. The old socket is still surrendered before the race — holding +it open through `beginConnectAttempt()` leaves its heartbeat loop, post-hello +restoration, and blip recovery half-alive, which is worse than an honest close — +so recovery is explicit instead: on failure the phone reconnects to the profile +it interrupted (`restorePreviousConnection`), re-points the attempt name at the +machine it is now dialling, and re-enables auto-reconnect. When there was no live +connection to restore, the saved profile is still retargeted back, or every later +automatic reconnect would aim at the machine that just proved unreachable. The +same protection covers the saved-host, account-machine, and sealed-adoption +paths. Attempting to connect to the machine already attached returns early +rather than tearing down a healthy connection to rebuild it (which, over Relay, +would also race the phone's own live tunnel for the same Durable Object); it +still applies that path's account-owner check and still learns newly advertised +routes. + +Because a failed switch ends with the phone attached again, callers must not +read success off `connectionState`: `reconnect(toSavedHost:)` returns whether +*that* host was reached, and the account path checks the attached profile's key +against the target's. Likewise, `lastError` describes the current connection, and +the restore's own `hello_ok` clears it — so `lastConnectAttemptFailure` carries +the explanation for the attempt instead, and Settings machine rows prefer it +(`settingsMachineRowErrorMessage`). A row failure is retired only when it is +disproven, i.e. when the phone attaches to that same machine +(`settingsMachineRowErrorsRetiring`); "connected to one Mac, with another Mac's +row explaining why it would not answer" is the honest steady state. A blocked +Attention/notification navigation records its reason the same way, so a tap that +cannot proceed explains itself rather than silently doing nothing. + +`SyncService.isAttached` treats `.syncing` as attached, because that is what +every connect path settles into before reaching `.connected`. The same rule +applies in `syncAccountMachineNavigationIsCurrent`, so a deeplink tapped +mid-hydration does not conclude it is on the wrong machine and re-pair to the +machine it is already talking to. + ### Route ranking, route memory, and roaming Sources: `apps/ios/ADE/Services/SyncConnectionRace.swift` (candidate ranking @@ -2121,6 +2207,9 @@ different machine's cached limits. | QR pairing payload (v3 smart URL) + camera scanner (`SettingsPairingScannerSheet`) | Implemented | | Account launch gate + account machine directory | Implemented; sign-in is the primary PIN-less path, while signed-out launches can continue with QR + PIN, Nearby + PIN, or advanced SSH pairing | | Account discovery + device-bound direct trust | Implemented; signed directory adoption, exact session-generation commit checks, direct trust retained across sign-out, fresh Relay proof per connection | +| Account `hello_ok` credential resolution (`syncResolveAccountHelloPairedSecret`) | Implemented; an omitted `accountPairing` keeps the stored secret (profile or Keychain), a present-but-mismatched one is rejected | +| Machine-switch recovery | Implemented; a failed switch restores the interrupted connection, retargets the saved profile back, and keeps the attempt's failure message (`lastConnectAttemptFailure`) after the restore clears `lastError` | +| Attempt-scoped connection copy | Implemented (`SyncConnectAttemptTarget` + `syncConnectionSubjectMachineName`); connecting/unreachable lines name the machine being dialed, not the last-connected one | | SSH one-time pairing bootstrap | Implemented; explicit host fingerprint trust, JSON-stdin device grant, optional Keychain recovery credentials | | One-time mobile machine-trust reset | Implemented; clears connection tokens/profiles after update while preserving account and stable device/DPoP identity | | Device-bound pairing (DPoP, Secure Enclave P-256) | Implemented (`DpopKeyService`; signed proof on every paired hello) | diff --git a/docs/features/web-client/README.md b/docs/features/web-client/README.md index 0dcdc6b59..20749ab18 100644 --- a/docs/features/web-client/README.md +++ b/docs/features/web-client/README.md @@ -404,7 +404,11 @@ Machine runtime and sync host: browser choice never changes the machine-wide desktop/runtime preference. - `apps/ade-cli/src/services/sync/syncPairingStore.ts` - pairing result store: per-device secret, optional DPoP public key, and explicit local/account - provenance used for owner-scoped revocation. + provenance (`accountOwnerUserId` plus the sticky `localTrustOrigin` flag) used + for owner-scoped revocation. A same-account hello backed by a DPoP proof + against the record's pinned key adopts a still-local pairing into the account; + owner-scoped revocation demotes such records back to local trust instead of + deleting them. - `apps/ade-cli/src/services/sync/syncDpop.ts` - host-side P-256 proof validation and replay guard. - `apps/ade-cli/src/services/sync/syncCloudRelayStore.ts` - stable cloud-tunnel From 1fb2eaf13d50cc88751171387037ebe46d608026 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:14:09 -0400 Subject: [PATCH 2/2] Address CodeRabbit: nil storage keys and stale domain statuses on restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real defects in the previous commit, both found on review: - `reachedTarget` compared optional storage keys directly. Two nils compare equal, so any attached machine read as "reached the target" whenever neither profile had a computable key — and the same nil-nil equality suppressed the restore that should have followed. Extracted the decision into `syncConnectReachedTarget`, which refuses to treat a key it cannot compute as proof, and pinned it with tests. - `restorePreviousConnection` left every domain `.failed` from the attempt it was recovering from. `reconnectIfPossible` only clears `.disconnected`, so a successful restore reported failures across the UI for a connection that was working. Co-Authored-By: Claude Opus 5 --- apps/ios/ADE/Services/SyncService.swift | 34 +++++++++++++++-- .../SyncAccountConnectRecoveryTests.swift | 37 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index acfa57bae..129acb593 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -2678,6 +2678,22 @@ func syncHubTransitionIsOwed( return baselineHostIdentity != incoming } +/// Whether an attempt actually landed on the machine it asked for. +/// +/// Storage keys are optional and two nils compare equal, so a naive +/// `attached == target` reads "reached the target" for ANY attached machine +/// whenever neither profile has a computable key — and, used in reverse, +/// suppresses the restore that should follow the failure. A key we cannot +/// compute is not proof of anything. +func syncConnectReachedTarget( + isAttached: Bool, + attachedStorageKey: String?, + targetStorageKey: String? +) -> Bool { + guard isAttached, let targetStorageKey else { return false } + return attachedStorageKey == targetStorageKey +} + /// Which paired secret an account `hello_ok` leaves us holding. /// /// Mirrors the web client's `resolveAccountHelloPairing` @@ -5825,11 +5841,19 @@ final class SyncService: ObservableObject { // Attached AND attached to the machine that was asked for. Testing the // state alone would report success for a restore of the PREVIOUS machine, // which is exactly what the doc comment above warns callers against. + // `profileStorageKey` is optional, and two nils compare equal — which would + // read "we reached the target" for any attached machine whenever neither + // profile has a usable key, and would equally suppress the restore below. + // A key we cannot compute is not proof of anything. let targetKey = profileStorageKey(profile) - let reachedTarget = isAttached && activeHostProfile.flatMap(profileStorageKey) == targetKey - if reachedTarget { return true } + if syncConnectReachedTarget( + isAttached: isAttached, + attachedStorageKey: activeHostProfile.flatMap(profileStorageKey), + targetStorageKey: targetKey + ) { return true } lastConnectAttemptFailure = SyncConnectAttemptFailure(message: lastError ?? "ADE could not reconnect to \(host.hostName).") - if let previousProfile, profileStorageKey(previousProfile) != targetKey { + if let previousProfile, + targetKey == nil || profileStorageKey(previousProfile) != targetKey { if wasAttached { await restorePreviousConnection(previousProfile) } else if tokenForProfile(previousProfile) != nil { @@ -7009,6 +7033,10 @@ final class SyncService: ObservableObject { ) allowAutoReconnect = true setAutoReconnectPausedByUser(false) + // The failed attempt marked every domain `.failed`, and `reconnectIfPossible` + // only clears `.disconnected`. Without this a successful restore leaves the + // whole UI reporting failures for a connection that is working. + setDomainStatus(SyncDomain.allCases, phase: .syncingInitialData) await reconnectIfPossible(userInitiated: true) } diff --git a/apps/ios/ADETests/SyncAccountConnectRecoveryTests.swift b/apps/ios/ADETests/SyncAccountConnectRecoveryTests.swift index 180ae1b5a..40949593c 100644 --- a/apps/ios/ADETests/SyncAccountConnectRecoveryTests.swift +++ b/apps/ios/ADETests/SyncAccountConnectRecoveryTests.swift @@ -102,6 +102,43 @@ final class SyncAccountConnectRecoveryTests: XCTestCase { XCTAssertEqual(secret, "fresh-secret") } + // MARK: - syncConnectReachedTarget + + func testReachedTargetWhenAttachedToTheRequestedMachine() { + XCTAssertTrue(syncConnectReachedTarget( + isAttached: true, + attachedStorageKey: "machine:host-1", + targetStorageKey: "machine:host-1" + )) + } + + func testDidNotReachTargetWhenAttachedElsewhere() { + XCTAssertFalse(syncConnectReachedTarget( + isAttached: true, + attachedStorageKey: "machine:host-2", + targetStorageKey: "machine:host-1" + )) + } + + /// The regression: storage keys are optional and two nils compare equal, so + /// a direct comparison claimed success for whatever machine happened to be + /// attached — and suppressed the restore that should have followed. + func testUncomputableKeysAreNotProofOfReachingTheTarget() { + XCTAssertFalse(syncConnectReachedTarget( + isAttached: true, + attachedStorageKey: nil, + targetStorageKey: nil + )) + } + + func testNotAttachedIsNeverReachingTheTarget() { + XCTAssertFalse(syncConnectReachedTarget( + isAttached: false, + attachedStorageKey: "machine:host-1", + targetStorageKey: "machine:host-1" + )) + } + // MARK: - syncHubTransitionIsOwed /// The regression: every switch path calls `saveProfile(target)` before the