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:
Mohamed Boudra
2026-10-22 19:50:06 +07:00
committed by GitHub
parent 91d749f82c
commit 793b16c0df
55 changed files with 4887 additions and 619 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();
@@ -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.