diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 0bf60240e0..d622f7ebef 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -56,6 +56,7 @@ class FakeDaemonClient { supportsBackgroundWorkspaces(): boolean { return false; } + relaySecurityFloor: "legacy" | "protected" | undefined = undefined; private state: ConnectionState = { status: "idle" }; private listeners = new Set<(status: ConnectionState) => void>(); private error: string | null = null; @@ -813,6 +814,8 @@ describe("HostRuntimeController", () => { }); await controller.activateConnection({ connectionId: relay.id }); + // The client negotiated protected itself; the pin it reported is already enforced. + createdClients[0]!.relaySecurityFloor = "protected"; await controller.updateHost( makeHost({ connections: [{ ...relay, minimumSecurityMode: "protected" }], @@ -825,6 +828,44 @@ describe("HostRuntimeController", () => { expect(controller.getSnapshot().client).toBe(createdClients[0]); }); + it("replaces the active legacy relay client when a strict offer pins its connection", async () => { + const relay: HostConnection = { + id: "relay:wss:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + }; + const createdClients: Array<{ client: FakeDaemonClient; connection: HostConnection }> = []; + const controller = new HostRuntimeController({ + host: makeHost({ connections: [relay], preferredConnectionId: relay.id }), + deps: { + createClient: ({ connection }) => { + const client = new FakeDaemonClient(); + createdClients.push({ client, connection }); + return client as unknown as DaemonClient; + }, + connectToDaemon: async ({ host, connection }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: connection.id, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await controller.activateConnection({ connectionId: relay.id }); + expect(createdClients[0]?.client.relaySecurityFloor).toBeUndefined(); + const pinned: HostConnection = { ...relay, minimumSecurityMode: "protected" }; + await controller.updateHost( + makeHost({ connections: [pinned], preferredConnectionId: relay.id }), + ); + + expect(createdClients.map((entry) => entry.connection)).toEqual([relay, pinned]); + expect(createdClients[0]?.client.isDisposed()).toBe(true); + expect(controller.getSnapshot().client).toBe(createdClients[1]?.client); + }); + it("keeps known hosts in connecting when a created client reports idle during connect", async () => { const host = makeHost({ connections: [ diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index eb7af2053c..63dacb0b1e 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -505,14 +505,24 @@ function buildConnectionCandidates(host: HostProfile): ConnectionCandidate[] { } /** - * A security pin is learned from the client that is already connected in - * protected mode, so tightening it never requires a new client. + * A pin learned from the active client is already enforced by that client, so + * it keeps running. A pin that arrived from elsewhere (a strict offer) while + * the active client is still legacy needs a client that enforces it. */ -function connectionNeedsNewClient(previous: HostConnection, next: HostConnection): boolean { +function connectionNeedsNewClient( + previous: HostConnection, + next: HostConnection, + activeClient: DaemonClient | null, +): boolean { if (previous.type === "relay" && next.type === "relay") { - const { minimumSecurityMode: _previousPin, ...previousRest } = previous; - const { minimumSecurityMode: _nextPin, ...nextRest } = next; - return !equal(previousRest, nextRest); + const { minimumSecurityMode: previousPin, ...previousRest } = previous; + const { minimumSecurityMode: nextPin, ...nextRest } = next; + if (!equal(previousRest, nextRest)) return true; + return ( + nextPin === "protected" && + previousPin !== "protected" && + activeClient?.relaySecurityFloor !== "protected" + ); } return !equal(previous, next); } @@ -835,7 +845,12 @@ export class HostRuntimeController { activeConnectionId && previousActiveConnection && nextActiveConnection && - (connectionNeedsNewClient(previousActiveConnection, nextActiveConnection) || passwordChanged) + (connectionNeedsNewClient( + previousActiveConnection, + nextActiveConnection, + this.activeClient, + ) || + passwordChanged) ) { this.connectionLastProbedAt.delete(activeConnectionId); await this.switchToConnection({ connectionId: activeConnectionId }); diff --git a/packages/app/src/types/host-connection.test.ts b/packages/app/src/types/host-connection.test.ts index 3e33f62f1a..8d7fb87b35 100644 --- a/packages/app/src/types/host-connection.test.ts +++ b/packages/app/src/types/host-connection.test.ts @@ -361,6 +361,38 @@ describe("relay endpoint security pin", () => { expect(profiles[0].connections).toEqual([pinned]); }); + it("is persisted when a strict offer re-imports an unpinned route", () => { + const profiles = [ + { ...makeHost("srv"), connections: [unpinned], preferredConnectionId: unpinned.id }, + ]; + const next = upsertHostConnectionInProfiles({ + profiles, + serverId: "srv", + connection: pinned, + }); + expect(next).not.toBe(profiles); + expect(next[0].connections).toEqual([pinned]); + }); + + it("tightens every route to the same daemon identity when a strict offer arrives through another relay", () => { + const strictElsewhere: HostConnection = { + id: "relay:wss:other.example.com:443", + type: "relay", + relayEndpoint: "other.example.com:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + minimumSecurityMode: "protected", + }; + const profiles = upsertHostConnectionInProfiles({ + profiles: [ + { ...makeHost("srv"), connections: [unpinned], preferredConnectionId: unpinned.id }, + ], + serverId: "srv", + connection: strictElsewhere, + }); + expect(profiles[0].connections).toEqual([pinned, strictElsewhere]); + }); + it("follows the daemon identity to a relay at another address", () => { const elsewhere: HostConnection = { id: "relay:wss:other.example.com:443", diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index 8b2bcea3fe..e7449b9a67 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -185,7 +185,6 @@ function upsertHostConnectionById( connections: HostConnection[], connection: HostConnection, ): HostConnection[] { - const incoming = keepRelaySecurityPin(connections, connection); const next: HostConnection[] = []; let replaced = false; for (const existing of connections) { @@ -195,29 +194,42 @@ function upsertHostConnectionById( } if (replaced) continue; - next.push(incoming); + next.push(connection); replaced = true; } - if (!replaced) next.push(incoming); - return next; + if (!replaced) next.push(connection); + // A pin is never lost: the replaced record counts as much as the incoming one. + return shareRelaySecurityPin(next, [...connections, connection]); } /** - * The security pin belongs to the daemon identity, not to one relay address: - * a re-import of a pinned key keeps the pin, through any relay. + * The security pin belongs to the daemon identity, not to one relay address. + * Once any relay route to a key is pinned, by a strict offer or a learned + * handshake, every route to that key is pinned. */ -function keepRelaySecurityPin( +function shareRelaySecurityPin( connections: HostConnection[], - incoming: HostConnection, -): HostConnection { - if (incoming.type !== "relay" || incoming.minimumSecurityMode === "protected") return incoming; - const pinned = connections.some( - (connection) => - connection.type === "relay" && - connection.minimumSecurityMode === "protected" && - connection.daemonPublicKeyB64 === incoming.daemonPublicKeyB64, + pinSources: HostConnection[], +): HostConnection[] { + const pinnedKeys = new Set(); + for (const connection of pinSources) { + if (connection.type === "relay" && connection.minimumSecurityMode === "protected") { + pinnedKeys.add(connection.daemonPublicKeyB64); + } + } + return connections.map((connection) => + connection.type === "relay" && + connection.minimumSecurityMode !== "protected" && + pinnedKeys.has(connection.daemonPublicKeyB64) + ? { ...connection, minimumSecurityMode: "protected" as const } + : connection, ); - return pinned ? { ...incoming, minimumSecurityMode: "protected" } : incoming; +} + +function relaySecurityPinEquals(left: HostConnection, right: HostConnection): boolean { + const pinOf = (connection: HostConnection) => + connection.type === "relay" ? connection.minimumSecurityMode : undefined; + return pinOf(left) === pinOf(right); } /** @@ -274,7 +286,10 @@ function hasProfileChanged(input: { connections.length !== previous.connections.length || connections.some((candidate, index) => { const old = previous.connections[index]; - return !old || !hostConnectionEquals(candidate, old); + // Identity equality decides profile matching; the pin is policy on top of it. + return ( + !old || !hostConnectionEquals(candidate, old) || !relaySecurityPinEquals(candidate, old) + ); }) ); } diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index ca8fc2476a..3b0e0718a2 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1345,7 +1345,7 @@ export class DaemonClient { * Starts at the configured minimum and rises to "protected" once a daemon * negotiates it, so a later reconnect cannot be downgraded to legacy. */ - private relaySecurityFloor: RelaySecurityMode | undefined; + private relaySecurityFloorValue: RelaySecurityMode | undefined; private readonly logConnectionPath: "direct" | "relay"; private readonly logServerId: string | null; private readonly logClientIdHash: string; @@ -1361,7 +1361,7 @@ export class DaemonClient { constructor(private config: DaemonClientConfig) { this.logger = config.logger ?? consoleLogger; - this.relaySecurityFloor = config.e2ee?.minimumMode; + this.relaySecurityFloorValue = config.e2ee?.minimumMode; this.logConnectionPath = isRelayClientWebSocketUrl(this.config.url) ? "relay" : "direct"; let parsedUrlForLog: URL | null = null; try { @@ -1479,9 +1479,9 @@ export class DaemonClient { baseFactory: transportFactory, daemonPublicKeyB64, logger: this.logger, - ...(this.relaySecurityFloor ? { minimumMode: this.relaySecurityFloor } : {}), + ...(this.relaySecurityFloorValue ? { minimumMode: this.relaySecurityFloorValue } : {}), onSecurityMode: (mode) => { - if (mode === "protected") this.relaySecurityFloor = "protected"; + if (mode === "protected") this.relaySecurityFloorValue = "protected"; this.config.e2ee?.onSecurityMode?.(mode); }, }); @@ -1848,6 +1848,11 @@ export class DaemonClient { return this.connectionState.status === "connecting"; } + /** The relay endpoint security this client requires on its next attempt. */ + get relaySecurityFloor(): RelaySecurityMode | undefined { + return this.relaySecurityFloorValue; + } + get lastError(): string | null { return this.lastErrorValue; }