mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
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:
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user