diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index e1f650438f..7233ff9b23 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -9,7 +9,7 @@ import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; import { isWeb } from "@/constants/platform"; -import { isHostTrustDeclinedError } from "@/runtime/host-trust"; +import { isHostTrustDeclinedError, OfferApproval } from "@/runtime/host-trust"; import { BackHeader } from "@/components/headers/back-header"; import { PairLinkModal } from "@/components/pair-link-modal"; @@ -132,7 +132,10 @@ export default function PairScanScreen() { const [permission, requestPermission] = useCameraPermissions(); const [isPairing, setIsPairing] = useState(false); const [scanError, setScanError] = useState(null); - const [passwordOfferUrl, setPasswordOfferUrl] = useState(null); + const [passwordRetry, setPasswordRetry] = useState<{ + offerUrl: string; + approval: OfferApproval; + } | null>(null); const navigateToPairedHost = useCallback( (serverId: string) => { @@ -166,14 +169,19 @@ export default function PairScanScreen() { if (!offerUrl) return; const store = getHostRuntimeStore(); - if (passwordOfferUrl) return; + if (passwordRetry) return; setIsPairing(true); setScanError(null); + const approval = new OfferApproval(); void store - .importConnectionLink(offerUrl, source === "onboarding" ? "hostRoot" : "hostSettings") + .importConnectionLink( + offerUrl, + source === "onboarding" ? "hostRoot" : "hostSettings", + approval, + ) .then((outcome) => { if (outcome.status === "connected") navigateToPairedHost(outcome.serverId); - else setPasswordOfferUrl(offerUrl); + else setPasswordRetry({ offerUrl, approval }); return outcome; }) .catch((error) => { @@ -182,11 +190,11 @@ export default function PairScanScreen() { }) .finally(() => setIsPairing(false)); }, - [isPairing, navigateToPairedHost, passwordOfferUrl, source], + [isPairing, navigateToPairedHost, passwordRetry, source], ); const handleRouterBack = useCallback(() => router.back(), [router]); - const closePasswordModal = useCallback(() => setPasswordOfferUrl(null), []); + const closePasswordModal = useCallback(() => setPasswordRetry(null), []); const savePasswordPairing = useCallback( ({ serverId }: { serverId: string }) => navigateToPairedHost(serverId), [navigateToPairedHost], @@ -258,9 +266,10 @@ export default function PairScanScreen() { )} diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index 8838f02c74..87ee7f7d13 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -6,7 +6,7 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native"; import type { HostProfile } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; -import { isHostTrustDeclinedError } from "@/runtime/host-trust"; +import { isHostTrustDeclinedError, OfferApproval } from "@/runtime/host-trust"; import { parseConnectionUri, serializeConnectionUri, @@ -313,6 +313,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod const [advancedUri, setAdvancedUri] = useState(""); const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0); const advancedTarget = useRef(new PairingTargetTracker("", true)); + const offerApproval = useRef(new OfferApproval()); const clearInput = useCallback(() => { setHost(""); @@ -323,6 +324,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod setIsAdvancedOpen(false); setAdvancedUri(""); advancedTarget.current = new PairingTargetTracker("", true); + offerApproval.current = new OfferApproval(); bumpInputResetKey(); }, []); @@ -381,6 +383,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl( relayUri, password || undefined, + offerApproval.current, ); const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId); onSaved?.({ profile, serverId, hostname, isNewHost }); diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index 154e4bb288..a694bb832c 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -10,7 +10,7 @@ import { parseRelayConnectionUri } from "@/utils/daemon-endpoints"; import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer"; import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection"; -import { isHostTrustDeclinedError } from "@/runtime/host-trust"; +import { isHostTrustDeclinedError, OfferApproval } from "@/runtime/host-trust"; import { PairingTargetTracker } from "./pair-link-credentials"; import { Button } from "@/components/ui/button"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; @@ -65,6 +65,8 @@ export interface PairLinkModalProps { visible: boolean; initialUrl?: string; initialPasswordRequired?: boolean; + /** Approval already given for `initialUrl`, so its password retry does not ask again. */ + approval?: OfferApproval; onClose: () => void; onCancel?: () => void; onSaved?: (result: { @@ -79,6 +81,7 @@ export function PairLinkModal({ visible, initialUrl, initialPasswordRequired = false, + approval, onClose, onCancel, onSaved, @@ -89,6 +92,7 @@ export function PairLinkModal({ visible={visible} initialUrl={initialUrl} initialPasswordRequired={initialPasswordRequired} + approval={approval} onClose={onClose} onCancel={onCancel} onSaved={onSaved} @@ -100,6 +104,7 @@ function PairLinkModalContent({ visible, initialUrl, initialPasswordRequired = false, + approval, onClose, onCancel, onSaved, @@ -112,6 +117,7 @@ function PairLinkModalContent({ const offerUrlRef = useRef(initialUrl ?? ""); const targetTracker = useRef(new PairingTargetTracker(initialUrl)); + const offerApproval = useRef(approval ?? new OfferApproval()); const inputRef = useRef(null); const [isSaving, setIsSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(""); @@ -166,6 +172,7 @@ function PairLinkModalContent({ const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl( raw, password || undefined, + offerApproval.current, ); const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId); onSaved?.({ profile, serverId, hostname, isNewHost }); diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 99e6fde598..205efa5537 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -34,6 +34,7 @@ import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-creden import { setHostTrustConfirmer, isHostTrustDeclinedError, + OfferApproval, type HostTrustRequest, } from "./host-trust"; @@ -3720,6 +3721,119 @@ describe("HostRuntimeStore", () => { store.syncHosts([]); }); + it("connects a saved host from a matching pairing link without asking", async () => { + const requests: HostTrustRequest[] = []; + setHostTrustConfirmer(async (request) => { + requests.push(request); + return true; + }); + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: "paired", + }), + getClientId: async () => "cid_saved_match", + }, + storage: createMemoryHostRuntimeStorage(), + }); + const offerUrl = encodeOfferUrl(makeOffer()); + + await store.probeAndUpsertConnectionFromOfferUrl(offerUrl); + expect(requests).toHaveLength(1); + expect(requests[0]?.isKnown).toBe(false); + + await store.probeAndUpsertConnectionFromOfferUrl(offerUrl); + await store.importConnectionLink(offerUrl, "openProject"); + expect(requests).toHaveLength(1); + expect(store.getHosts()).toHaveLength(1); + store.syncHosts([]); + }); + + it("asks when a pairing link changes a saved host's key or relay", async () => { + const requests: HostTrustRequest[] = []; + setHostTrustConfirmer(async (request) => { + requests.push(request); + return requests.length === 1; + }); + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: "paired", + }), + getClientId: async () => "cid_saved_change", + }, + storage: createMemoryHostRuntimeStorage(), + }); + await store.probeAndUpsertConnectionFromOfferUrl(encodeOfferUrl(makeOffer())); + const savedHost = store.getHosts()[0]; + + const changedKeyUrl = encodeOfferUrl(makeOffer({ daemonPublicKeyB64: "pk_other_key" })); + await expect(store.probeAndUpsertConnectionFromOfferUrl(changedKeyUrl)).rejects.toSatisfy( + isHostTrustDeclinedError, + ); + const changedRelayUrl = encodeOfferUrl( + makeOffer({ relay: { endpoint: "relay.example:443", useTls: false } }), + ); + await expect(store.importConnectionLink(changedRelayUrl, "openProject")).rejects.toSatisfy( + isHostTrustDeclinedError, + ); + + expect(requests.map((request) => request.isKnown)).toEqual([false, true, true]); + expect(store.getHosts()).toEqual([savedHost]); + store.syncHosts([]); + }); + + it("does not ask again for the password retry of an approved pairing link", async () => { + const requests: HostTrustRequest[] = []; + setHostTrustConfirmer(async (request) => { + requests.push(request); + return true; + }); + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => { + if (host.password !== "correct-password") + throw new DaemonAuthenticationError("password_required"); + return { + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: "paired", + }; + }, + getClientId: async () => "cid_password_retry", + }, + storage: createMemoryHostRuntimeStorage(), + }); + const offerUrl = encodeOfferUrl(makeOffer()); + const approval = new OfferApproval(); + + await expect(store.importConnectionLink(offerUrl, "hostRoot", approval)).resolves.toEqual({ + status: "password_required", + }); + expect(requests).toHaveLength(1); + + // A separate pairing flow for the same link has not been approved yet. + await expect( + store.probeAndUpsertConnectionFromOfferUrl(offerUrl, "wrong-password", new OfferApproval()), + ).rejects.toThrow(); + expect(requests).toHaveLength(2); + + await store.probeAndUpsertConnectionFromOfferUrl(offerUrl, "correct-password", approval); + expect(requests).toHaveLength(2); + expect(store.getHosts()[0]).toMatchObject({ + serverId: "srv_offer", + password: "correct-password", + }); + store.syncHosts([]); + }); + it("preserves the existing host label when re-pairing an existing relay host", async () => { const store = new HostRuntimeStore({ deps: { diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 0d4df14fa0..8cf36d6f83 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -14,6 +14,7 @@ import { normalizeStoredHostProfile, upsertHostConnectionInProfiles, registryHasConnection, + hostHasConnection, StoredHostRegistrySchema, type HostConnection, type HostProfile, @@ -29,7 +30,12 @@ import { } from "@/utils/daemon-endpoints"; import { resolveAppVersion } from "@/utils/app-version"; import { ConnectionOfferSchema, type ConnectionOffer } from "@getpaseo/protocol/connection-offer"; -import { confirmHostTrust, hostTrustRequestFromOffer, HostTrustDeclinedError } from "./host-trust"; +import { + confirmHostTrust, + hostTrustRequestFromOffer, + HostTrustDeclinedError, + type OfferApproval, +} from "./host-trust"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { isWeb } from "@/constants/platform"; import { connectToDaemon, getConnectionAuthFailureReason } from "@/utils/test-daemon-connection"; @@ -1429,6 +1435,19 @@ function parseOfferConnectionUrl(input: string): { offer: ConnectionOffer; passw return { offer: ConnectionOfferSchema.parse(payload) }; } +function relayConnectionFromOffer(offer: ConnectionOffer): HostConnection { + // COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10. + const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint); + const relayEndpoint = normalizeHostPort(offer.relay.endpoint); + return { + id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`, + type: "relay", + relayEndpoint, + useTls, + daemonPublicKeyB64: offer.daemonPublicKeyB64.trim(), + }; +} + export function hasConfiguredLocalDaemonOverride(): boolean { return readConfiguredLocalDaemonOverride() !== null; } @@ -1937,15 +1956,23 @@ export class HostRuntimeStore { }); } - // Adding or connecting a host from a pairing link (a URL, a QR code, or a - // pasted link) asks the user to confirm first. Both URL-import primitives go - // through this check, so every pairing-link path asks once. - private async requireOfferTrust(offer: ConnectionOffer): Promise { - const isKnown = this.hosts.some((host) => host.serverId === offer.serverId); - const trusted = await confirmHostTrust(hostTrustRequestFromOffer(offer, { isKnown })); - if (!trusted) { + // A pairing link (a URL, a QR code, or a pasted link) asks the user to + // confirm when it adds a new host or changes a saved host's key or relay. A + // link that matches the saved host connects without asking, and so does a + // retry that carries the approval given earlier in the same pairing flow. + // Both URL-import primitives go through this check. + private async requireOfferTrust( + offer: ConnectionOffer, + approval: OfferApproval | undefined, + ): Promise { + if (approval?.covers(offer)) return; + const savedHost = this.hosts.find((host) => host.serverId === offer.serverId); + if (savedHost && hostHasConnection(savedHost, relayConnectionFromOffer(offer))) return; + const request = hostTrustRequestFromOffer(offer, { isKnown: savedHost !== undefined }); + if (!(await confirmHostTrust(request))) { throw new HostTrustDeclinedError(offer.serverId); } + approval?.record(offer); } async upsertConnectionFromOfferUrl( @@ -1954,27 +1981,20 @@ export class HostRuntimeStore { password?: string, ): Promise { const parsed = parseOfferConnectionUrl(offerUrlOrFragment); - await this.requireOfferTrust(parsed.offer); + await this.requireOfferTrust(parsed.offer, undefined); return this.upsertConnectionFromOffer(parsed.offer, label, password ?? parsed.password); } async probeAndUpsertConnectionFromOfferUrl( offerUrlOrFragment: string, password?: string, + approval?: OfferApproval, ): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> { const parsed = parseOfferConnectionUrl(offerUrlOrFragment); const offer = parsed.offer; - await this.requireOfferTrust(offer); + await this.requireOfferTrust(offer, approval); const credential = password ?? parsed.password; - const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint); - const relayEndpoint = normalizeHostPort(offer.relay.endpoint); - const connection: HostConnection = { - id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`, - type: "relay", - relayEndpoint, - useTls, - daemonPublicKeyB64: offer.daemonPublicKeyB64, - }; + const connection = relayConnectionFromOffer(offer); const probeHost: HostProfile = { serverId: offer.serverId, ...(credential ? { password: credential } : {}), @@ -1995,13 +2015,18 @@ export class HostRuntimeStore { async importConnectionLink( url: string, target: PairingNavigationTarget, + approval?: OfferApproval, ): Promise<{ status: "connected"; serverId: string } | { status: "password_required" }> { if (target === "openProject") { const profile = await this.upsertConnectionFromOfferUrl(url); return { status: "connected", serverId: profile.serverId }; } try { - const { serverId } = await this.probeAndUpsertConnectionFromOfferUrl(url); + const { serverId } = await this.probeAndUpsertConnectionFromOfferUrl( + url, + undefined, + approval, + ); return { status: "connected", serverId }; } catch (error) { if (!getConnectionAuthFailureReason(error)) throw error; @@ -2793,6 +2818,7 @@ export interface HostMutations { probeAndUpsertConnectionFromOfferUrl: ( offerUrlOrFragment: string, password?: string, + approval?: OfferApproval, ) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>; renameHost: (serverId: string, label: string) => Promise; setHostColor: (serverId: string, color: HostColor) => Promise; @@ -2812,8 +2838,8 @@ export function useHostMutations(): HostMutations { upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label), upsertConnectionFromOfferUrl: (url, label, password) => store.upsertConnectionFromOfferUrl(url, label, password), - probeAndUpsertConnectionFromOfferUrl: (url, password) => - store.probeAndUpsertConnectionFromOfferUrl(url, password), + probeAndUpsertConnectionFromOfferUrl: (url, password, approval) => + store.probeAndUpsertConnectionFromOfferUrl(url, password, approval), renameHost: (serverId, label) => store.renameHost(serverId, label), setHostColor: (serverId, color) => store.setHostColor(serverId, color), setHostBadgeDisplay: (serverId, badgeDisplay) => diff --git a/packages/app/src/runtime/host-trust.ts b/packages/app/src/runtime/host-trust.ts index 1d42ba22ed..03221e2970 100644 --- a/packages/app/src/runtime/host-trust.ts +++ b/packages/app/src/runtime/host-trust.ts @@ -2,8 +2,9 @@ import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer"; /** * A pairing offer (from a URL, a QR code, or a pasted link) names the daemon the - * client will connect to. Before anything is added or connected, the user - * confirms the host. + * client will connect to. Before a new host is added, or a saved host's + * connection changes, the user confirms the host. A link that matches a saved + * host exactly connects without asking. * * The host runtime (the model) owns the decision to ask; the UI registers a * confirmer that renders the prompt. When no confirmer is registered the answer @@ -16,7 +17,7 @@ export interface HostTrustRequest { keyFingerprint: string; /** Relay endpoint the client would connect through. */ endpoint: string; - /** Whether a host with this server id already exists in the registry. */ + /** A host with this server id is saved, and the link changes its connection. */ isKnown: boolean; } @@ -34,6 +35,31 @@ export function isHostTrustDeclinedError(error: unknown): error is HostTrustDecl return error instanceof HostTrustDeclinedError; } +/** + * The approval given during one pairing flow (one QR scan, or one open pairing + * modal). When the approved host then asks for a password, the retry passes the + * same `OfferApproval` so the user is not asked twice. It only covers the exact + * offer that was approved, and a new flow starts with a new one. + */ +export class OfferApproval { + private approved: ConnectionOffer | null = null; + + record(offer: ConnectionOffer): void { + this.approved = offer; + } + + covers(offer: ConnectionOffer): boolean { + const approved = this.approved; + return ( + approved !== null && + approved.serverId === offer.serverId && + approved.daemonPublicKeyB64 === offer.daemonPublicKeyB64 && + approved.relay.endpoint === offer.relay.endpoint && + approved.relay.useTls === offer.relay.useTls + ); + } +} + let activeConfirmer: HostTrustConfirmer | null = null; /**