Files
paseo/packages/app/src/runtime/host-confirmation.ts
T
Mohamed Boudra 364e6456e0 Add confirmation for new hosts (#5753)
* 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.

* Only ask for new hosts or changed connections

A pairing link now connects without the confirmation when it matches a
saved host exactly (same server id, key and relay). It still asks for a new
host, and for a saved host whose key or relay differs from what is saved,
with the "This link changes how you connect to this host" text.

After the user approves a link that then needs a password, the password
retry no longer asks again. The approval is an `OfferApproval` owned by one
pairing flow (one QR scan, or one open pair-link or add-host modal) and
only covers the exact offer that was approved.

Adds tests for a matching saved host, a changed key or relay, and the
password retry.

* Move host confirmation into the host runtime

The host runtime now owns the pending host confirmation and the decision to
ask. The sheet reads the pending confirmation and sends the answer; nothing
registers into a module variable.

A pairing flow is a LinkPairing the runtime hands out, so the password retry
for a confirmed link doesn't ask again without views creating or resetting
approval objects. Cancel is a result instead of an error. Pairing waits for
saved hosts to load before deciding whether to ask.

* Stop QR scanning after an attempt ends

The scan screen now keeps its workflow in a small model. When the user
cancels the host confirmation, closes the password prompt, or the attempt
fails, scanning stops until they tap Scan QR code again. Before, the code
still in front of the camera started a new attempt on the next frame.

A host confirmation answer is now tied to the request it answers, so a tap
on one sheet never answers a newer one. Relay hosts can no longer be added
through HostMutations without the confirmation, the add-host modal drops
the reset its per-open mount made redundant, and the fingerprint comment
says what the shortened form shows.
2026-09-30 18:11:23 +01:00

98 lines
3.3 KiB
TypeScript

import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import {
hostHasConnection,
relayConnectionFromOffer,
type HostProfile,
} from "@/types/host-connection";
/**
* The question shown before a pairing link adds a host that is not saved, or
* changes the key, relay, or TLS setting of a saved one.
*/
export interface HostConfirmationRequest {
/** Identifies this question, so an answer shown for it never answers a newer one. */
id: number;
serverId: string;
/** Grouped daemon public key the user can compare with the one their daemon shows. */
keyFingerprint: string;
relayEndpoint: string;
kind: "newHost" | "changedConnection";
}
interface PendingConfirmation {
request: HostConfirmationRequest;
resolve: (approved: boolean) => void;
}
/**
* Owns the one host confirmation the app is waiting on. The host runtime asks
* through `confirmLink`; the view reads `getPending` and replies with `answer`.
* A question waits until it is answered, whether or not a view is showing it.
*/
export class HostConfirmations {
private pending: PendingConfirmation | null = null;
private nextRequestId = 1;
private listeners = new Set<() => void>();
getPending(): HostConfirmationRequest | null {
return this.pending?.request ?? null;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
/** Answers the request with `requestId`; an answer for any other request is ignored. */
answer(requestId: number, approved: boolean): void {
const current = this.pending;
if (!current || current.request.id !== requestId) return;
this.pending = null;
this.emit();
current.resolve(approved);
}
/** Resolves true without asking when the link matches a saved host exactly. */
confirmLink(offer: ConnectionOffer, savedHosts: HostProfile[]): Promise<boolean> {
const connection = relayConnectionFromOffer(offer);
const savedHost = savedHosts.find((host) => host.serverId === offer.serverId);
if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true);
return this.ask({
id: this.nextRequestId++,
serverId: offer.serverId,
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
relayEndpoint: connection.relayEndpoint,
kind: savedHost ? "changedConnection" : "newHost",
});
}
private ask(request: HostConfirmationRequest): Promise<boolean> {
// One question at a time: a newer one cancels the unanswered one.
this.pending?.resolve(false);
return new Promise<boolean>((resolve) => {
this.pending = { request, resolve };
this.emit();
});
}
private emit(): void {
for (const listener of this.listeners) {
listener();
}
}
}
/**
* Shows the key material itself (not a re-hash) in groups of four. Keys longer
* than 16 characters show only their first and last 8, so two keys that share
* both ends show the same value.
*/
function formatDaemonKeyFingerprint(daemonPublicKeyB64: string): string {
const normalized = daemonPublicKeyB64.replace(/[^A-Za-z0-9]/g, "");
const group = (value: string): string => value.replace(/(.{4})/g, "$1 ").trim();
if (normalized.length <= 16) return group(normalized);
return `${group(normalized.slice(0, 8))} … ${group(normalized.slice(-8))}`;
}