mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user