mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
fix(relay): protect sessions against replayed commands (#7610)
Protect relay sessions against replayed commands with authenticated Noise NK negotiation, ordered records, and identity-bound security pins. Preserve legacy admission during migration and add daemon enforcement policy. All 21 CI checks passed. General engineering review is complete; specialist cryptographic review of the handwritten Noise implementation and static-key reuse remains required before release or deployment. Closes #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();
|
||||
|
||||
@@ -3745,6 +3748,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