diff --git a/docs/relay-endpoint-security.md b/docs/relay-endpoint-security.md index 6eadfc0090..0fd6a1899f 100644 --- a/docs/relay-endpoint-security.md +++ b/docs/relay-endpoint-security.md @@ -57,6 +57,8 @@ The daemon advertises `server_info.features.relayProtectedSecurity: true` on eve The app pins `minimumSecurityMode: "protected"` on every relay connection to a daemon public key after the first protected handshake with that key, or when the pairing offer carries `minimumSecurityMode`. A pinned client passes `minimumMode: "protected"` to the handshake and refuses a legacy ready before sending credentials. The pin belongs to the daemon identity, not to a relay address: re-importing the same key through any relay keeps it, a different key is a different connection, and connection failures never clear it. +The handshake result is read in one place, `HostRuntimeController` when the host's active client reports connected. Every client reaches that point, whether the controller created it or adopted it from a pairing or startup probe, so no client constructor carries a persistence callback. A probe that negotiated protected but was never adopted pins nothing; the pin lands when the host first goes online. + Within one client process the floor also rises on its own. `DaemonClient` and the Node host connector remember a protected result and require it on every later attempt, so a reconnect after a relay drop cannot be answered in legacy mode even before the pin is persisted. The daemon refuses a legacy client with close `1008` and reason `protected endpoint security required`, and logs `relay_e2ee_handshake_rejected`. That close code stops at the relay: both relays replace a daemon-side close with their own (the wrangler relay sends `1012 Server disconnected`), so an old client sees an ordinary disconnect and keeps retrying. The only client-detectable failure is the client's own `4002`, which stops reconnecting and tells the user to update. diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index d622f7ebef..959ca06c48 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -345,6 +345,11 @@ class Deferred { } } +function relayPinOf(host: HostProfile | undefined): "protected" | undefined { + const connection = host?.connections[0]; + return connection?.type === "relay" ? connection.minimumSecurityMode : undefined; +} + async function waitForHostOnline(store: HostRuntimeStore, serverId: string): Promise { if (store.getSnapshot(serverId)?.connectionStatus === "online") return; await new Promise((resolve) => { @@ -564,6 +569,7 @@ function createPairingStore( connectAttempts?: string[]; serverIdForHost?: (host: HostProfile) => string; storage?: HostRuntimeStorage; + probeSecurityFloor?: "legacy" | "protected"; } = {}, ): HostRuntimeStore { return new HostRuntimeStore({ @@ -575,8 +581,10 @@ function createPairingStore( if (input.password && host.password !== input.password) { throw new DaemonAuthenticationError("password_required"); } + const client = makeConnectedProbeClient(5); + client.relaySecurityFloor = input.probeSecurityFloor; return { - client: makeConnectedProbeClient(5) as unknown as DaemonClient, + client: client as unknown as DaemonClient, serverId: input.serverIdForHost?.(host) ?? host.serverId, hostname: input.hostname ?? "paired", }; @@ -3892,6 +3900,96 @@ describe("HostRuntimeStore", () => { store.syncHosts([]); }); + it("keeps a strict offer's security pin through both pairing paths and a reload", async () => { + const storage = createMemoryHostRuntimeStorage(); + const store = createPairingStore({ storage }); + answerHostConfirmations(store, true); + const strictOffer = (serverId: string): ConnectionOffer => ({ + ...makeOffer({ serverId, daemonPublicKeyB64: `pk_${serverId}` }), + minimumSecurityMode: "protected", + }); + + await store.importConnectionLink(encodeOfferUrl(strictOffer("srv_import")), "openProject"); + await store.beginLinkPairing().submit(encodeOfferUrl(strictOffer("srv_pair"))); + + const pinOf = (hosts: HostProfile[], serverId: string) => + relayPinOf(hosts.find((host) => host.serverId === serverId)); + expect(pinOf(store.getHosts(), "srv_import")).toBe("protected"); + expect(pinOf(store.getHosts(), "srv_pair")).toBe("protected"); + + await waitForHostOnline(store, "srv_pair"); + const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]"); + expect(pinOf(persisted, "srv_import")).toBe("protected"); + expect(pinOf(persisted, "srv_pair")).toBe("protected"); + store.syncHosts([]); + + const reloaded = createPairingStore({ storage }); + const registryLoaded = onceHostListMatches(reloaded, () => reloaded.isHostRegistryLoaded()); + reloaded.boot(); + await registryLoaded; + expect(pinOf(reloaded.getHosts(), "srv_import")).toBe("protected"); + expect(pinOf(reloaded.getHosts(), "srv_pair")).toBe("protected"); + + reloaded.syncHosts([]); + }); + + it("learns the security pin from an adopted probe client and keeps it across a reload", async () => { + const relay: HostConnection = { + id: "relay:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "pk_saved", + }; + const host = makeHost({ + serverId: "srv_saved", + connections: [relay], + preferredConnectionId: relay.id, + }); + const storage = createMemoryHostRuntimeStorage({ + "@paseo:daemon-registry": JSON.stringify([host]), + "@paseo:e2e": "1", + }); + const store = createPairingStore({ storage, probeSecurityFloor: "protected" }); + + store.boot(); + await waitForHostOnline(store, "srv_saved"); + const pinned = onceHostListMatches( + store, + () => relayPinOf(store.getHosts()[0]) === "protected", + ); + await pinned; + + const activeClient = store.getSnapshot("srv_saved")?.client as unknown as FakeDaemonClient; + expect(activeClient.relaySecurityFloor).toBe("protected"); + expect(activeClient.isDisposed()).toBe(false); + const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]"); + expect(persisted[0]?.connections[0]?.minimumSecurityMode).toBe("protected"); + store.syncHosts([]); + + const reloaded = createPairingStore({ storage, probeSecurityFloor: "legacy" }); + const registryLoaded = onceHostListMatches(reloaded, () => reloaded.isHostRegistryLoaded()); + reloaded.boot(); + await registryLoaded; + expect(relayPinOf(reloaded.getHosts()[0])).toBe("protected"); + + reloaded.syncHosts([]); + }); + + it("learns the security pin for a host paired from an old offer once it goes online", async () => { + const storage = createMemoryHostRuntimeStorage(); + const store = createPairingStore({ storage, probeSecurityFloor: "protected" }); + answerHostConfirmations(store, true); + + await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer())); + await waitForHostOnline(store, "srv_offer"); + await onceHostListMatches(store, () => relayPinOf(store.getHosts()[0]) === "protected"); + + const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]"); + expect(persisted[0]?.connections[0]?.minimumSecurityMode).toBe("protected"); + + store.syncHosts([]); + }); + it("probes a pairing link immediately and saves only after admission", async () => { const store = createPairingStore({ password: "correct-password" }); answerHostConfirmations(store, true); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 63dacb0b1e..6c0557c954 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -192,6 +192,12 @@ function hashForLog(value: string): string { return `h_${Math.abs(hash).toString(16)}`; } +/** A daemon identity that negotiated protected relay security. */ +export interface RelaySecurityPin { + serverId: string; + daemonPublicKeyB64: string; +} + export interface HostRuntimeControllerDeps { createClient: (input: { host: HostProfile; @@ -555,10 +561,6 @@ function probeIntervalForConnection( function createDefaultDeps( resolveOrigin: (serverId: string) => DaemonClient | null = () => null, - pinRelayProtectedSecurity: (input: { - serverId: string; - daemonPublicKeyB64: string; - }) => void = () => undefined, ): HostRuntimeControllerDeps { const browserHostAvailable = typeof getDesktopHost()?.browser?.executeAutomationCommand === "function"; @@ -647,13 +649,6 @@ function createDefaultDeps( ...(connection.minimumSecurityMode ? { minimumMode: connection.minimumSecurityMode } : {}), - onSecurityMode: (mode) => { - if (mode !== "protected" || connection.minimumSecurityMode === "protected") return; - pinRelayProtectedSecurity({ - serverId: host.serverId, - daemonPublicKeyB64: connection.daemonPublicKeyB64, - }); - }, }, }); }, @@ -716,6 +711,7 @@ export class HostRuntimeController { private host: HostProfile; private deps: HostRuntimeControllerDeps; private onReconcileServerId: ((oldId: string, newId: string) => void) | null; + private onRelayProtectedSecurityNegotiated: ((input: RelaySecurityPin) => void) | null; private connectionMachineState: HostRuntimeConnectionMachineState; private connectionEpoch = 0; private snapshot: HostRuntimeSnapshot; @@ -740,10 +736,12 @@ export class HostRuntimeController { host: HostProfile; deps?: HostRuntimeControllerDeps; onReconcileServerId?: (oldId: string, newId: string) => void; + onRelayProtectedSecurityNegotiated?: (input: RelaySecurityPin) => void; }) { this.host = input.host; this.deps = input.deps ?? createDefaultDeps(); this.onReconcileServerId = input.onReconcileServerId ?? null; + this.onRelayProtectedSecurityNegotiated = input.onRelayProtectedSecurityNegotiated ?? null; this.connectionMachineState = { tag: "booting", }; @@ -1468,6 +1466,7 @@ export class HostRuntimeController { void failConnection(new DaemonAuthenticationError(client.authFailureReason)); return; } + if (state.status === "connected") this.learnRelaySecurity(client, connection.id); this.applyConnectionEvent({ type: "client_state", state, lastError: client.lastError }); this.updateSnapshot({ ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), @@ -1481,6 +1480,25 @@ export class HostRuntimeController { } } + // The active client is the one place every connection path meets: clients + // created here and probe clients adopted from `connectToDaemon` both land in + // `switchToConnection`. A protected handshake observed here becomes the + // persisted pin for that daemon identity. + private learnRelaySecurity(client: DaemonClient, connectionId: string): void { + const connection = findConnectionById(this.host, connectionId); + if ( + connection?.type !== "relay" || + connection.minimumSecurityMode === "protected" || + client.relaySecurityFloor !== "protected" + ) { + return; + } + this.onRelayProtectedSecurityNegotiated?.({ + serverId: this.host.serverId, + daemonPublicKeyB64: connection.daemonPublicKeyB64, + }); + } + adoptReconciledServerId(newServerId: string): void { this.host = { ...this.host, serverId: newServerId }; this.snapshot = { ...this.snapshot, serverId: newServerId }; @@ -1619,12 +1637,7 @@ export class HostRuntimeStore { replicaRowStore?: ReplicaRowStore; revokePushNotifications?: typeof revokePushNotifications; }) { - this.deps = - input?.deps ?? - createDefaultDeps( - (serverId) => this.getClient(serverId), - (pin) => void this.pinRelayProtectedSecurity(pin), - ); + this.deps = input?.deps ?? createDefaultDeps((serverId) => this.getClient(serverId)); this.storage = input?.storage ?? AsyncStorage; this.replicaCache = new ReplicaCache(input?.replicaRowStore ?? createReplicaRowStore()); this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications; @@ -2034,39 +2047,6 @@ export class HostRuntimeStore { }); } - private async upsertRelayConnection(input: { - serverId: string; - relayEndpoint: string; - useTls?: boolean; - daemonPublicKeyB64: string; - label?: string; - password?: string; - }): Promise { - const relayEndpoint = normalizeHostPort(input.relayEndpoint); - const useTls = input.useTls ?? false; - const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim(); - if (!daemonPublicKeyB64) { - throw new Error("daemonPublicKeyB64 is required"); - } - const explicitUseTls = input.useTls !== undefined; - const profile = await this.upsertHostConnection({ - serverId: input.serverId, - label: input.label, - connection: { - id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`, - type: "relay", - relayEndpoint, - ...(explicitUseTls ? { useTls } : {}), - daemonPublicKeyB64, - }, - }); - if (input.password) { - await this.setHostPassword(profile.serverId, input.password); - return this.hosts.find((host) => host.serverId === profile.serverId) ?? profile; - } - return profile; - } - // 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, relay, or // TLS setting. The answer needs the saved hosts, so it waits for them to load. @@ -2134,14 +2114,19 @@ export class HostRuntimeStore { password?: string, ): Promise { const connection = relayConnectionFromOffer(offer); - return this.upsertRelayConnection({ + if (!connection.daemonPublicKeyB64) { + throw new Error("daemonPublicKeyB64 is required"); + } + const profile = await this.upsertHostConnection({ serverId: offer.serverId, - relayEndpoint: connection.relayEndpoint, - useTls: connection.useTls, - daemonPublicKeyB64: connection.daemonPublicKeyB64, label, - password, + connection, }); + if (password) { + await this.setHostPassword(profile.serverId, password); + return this.hosts.find((host) => host.serverId === profile.serverId) ?? profile; + } + return profile; } private async probeAndUpsertOffer( @@ -2187,10 +2172,7 @@ export class HostRuntimeStore { }); } - private async pinRelayProtectedSecurity(input: { - serverId: string; - daemonPublicKeyB64: string; - }): Promise { + private async pinRelayProtectedSecurity(input: RelaySecurityPin): Promise { const next = pinRelayProtectedSecurityInProfiles({ profiles: this.hosts, ...input }); if (next === this.hosts) return; this.setHostsAndSync(next); @@ -2406,6 +2388,7 @@ export class HostRuntimeStore { host, deps: this.deps, onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId), + onRelayProtectedSecurityNegotiated: (pin) => void this.pinRelayProtectedSecurity(pin), }); this.controllers.set(host.serverId, controller); useSessionStore.getState().initializeSession(host.serverId, null);