Add confirmation for new hosts

Opening a pairing link (an `#offer=` URL, a scanned QR code, or a pasted
link) now asks the user to confirm before the host is added or connected.
The prompt shows the host id, its key fingerprint, and the relay. Cancel
leaves no new host and no connection.

The host runtime owns the check: `upsertConnectionFromOfferUrl` and
`probeAndUpsertConnectionFromOfferUrl` ask a registered confirmer before
doing anything. `HostTrustPromptModal`, mounted at the app root, registers
the confirmer and renders the prompt with the shared confirm sheet. Without
a registered confirmer, pairing links are declined.

Adds a test that an offer link with the confirmation declined adds no host
and attempts no connection.
This commit is contained in:
Mohamed Boudra
2026-09-30 12:04:39 +02:00
parent 5599f9e567
commit fd7130ad44
17 changed files with 416 additions and 2 deletions
+4
View File
@@ -45,6 +45,8 @@ import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { HostTrustPromptModal } from "@/hosts/host-trust-prompt";
import { isHostTrustDeclinedError } from "@/runtime/host-trust";
import {
getIsElectronRuntime,
HEADER_INNER_HEIGHT,
@@ -606,6 +608,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
<HostTrustPromptModal />
<ProviderSettingsHost />
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
@@ -709,6 +712,7 @@ function OfferLinkListener() {
const result = await getHostRuntimeStore().importConnectionLink(url, "openProject");
if (!cancelled && result.status === "connected") router.replace(buildOpenProjectRoute());
} catch (error) {
if (isHostTrustDeclinedError(error)) return;
console.warn("[OfferLinkListener] Pairing link failed", error);
}
};
+5 -1
View File
@@ -9,6 +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 { BackHeader } from "@/components/headers/back-header";
import { PairLinkModal } from "@/components/pair-link-modal";
@@ -175,7 +176,10 @@ export default function PairScanScreen() {
else setPasswordOfferUrl(offerUrl);
return outcome;
})
.catch((error) => setScanError(error instanceof Error ? error.message : String(error)))
.catch((error) => {
if (isHostTrustDeclinedError(error)) return;
setScanError(error instanceof Error ? error.message : String(error));
})
.finally(() => setIsPairing(false));
},
[isPairing, navigateToPairedHost, passwordOfferUrl, source],
@@ -6,6 +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 {
parseConnectionUri,
serializeConnectionUri,
@@ -385,6 +386,9 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
} catch (error) {
if (isHostTrustDeclinedError(error)) {
return;
}
setErrorMessage(
error instanceof Error ? error.message : directConnectionLabels.invalidConnection,
);
@@ -10,6 +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 { PairingTargetTracker } from "./pair-link-credentials";
import { Button } from "@/components/ui/button";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
@@ -170,6 +171,9 @@ function PairLinkModalContent({
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
} catch (error) {
if (isHostTrustDeclinedError(error)) {
return;
}
const message =
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
setErrorMessage(message);
@@ -0,0 +1,150 @@
import { useCallback, useEffect, useMemo } from "react";
import { Text, View } from "react-native";
import { create } from "zustand";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
import { setHostTrustConfirmer, type HostTrustRequest } from "@/runtime/host-trust";
interface PendingTrustPrompt {
id: number;
request: HostTrustRequest;
resolve: (trusted: boolean) => void;
}
interface HostTrustPromptState {
pending: PendingTrustPrompt | null;
open: (input: Omit<PendingTrustPrompt, "id">) => void;
settle: (trusted: boolean) => void;
}
let nextPromptId = 1;
const useHostTrustPromptStore = create<HostTrustPromptState>((set, get) => ({
pending: null,
open: (input) => {
// A newer prompt supersedes an unanswered one; decline the old request so
// its caller unwinds rather than hangs.
const previous = get().pending;
if (previous) previous.resolve(false);
set({ pending: { ...input, id: nextPromptId++ } });
},
settle: (trusted) => {
const current = get().pending;
if (!current) return;
set({ pending: null });
current.resolve(trusted);
},
}));
/**
* Mount once at the app root. While mounted, pairing links ask for
* confirmation through this sheet; unmounted, the runtime declines them.
*/
export function HostTrustPromptModal() {
const { t } = useTranslation();
const pending = useHostTrustPromptStore((state) => state.pending);
const open = useHostTrustPromptStore((state) => state.open);
const settle = useHostTrustPromptStore((state) => state.settle);
useEffect(() => {
setHostTrustConfirmer(
(request) =>
new Promise<boolean>((resolve) => {
open({ request, resolve });
}),
);
return () => setHostTrustConfirmer(null);
}, [open]);
const cancel = useCallback(() => settle(false), [settle]);
const connect = useCallback(() => settle(true), [settle]);
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.trust.title") }), [t]);
if (!pending) return null;
const { request } = pending;
return (
<AdaptiveModalSheet header={header} visible onClose={cancel} testID="host-trust">
<Text style={styles.confirmText}>
{request.isKnown ? t("pairing.trust.descriptionKnown") : t("pairing.trust.description")}
</Text>
<View style={settingsStyles.card}>
<DetailRow
label={t("pairing.trust.hostLabel")}
value={request.serverId}
testID="host-trust-server-id"
/>
<DetailRow
label={t("pairing.trust.fingerprintLabel")}
value={request.keyFingerprint}
testID="host-trust-fingerprint"
showBorder
/>
<DetailRow label={t("pairing.trust.endpointLabel")} value={request.endpoint} showBorder />
</View>
<View style={styles.confirmActions}>
<Button
variant="secondary"
size="sm"
style={FLEX_1_STYLE}
onPress={cancel}
testID="host-trust-cancel"
>
{t("common.actions.cancel")}
</Button>
<Button
variant="default"
size="sm"
style={FLEX_1_STYLE}
onPress={connect}
testID="host-trust-connect"
>
{t("pairing.trust.connect")}
</Button>
</View>
</AdaptiveModalSheet>
);
}
function DetailRow({
label,
value,
showBorder = false,
testID,
}: {
label: string;
value: string;
showBorder?: boolean;
testID?: string;
}) {
return (
<View style={[settingsStyles.row, showBorder && settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{label}</Text>
<Text style={settingsStyles.rowHint} selectable testID={testID}>
{value}
</Text>
</View>
</View>
);
}
// Same shape as the remove-host and remove-connection confirm sheets in
// screens/settings/host-page.tsx.
const styles = StyleSheet.create((theme) => ({
confirmText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
confirmActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[4],
},
}));
const FLEX_1_STYLE = { flex: 1 };
+11
View File
@@ -1621,6 +1621,17 @@ export const ar: TranslationResources = {
title: "كلمة المرور لـ {{host}}",
label: "كلمة مرور المضيف",
},
trust: {
title: "الاتصال بهذا المضيف؟",
description:
"سيتمكن هذا المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.",
descriptionKnown:
"يغيّر هذا الرابط طريقة اتصالك بهذا المضيف. سيتمكن المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.",
hostLabel: "المضيف",
fingerprintLabel: "بصمة المفتاح",
endpointLabel: "المُرحّل",
connect: "اتصال",
},
connectionMethods: {
title: "إضافة اتصال",
direct: {
+11
View File
@@ -1646,6 +1646,17 @@ export const en = {
title: "Password for {{host}}",
label: "Host password",
},
trust: {
title: "Connect to this host?",
description:
"This host will be able to run code in this app and reach your other connected hosts. Only connect if you recognize it.",
descriptionKnown:
"This link changes how you connect to this host. The host will be able to run code in this app and reach your other connected hosts. Only connect if you recognize it.",
hostLabel: "Host",
fingerprintLabel: "Key fingerprint",
endpointLabel: "Relay",
connect: "Connect",
},
connectionMethods: {
title: "Add connection",
direct: {
+11
View File
@@ -1667,6 +1667,17 @@ export const es: TranslationResources = {
title: "Contraseña de {{host}}",
label: "Contraseña del host",
},
trust: {
title: "¿Conectar con este host?",
description:
"Este host podrá ejecutar código en esta app y acceder a tus otros hosts conectados. Conéctate solo si lo reconoces.",
descriptionKnown:
"Este enlace cambia cómo te conectas a este host. El host podrá ejecutar código en esta app y acceder a tus otros hosts conectados. Conéctate solo si lo reconoces.",
hostLabel: "Host",
fingerprintLabel: "Huella de la clave",
endpointLabel: "Relay",
connect: "Conectar",
},
connectionMethods: {
title: "Agregar conexión",
direct: {
+11
View File
@@ -1671,6 +1671,17 @@ export const fr: TranslationResources = {
title: "Mot de passe pour {{host}}",
label: "Mot de passe de l’hôte",
},
trust: {
title: "Se connecter à cet hôte ?",
description:
"Cet hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.",
descriptionKnown:
"Ce lien modifie la façon dont vous vous connectez à cet hôte. L'hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.",
hostLabel: "Hôte",
fingerprintLabel: "Empreinte de la clé",
endpointLabel: "Relais",
connect: "Se connecter",
},
connectionMethods: {
title: "Ajouter une connexion",
direct: {
+11
View File
@@ -1638,6 +1638,17 @@ export const ja: TranslationResources = {
title: "{{host}} のパスワード",
label: "ホストのパスワード",
},
trust: {
title: "このホストに接続しますか?",
description:
"このホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。",
descriptionKnown:
"このリンクはこのホストへの接続方法を変更します。ホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。",
hostLabel: "ホスト",
fingerprintLabel: "鍵のフィンガープリント",
endpointLabel: "リレー",
connect: "接続",
},
connectionMethods: {
title: "接続を追加",
direct: {
+11
View File
@@ -1631,6 +1631,17 @@ export const ko: TranslationResources = {
title: "{{host}}의 비밀번호",
label: "호스트 비밀번호",
},
trust: {
title: "이 호스트에 연결할까요?",
description:
"이 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.",
descriptionKnown:
"이 링크는 이 호스트에 연결하는 방식을 변경합니다. 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.",
hostLabel: "호스트",
fingerprintLabel: "키 지문",
endpointLabel: "릴레이",
connect: "연결",
},
connectionMethods: {
title: "연결 추가",
direct: {
+11
View File
@@ -1652,6 +1652,17 @@ export const ptBR: TranslationResources = {
title: "Senha de {{host}}",
label: "Senha do host",
},
trust: {
title: "Conectar a este host?",
description:
"Este host poderá executar código neste app e acessar seus outros hosts conectados. Conecte apenas se você o reconhecer.",
descriptionKnown:
"Este link muda como você se conecta a este host. O host poderá executar código neste app e acessar seus outros hosts conectados. Conecte apenas se você o reconhecer.",
hostLabel: "Host",
fingerprintLabel: "Impressão da chave",
endpointLabel: "Relay",
connect: "Conectar",
},
connectionMethods: {
title: "Adicionar conexão",
direct: {
+11
View File
@@ -1650,6 +1650,17 @@ export const ru: TranslationResources = {
title: "Пароль для {{host}}",
label: "Пароль хоста",
},
trust: {
title: "Подключиться к этому хосту?",
description:
"Этот хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.",
descriptionKnown:
"Эта ссылка меняет способ подключения к этому хосту. Хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.",
hostLabel: "Хост",
fingerprintLabel: "Отпечаток ключа",
endpointLabel: "Реле",
connect: "Подключить",
},
connectionMethods: {
title: "Добавить подключение",
direct: {
+10
View File
@@ -1604,6 +1604,16 @@ export const zhCN: TranslationResources = {
title: "{{host}} 的密码",
label: "主机密码",
},
trust: {
title: "连接到此主机?",
description: "此主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。",
descriptionKnown:
"此链接会改变你连接此主机的方式。该主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。",
hostLabel: "主机",
fingerprintLabel: "密钥指纹",
endpointLabel: "中继",
connect: "连接",
},
connectionMethods: {
title: "添加连接",
direct: {
+55 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AppStateStatus } from "react-native";
import {
bindHostRuntimeAppState,
@@ -31,6 +31,11 @@ import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
import { subscriptionFixture } from "./subscription-fixture";
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
import {
setHostTrustConfirmer,
isHostTrustDeclinedError,
type HostTrustRequest,
} from "./host-trust";
it("requests the managed connection credential through desktop main without a web hint", async () => {
const requests: string[] = [];
@@ -271,8 +276,16 @@ class FakeDaemonClient {
}
}
// Offer imports require host-trust approval. Existing tests exercise the
// post-approval flow, so grant approval by default; the trust-gate tests below
// override this to deny.
beforeEach(() => {
setHostTrustConfirmer(async () => true);
});
afterEach(() => {
vi.useRealTimers();
setHostTrustConfirmer(null);
delete (globalThis as Record<string, unknown>).__PASEO_INITIAL_DAEMON_CONNECTION__;
delete (globalThis as { window?: unknown }).window;
});
@@ -3666,6 +3679,47 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("does not add or connect a host from an offer link when trust is declined", async () => {
let connectAttempts = 0;
const requests: HostTrustRequest[] = [];
setHostTrustConfirmer(async (request) => {
requests.push(request);
return false;
});
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => {
connectAttempts += 1;
return {
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
};
},
getClientId: async () => "cid_trust",
},
storage: createMemoryHostRuntimeStorage(),
});
const offerUrl = encodeOfferUrl(makeOffer());
// The auto-import path (deep link / QR).
await expect(store.importConnectionLink(offerUrl, "openProject")).rejects.toSatisfy(
isHostTrustDeclinedError,
);
// The paste-link / add-host path.
await expect(store.probeAndUpsertConnectionFromOfferUrl(offerUrl)).rejects.toSatisfy(
isHostTrustDeclinedError,
);
expect(store.getHosts()).toHaveLength(0);
expect(connectAttempts).toBe(0);
expect(requests).toHaveLength(2);
expect(requests[0]?.serverId).toBe("srv_offer");
expect(requests[0]?.keyFingerprint).toContain("pk");
store.syncHosts([]);
});
it("preserves the existing host label when re-pairing an existing relay host", async () => {
const store = new HostRuntimeStore({
deps: {
+14
View File
@@ -29,6 +29,7 @@ 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 { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { isWeb } from "@/constants/platform";
import { connectToDaemon, getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
@@ -1936,12 +1937,24 @@ 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<void> {
const isKnown = this.hosts.some((host) => host.serverId === offer.serverId);
const trusted = await confirmHostTrust(hostTrustRequestFromOffer(offer, { isKnown }));
if (!trusted) {
throw new HostTrustDeclinedError(offer.serverId);
}
}
async upsertConnectionFromOfferUrl(
offerUrlOrFragment: string,
label?: string,
password?: string,
): Promise<HostProfile> {
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
await this.requireOfferTrust(parsed.offer);
return this.upsertConnectionFromOffer(parsed.offer, label, password ?? parsed.password);
}
@@ -1951,6 +1964,7 @@ export class HostRuntimeStore {
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
const offer = parsed.offer;
await this.requireOfferTrust(offer);
const credential = password ?? parsed.password;
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
const relayEndpoint = normalizeHostPort(offer.relay.endpoint);
+82
View File
@@ -0,0 +1,82 @@
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.
*
* 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
* is "no", so a pairing link never connects without confirmation.
*/
export interface HostTrustRequest {
/** Stable daemon identifier carried by the offer. */
serverId: string;
/** Human-comparable fingerprint of the daemon's public key. */
keyFingerprint: string;
/** Relay endpoint the client would connect through. */
endpoint: string;
/** Whether a host with this server id already exists in the registry. */
isKnown: boolean;
}
export type HostTrustConfirmer = (request: HostTrustRequest) => Promise<boolean>;
/** Thrown when the user cancels the trust prompt. Callers treat it as a no-op. */
export class HostTrustDeclinedError extends Error {
constructor(serverId: string) {
super(`Connection to ${serverId} was not approved`);
this.name = "HostTrustDeclinedError";
}
}
export function isHostTrustDeclinedError(error: unknown): error is HostTrustDeclinedError {
return error instanceof HostTrustDeclinedError;
}
let activeConfirmer: HostTrustConfirmer | null = null;
/**
* Register the UI that answers trust prompts. Passing `null` unregisters it,
* which returns the module to declining every request.
*/
export function setHostTrustConfirmer(confirmer: HostTrustConfirmer | null): void {
activeConfirmer = confirmer;
}
/**
* Ask whether the user trusts this host. Returns `false` when no confirmer is
* registered so an offer link can never add or connect a host without consent.
*/
export async function confirmHostTrust(request: HostTrustRequest): Promise<boolean> {
if (!activeConfirmer) return false;
return activeConfirmer(request);
}
/**
* Render a daemon public key as a grouped fingerprint the user can compare
* against the key shown by their daemon. This shows the key material itself
* (not a re-hash), so different keys show different values.
*/
export function formatDaemonKeyFingerprint(daemonPublicKeyB64: string): string {
const normalized = daemonPublicKeyB64.replace(/[^A-Za-z0-9]/g, "");
if (normalized.length <= 16) {
return normalized.replace(/(.{4})/g, "$1 ").trim();
}
const head = normalized.slice(0, 8);
const tail = normalized.slice(-8);
const group = (value: string): string => value.replace(/(.{4})/g, "$1 ").trim();
return `${group(head)} … ${group(tail)}`;
}
export function hostTrustRequestFromOffer(
offer: ConnectionOffer,
options: { isKnown: boolean },
): HostTrustRequest {
return {
serverId: offer.serverId,
keyFingerprint: formatDaemonKeyFingerprint(offer.daemonPublicKeyB64),
endpoint: offer.relay.endpoint,
isKnown: options.isKnown,
};
}