Tighten every relay route to a daemon when a strict offer arrives

A protected-only pairing offer for a daemon now pins every stored relay
route to that daemon's key, including the route the offer re-imports and
routes through other relays, and the pin change counts as a persisted
profile change (connection identity equality is unchanged, so profile
matching is not affected). A re-import of a pinned route without the pin
still keeps it.

HostRuntime replaces the active relay client when a pin arrives from
outside while that client has not negotiated protected itself. A pin the
client learned on its own connection does not restart it. DaemonClient
exposes the relay security floor it enforces so the runtime can tell the
two apart.
This commit is contained in:
Mohamed Boudra
2026-10-21 14:57:06 +02:00
parent eaf38b6eb2
commit 5310d97e66
5 changed files with 136 additions and 28 deletions
@@ -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: [
+22 -7
View File
@@ -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 });
@@ -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",
+32 -17
View File
@@ -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<string>();
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)
);
})
);
}
+9 -4
View File
@@ -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;
}