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
@@ -151,3 +151,59 @@ test("encrypted connector rejects overload synchronously and preserves the physi
).toBe(true);
expect(server.wss.clients.size).toBe(1);
});
import { openDaemonSecureChannel } from "@getpaseo/relay/e2ee";
test("encrypted connector refuses legacy endpoint security on a later attempt once protected was negotiated", async () => {
const server = await endpoint();
const keys = generateKeyPair();
const legacyReceived: unknown[] = [];
let connections = 0;
server.wss.on("connection", (socket) => {
connections += 1;
const wire: Transport = {
send: (data) => socket.send(data),
close: (code, reason) => socket.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
socket.on("message", (data, isBinary) => {
const bytes = Buffer.from(data as Buffer);
wire.onmessage?.({
data: isBinary
? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
: bytes.toString(),
isBinary,
});
});
socket.on("close", (code, reason) => wire.onclose?.(code, reason.toString()));
if (connections === 1) {
void openDaemonSecureChannel(wire, { daemonKeyPair: keys }).catch(() => {});
} else {
// The daemon behind the relay now only speaks the legacy construction.
void createDaemonChannel(wire, keys, {
onmessage: (data) => legacyReceived.push(data),
}).catch(() => {});
}
});
const port = new URL(server.url).port;
const factory = createHostTransportFactory(
`relay://127.0.0.1:${port}/srv_latch?key=${encodeURIComponent(exportPublicKey(keys.publicKey))}`,
);
const first = factory({ url: server.url });
cleanup.push(async () => first.close());
await new Promise<void>((resolve) => first.onOpen(resolve));
first.close();
const second = factory({ url: server.url });
cleanup.push(async () => second.close());
let opened = false;
second.onOpen(() => {
opened = true;
});
const closed = await new Promise<unknown>((resolve) => second.onClose(resolve));
expect(closed).toMatchObject({ code: 4002, reason: expect.stringContaining("legacy") });
expect(opened).toBe(false);
expect(legacyReceived).toEqual([]);
});
+9
View File
@@ -7,6 +7,7 @@ import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target
import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js";
import { createWebSocketTransportFactory } from "../daemon-client-websocket-transport.js";
import { createRelayE2eeTransportFactory } from "../daemon-client-relay-e2ee-transport.js";
import type { SecurityMode } from "@getpaseo/relay/e2ee";
import type {
DaemonTransport,
DaemonTransportFactory,
@@ -24,6 +25,10 @@ export function createHostTransportFactory(
budget = new HostTransportBudget(),
): DaemonTransportFactory {
const target = parseHostTarget(connection);
// Rises to "protected" once the daemon negotiates it, so a later attempt
// cannot be downgraded to legacy endpoint security.
let securityFloor: SecurityMode | undefined =
target.kind === "socket" ? target.offer?.minimumSecurityMode : undefined;
return (attemptOptions) => {
const opens = new Set<() => void>();
const closes = new Set<(event?: unknown) => void>();
@@ -120,6 +125,10 @@ export function createHostTransportFactory(
factory = createRelayE2eeTransportFactory({
baseFactory: factory,
daemonPublicKeyB64: socketTarget.offer.daemonPublicKeyB64,
...(securityFloor ? { minimumMode: securityFloor } : {}),
onSecurityMode: (mode) => {
if (mode === "protected") securityFloor = "protected";
},
logger: { warn() {} },
assertOutboundCapacity: (bytes) => assertOutboundCapacity(bytes),
});