mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 20:52:51 +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:
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user