Learn the relay security pin from the active client, whichever path made it

A strict pairing offer kept its minimum only until the relay upsert rebuilt the connection without it, and a pin learned by a client was persisted only when the controller had constructed that client itself. Probe clients adopted from pairing or startup never reported it, so a saved host that negotiated protected lost the pin on restart.

The offer's parsed connection is now stored as is. The controller reads the active client's negotiated floor when it reports connected and asks the store to pin the daemon identity; client constructors no longer carry a persistence callback.
This commit is contained in:
Mohamed Boudra
2026-10-21 15:33:21 +02:00
parent 5310d97e66
commit e50482d4c2
3 changed files with 143 additions and 60 deletions
+2
View File
@@ -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.
+99 -1
View File
@@ -345,6 +345,11 @@ class Deferred<T> {
}
}
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<void> {
if (store.getSnapshot(serverId)?.connectionStatus === "online") return;
await new Promise<void>((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);
+42 -59
View File
@@ -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<HostProfile> {
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<HostProfile> {
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<void> {
private async pinRelayProtectedSecurity(input: RelaySecurityPin): Promise<void> {
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);