mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
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:
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -1621,6 +1621,17 @@ export const ar: TranslationResources = {
|
||||
title: "كلمة المرور لـ {{host}}",
|
||||
label: "كلمة مرور المضيف",
|
||||
},
|
||||
trust: {
|
||||
title: "الاتصال بهذا المضيف؟",
|
||||
description:
|
||||
"سيتمكن هذا المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.",
|
||||
descriptionKnown:
|
||||
"يغيّر هذا الرابط طريقة اتصالك بهذا المضيف. سيتمكن المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.",
|
||||
hostLabel: "المضيف",
|
||||
fingerprintLabel: "بصمة المفتاح",
|
||||
endpointLabel: "المُرحّل",
|
||||
connect: "اتصال",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "إضافة اتصال",
|
||||
direct: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1638,6 +1638,17 @@ export const ja: TranslationResources = {
|
||||
title: "{{host}} のパスワード",
|
||||
label: "ホストのパスワード",
|
||||
},
|
||||
trust: {
|
||||
title: "このホストに接続しますか?",
|
||||
description:
|
||||
"このホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。",
|
||||
descriptionKnown:
|
||||
"このリンクはこのホストへの接続方法を変更します。ホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。",
|
||||
hostLabel: "ホスト",
|
||||
fingerprintLabel: "鍵のフィンガープリント",
|
||||
endpointLabel: "リレー",
|
||||
connect: "接続",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "接続を追加",
|
||||
direct: {
|
||||
|
||||
@@ -1631,6 +1631,17 @@ export const ko: TranslationResources = {
|
||||
title: "{{host}}의 비밀번호",
|
||||
label: "호스트 비밀번호",
|
||||
},
|
||||
trust: {
|
||||
title: "이 호스트에 연결할까요?",
|
||||
description:
|
||||
"이 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.",
|
||||
descriptionKnown:
|
||||
"이 링크는 이 호스트에 연결하는 방식을 변경합니다. 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.",
|
||||
hostLabel: "호스트",
|
||||
fingerprintLabel: "키 지문",
|
||||
endpointLabel: "릴레이",
|
||||
connect: "연결",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "연결 추가",
|
||||
direct: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1650,6 +1650,17 @@ export const ru: TranslationResources = {
|
||||
title: "Пароль для {{host}}",
|
||||
label: "Пароль хоста",
|
||||
},
|
||||
trust: {
|
||||
title: "Подключиться к этому хосту?",
|
||||
description:
|
||||
"Этот хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.",
|
||||
descriptionKnown:
|
||||
"Эта ссылка меняет способ подключения к этому хосту. Хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.",
|
||||
hostLabel: "Хост",
|
||||
fingerprintLabel: "Отпечаток ключа",
|
||||
endpointLabel: "Реле",
|
||||
connect: "Подключить",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "Добавить подключение",
|
||||
direct: {
|
||||
|
||||
@@ -1604,6 +1604,16 @@ export const zhCN: TranslationResources = {
|
||||
title: "{{host}} 的密码",
|
||||
label: "主机密码",
|
||||
},
|
||||
trust: {
|
||||
title: "连接到此主机?",
|
||||
description: "此主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。",
|
||||
descriptionKnown:
|
||||
"此链接会改变你连接此主机的方式。该主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。",
|
||||
hostLabel: "主机",
|
||||
fingerprintLabel: "密钥指纹",
|
||||
endpointLabel: "中继",
|
||||
connect: "连接",
|
||||
},
|
||||
connectionMethods: {
|
||||
title: "添加连接",
|
||||
direct: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user