Add protected relay endpoint security with replay protection

Updated peers negotiate Noise_NK_25519_ChaChaPoly_SHA256 inside the
existing e2ee_hello / e2ee_ready exchange and send counter-nonced records
framed with authenticated chunk metadata. Old peers on either side fall
back to the legacy NaCl box channel while the daemon permits it.

Daemon flag daemon.relay.requireProtectedSecurity (default false,
reloadable) refuses legacy relay clients at hello time; pairing offers
and relay URIs then carry minimumSecurityMode=protected. The daemon
advertises server_info.features.relayProtectedSecurity, and a relay
client that negotiated legacy with such a daemon closes 4002 before
admission. The app pins the protected minimum per daemon public key.

Covers getpaseo/paseo#359.
This commit is contained in:
Mohamed Boudra
2026-10-21 14:20:49 +02:00
parent da7f84879c
commit 7c04eed039
49 changed files with 3713 additions and 307 deletions
@@ -14,6 +14,9 @@ export const ConnectionOfferV2Schema = z.object({
endpoint: z.string().min(1),
useTls: z.boolean().optional(),
}),
// COMPAT(relayProtectedSecurity): added in v0.13.0. Present when the daemon requires
// protected endpoint security; clients pin it before their first handshake.
minimumSecurityMode: z.literal("protected").optional(),
});
export type ConnectionOfferV2 = z.infer<typeof ConnectionOfferV2Schema>;
@@ -232,3 +232,40 @@ describe("relay connection URI", () => {
).toEqual(parts);
});
});
describe("relay connection URI security mode", () => {
test("round-trips a protected minimum security mode", () => {
const uri = serializeRelayConnectionUri({
offer: {
v: 2,
serverId: "srv_test",
daemonPublicKeyB64: "key",
relay: { endpoint: "relay.example.com:443", useTls: true },
minimumSecurityMode: "protected",
},
});
expect(new URL(uri).searchParams.get("security")).toBe("protected");
expect(parseRelayConnectionUri(uri).offer.minimumSecurityMode).toBe("protected");
});
test("omits the security parameter when the offer has no minimum", () => {
const uri = serializeRelayConnectionUri({
offer: {
v: 2,
serverId: "srv_test",
daemonPublicKeyB64: "key",
relay: { endpoint: "relay.example.com:443", useTls: true },
},
});
expect(new URL(uri).searchParams.has("security")).toBe(false);
expect(parseRelayConnectionUri(uri).offer).not.toHaveProperty("minimumSecurityMode");
});
test("rejects an unknown security mode instead of silently downgrading", () => {
expect(() =>
parseRelayConnectionUri("relay://relay.example.com:443/srv_test?key=k&security=legacy"),
).toThrow("unsupported security mode");
});
});
@@ -42,12 +42,17 @@ export function parseRelayConnectionUri(input: string): ParsedRelayConnectionUri
}
const endpoint = normalizeHostPort(url.host);
const password = url.searchParams.get("password") || undefined;
const security = url.searchParams.get("security");
if (security !== null && security !== "protected") {
throw new Error("Relay connection URI has an unsupported security mode");
}
return {
offer: {
v: 2,
serverId,
daemonPublicKeyB64: key,
relay: { endpoint, useTls: url.searchParams.get("ssl") === "true" },
...(security === "protected" ? { minimumSecurityMode: "protected" as const } : {}),
},
...(password ? { password } : {}),
};
@@ -59,6 +64,9 @@ export function serializeRelayConnectionUri(parts: ParsedRelayConnectionUri): st
);
url.searchParams.set("key", parts.offer.daemonPublicKeyB64);
if (parts.offer.relay.useTls) url.searchParams.set("ssl", "true");
if (parts.offer.minimumSecurityMode) {
url.searchParams.set("security", parts.offer.minimumSecurityMode);
}
if (parts.password) url.searchParams.set("password", parts.password);
return url.toString();
}
+6
View File
@@ -187,6 +187,9 @@ const MutableBrowserToolsConfigSchema = z
const MutableRelayConfigSchema = z
.object({
enabled: z.boolean(),
// COMPAT(relayProtectedSecurity): added in v0.13.0, keep optional while old daemons are supported.
// When true the daemon admits only relay sessions that negotiated protected endpoint security.
requireProtectedSecurity: z.boolean().optional(),
})
.passthrough();
@@ -3743,6 +3746,9 @@ export const ServerInfoStatusPayloadSchema = z
workspaceMarkUnread: z.boolean().optional(),
// COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X.
hubRelationship: z.boolean().optional(),
// COMPAT(relayProtectedSecurity): added in v0.13.0. A capable daemon always advertises
// it; relay clients that negotiated legacy security reject the connection on seeing it.
relayProtectedSecurity: z.boolean().optional(),
// COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15.
projectGithubClone: z.boolean().optional(),
// COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15.