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:
Mohamed Boudra
2026-10-21 14:20:49 +02:00
parent da7f84879c
commit 7c04eed039
49 changed files with 3713 additions and 307 deletions
+1
View File
@@ -43,6 +43,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
| [docs/website-i18n.md](docs/website-i18n.md) | Website languages: locale URLs, translated Markdown pairs, `{#id}` anchors, Chinese style |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) | Relay E2EE modes: Noise NK mapping, record framing, negotiation, the `requireProtectedSecurity` flag, close codes |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
+3 -3
View File
@@ -21,8 +21,8 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600`
2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key.
3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes.
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds.
3. When the phone connects via the relay, it generates fresh ephemeral keys and sends an `e2ee_hello` message. The daemon will not process any application messages until this handshake completes.
4. Updated peers run a Noise `NK` handshake (`Noise_NK_25519_ChaChaPoly_SHA256`) keyed to the daemon's static key: the `e2ee_hello` carries the first Noise message and the daemon's `e2ee_ready` carries the second. Every later frame is a counter-nonced ChaCha20-Poly1305 record. When either side predates this, the session falls back to the legacy construction: a Curve25519 ECDH shared key with XSalsa20-Poly1305 (NaCl `box`) and a random `[24-byte nonce][ciphertext]` bundle. Both modes negotiate `binaryCiphertext`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) has the exact mapping.
The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations.
@@ -36,7 +36,7 @@ The daemon requires a valid cryptographic handshake before processing any comman
- **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon.
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
- **Replay old messages** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. In protected mode every record carries a strictly increasing counter, so a duplicated, reordered, or reflected record closes the session. Legacy mode uses random nonces and has no in-session replay protection; a daemon that still admits legacy clients can also be replayed a recorded legacy transcript. Set `daemon.relay.requireProtectedSecurity: true` to refuse legacy clients once every client you use has updated.
### Trust model
+2 -2
View File
@@ -175,9 +175,9 @@ Communicates with the daemon via the same WebSocket protocol as the app.
Enables remote access when the daemon is behind a firewall.
- Curve25519 establishes the relay-session secret; NaCl `box` protects each payload with XSalsa20-Poly1305
- Updated peers negotiate a Noise `NK` session with per-record counters; peers on either side that predate it fall back to the legacy NaCl `box` construction. [docs/relay-endpoint-security.md](relay-endpoint-security.md) owns the mapping, negotiation, and the `daemon.relay.requireProtectedSecurity` flag
- The relay is zero-knowledge — it routes encrypted bytes and cannot read content
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
- Client and daemon channels with identical API (`openClientSecureChannel`, `openDaemonSecureChannel`)
- Pairing via QR code transfers the daemon's public key to the client
- New homes keep relay disabled until pairing consent. `DaemonConfigStore` persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot.
- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only
+81
View File
@@ -0,0 +1,81 @@
# Relay endpoint security
The relay carries daemon sessions between a client and a daemon that cannot reach each other directly. The relay is untrusted, so the two endpoints secure the session themselves. This doc owns the protected construction, its mapping onto Paseo's handshake and record flow, the negotiation rules, and the enforcement flag. The threat model lives in [SECURITY.md](../SECURITY.md); the code is `packages/relay/src/secure-channel.ts`.
Fixes [#359](https://github.com/getpaseo/paseo/issues/359): the legacy construction has no replay protection. A recorded legacy hello plus records replays onto a fresh daemon channel, and duplicated records are delivered twice.
## Two modes
| Mode | Construction | Replay protection |
| ----------- | ------------------------------------------------ | -------------------------------- |
| `legacy` | X25519 + NaCl `box`, random nonces | none |
| `protected` | `Noise_NK_25519_ChaChaPoly_SHA256` (revision 34) | counters, fresh daemon ephemeral |
Two updated peers always negotiate `protected`. An old peer on either side falls to `legacy` while the daemon permits it. Everything in `packages/relay/src/encrypted-channel.ts` is the legacy construction and is unchanged.
The shared entry points are `openClientSecureChannel` and `openDaemonSecureChannel`. Callers get a `SecureChannel` and ask it `securityMode()`; nothing outside the module branches on the construction.
## Noise mapping
- **Pattern:** NK. The daemon's static key is the existing `$PASEO_HOME/daemon-keypair.json` X25519 key that pairing already distributes. There is no second daemon identity.
- **Prologue:** the ASCII bytes `paseo-relay-e2ee/1`.
- **Primitives:** `@noble/curves` x25519, `@noble/ciphers` ChaCha20-Poly1305, `@noble/hashes` SHA-256 and HMAC. The state machine in `packages/relay/src/noise/` is our own implementation of the spec and is checked against vendored third-party vectors in `noise/fixtures/`.
- **Message 1** (`-> e, es`) travels inside the legacy hello: `{ type: "e2ee_hello", key, capabilities, protected: { v: 1, message: <base64> } }`. `key` is a separate legacy ephemeral so an old daemon still completes a legacy handshake. The Noise payload is the offered parameters.
- **Message 2** (`<- e, ee`) travels inside the ready: `{ type: "e2ee_ready", protected: { v: 1, message: <base64> } }`. Its payload is the selected parameters. The outer `capabilities` field is ignored in protected mode because it is not authenticated.
- **Parameters payload:** two bytes, `[0x01 version][flags]`. Flag bit 0 is `binaryCiphertext`. Unknown versions, reserved flag bits, and a selection the client did not offer fail the handshake.
- **Split:** the initiator sends with the first cipher state and receives with the second. The daemon is the mirror. Nonces are 64-bit little-endian in a 12-byte nonce, reserved synchronously before any await, never wrapped. A receiver advances its counter only after the tag verifies.
Nothing else is bound into the handshake. The outer JSON, the relay URL, and session credentials are not authenticated by Noise. Credentials travel in the application `hello` after message 2 verified the daemon.
## Records
Each application frame becomes one record of one or more Noise transport messages:
```
chunk plaintext = kind(1) || flags(1) || totalLength(u32 BE) || data
wire record = (u16 BE ciphertextLength || ciphertext)+
```
`kind` is `0x00` text or `0x01` binary. `flags` bit 0 marks the first chunk and bit 1 the last. `data` is at most 65513 bytes per chunk, so a chunk never exceeds the Noise message limit. The chunk count is `max(1, ceil(totalLength / 65513))`.
The receiver rejects a record whose metadata disagrees between chunks, whose non-final chunks are not full, whose byte total differs from `totalLength`, or that has zero chunks, trailing bytes, reserved flags, or an invalid kind. Partial plaintext never reaches the application. Transport AD is empty, as the Noise spec writes it; framing authenticity comes from the metadata being inside the ciphertext. `packages/relay/src/protected-records.ts` owns this.
Frame kind is preserved on the wire the same way as legacy: text records are base64 WebSocket text, binary records are WebSocket binary when `binaryCiphertext` was negotiated.
## Retries and failures
- The client resends its hello every second until a ready arrives. The daemon caches the exact hello text and ready text for the channel. An identical hello gets the identical ready and counters keep running. A different hello closes the channel (`1008`, transcript mismatch). A ready after the channel is open is ignored.
- A fresh daemon channel always generates a fresh `e`, so a recorded hello and its records fail authentication when replayed later, including after a daemon restart.
- A present but invalid `protected` field fails the handshake. There is no fallback to legacy once protected was attempted.
- Any record failure (bad tag, duplicate, reordered, reflected, malformed) closes the channel with `1011` and nothing from that record is delivered.
- Keys are cleared on close as a best effort.
## Negotiation and admission
The daemon advertises `server_info.features.relayProtectedSecurity: true` on every session, whatever mode that session negotiated. A relay client that ended up in `legacy` with such a daemon closes with `4002` before the client marks itself connected, restores subscriptions, or flushes queued commands. The check lives in the relay E2EE transport, so it also covers the Node connector; direct connections never see it.
The app pins `minimumSecurityMode: "protected"` on the relay connection record after the first protected handshake with that daemon public key, or when the pairing offer carries `minimumSecurityMode`. A pinned client passes `minimumMode: "protected"` to the handshake and refuses a legacy ready before sending credentials. The pin is keyed to the daemon identity: re-importing the same key keeps it, a different key is a different connection, and connection failures never clear it.
The daemon refuses a legacy client with close `1008` and reason `protected endpoint security required`, and logs `relay_e2ee_handshake_rejected`. That close code stops at the relay: both relays replace a daemon-side close with their own (the wrangler relay sends `1012 Server disconnected`), so an old client sees an ordinary disconnect and keeps retrying. The only client-detectable failure is the client's own `4002`, which stops reconnecting and tells the user to update.
## Enforcement flag
`daemon.relay.requireProtectedSecurity` in `config.json` (also `relay.requireProtectedSecurity` through the config RPC) makes the daemon refuse legacy relay clients. Default `false`. It is a local daemon setting with no relay-server counterpart.
- It is read when each hello arrives, so toggling it affects the next admission without a restart. Sessions already admitted in legacy mode stay up until they disconnect; turning the flag on does not close them.
- While it is on, pairing offers and `relay://` URIs carry `minimumSecurityMode=protected` so new clients pin before their first handshake.
- Direct and local connections are outside the flag.
Flipping the default is a separate decision, tentatively reviewed about 30 days after the compatibility release. Nothing in the code expires on a date.
## Known limitation
While a daemon accepts legacy clients, a transcript recorded from any legacy session (hello, credentials, commands) can still be replayed to it. Pinning on the client does not change that, because the attacker replays the old client's transcript, not the pinned client's. The only remedy is enabling the flag on that daemon.
## Testing
- `packages/relay/src/noise/noise.test.ts`: vendored vectors, replay and out-of-order rejection at the cipher-state level.
- `packages/relay/src/protected-records.test.ts`: framing round trips and every malformed-record rule.
- `packages/relay/src/secure-channel.test.ts`: both modes, mixed versions, policy rejection, retries, recorded-transcript replay, downgrade attempts.
- `packages/server/src/server/websocket/secure-relay-ingress.test.ts`: daemon admission and the flag.
- `packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts`: real daemon through the wrangler relay. Set `PASEO_RELAY_E2E_PORT` to run the same cases against an already-running relay such as the Elixir relay in Docker.
+42
View File
@@ -8805,6 +8805,45 @@
"license": "MIT",
"optional": true
},
"node_modules/@noble/ciphers": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
"integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.4.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -42184,6 +42223,9 @@
"name": "@getpaseo/relay",
"version": "0.12.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
"@noble/hashes": "^2.4.0",
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
"ws": "^8.14.2"
@@ -786,6 +786,45 @@ describe("HostRuntimeController", () => {
expect(controller.getSnapshot().client).toBe(createdClients[1]?.client);
});
it("keeps the active relay client when its security pin is learned", async () => {
const relay: HostConnection = {
id: "relay:wss:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
useTls: true,
daemonPublicKeyB64: "pk_a",
};
const createdClients: FakeDaemonClient[] = [];
const controller = new HostRuntimeController({
host: makeHost({ connections: [relay], preferredConnectionId: relay.id }),
deps: {
createClient: () => {
const client = new FakeDaemonClient();
createdClients.push(client);
return client as unknown as DaemonClient;
},
connectToDaemon: async ({ host, connection }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: connection.id,
}),
getClientId: async () => "cid_test_runtime",
},
});
await controller.activateConnection({ connectionId: relay.id });
await controller.updateHost(
makeHost({
connections: [{ ...relay, minimumSecurityMode: "protected" }],
preferredConnectionId: relay.id,
}),
);
expect(createdClients).toHaveLength(1);
expect(createdClients[0]?.isDisposed()).toBe(false);
expect(controller.getSnapshot().client).toBe(createdClients[0]);
});
it("keeps known hosts in connecting when a created client reports idle during connect", async () => {
const host = makeHost({
connections: [
+48 -2
View File
@@ -16,6 +16,7 @@ import {
connectionFromListen,
createRemoteSshHostConnection,
normalizeStoredHostProfile,
pinRelayProtectedSecurityInProfiles,
upsertHostConnectionInProfiles,
registryHasConnection,
relayConnectionFromOffer,
@@ -503,6 +504,19 @@ function buildConnectionCandidates(host: HostProfile): ConnectionCandidate[] {
}));
}
/**
* A security pin is learned from the client that is already connected in
* protected mode, so tightening it never requires a new client.
*/
function connectionNeedsNewClient(previous: HostConnection, next: HostConnection): boolean {
if (previous.type === "relay" && next.type === "relay") {
const { minimumSecurityMode: _previousPin, ...previousRest } = previous;
const { minimumSecurityMode: _nextPin, ...nextRest } = next;
return !equal(previousRest, nextRest);
}
return !equal(previous, next);
}
function findConnectionById(host: HostProfile, connectionId: string | null): HostConnection | null {
if (!connectionId) {
return null;
@@ -531,6 +545,11 @@ function probeIntervalForConnection(
function createDefaultDeps(
resolveOrigin: (serverId: string) => DaemonClient | null = () => null,
pinRelayProtectedSecurity: (input: {
serverId: string;
connectionId: string;
daemonPublicKeyB64: string;
}) => void = () => undefined,
): HostRuntimeControllerDeps {
const browserHostAvailable =
typeof getDesktopHost()?.browser?.executeAutomationCommand === "function";
@@ -616,6 +635,17 @@ function createDefaultDeps(
e2ee: {
enabled: true,
daemonPublicKeyB64: connection.daemonPublicKeyB64,
...(connection.minimumSecurityMode
? { minimumMode: connection.minimumSecurityMode }
: {}),
onSecurityMode: (mode) => {
if (mode !== "protected" || connection.minimumSecurityMode === "protected") return;
pinRelayProtectedSecurity({
serverId: host.serverId,
connectionId: connection.id,
daemonPublicKeyB64: connection.daemonPublicKeyB64,
});
},
},
});
},
@@ -807,7 +837,7 @@ export class HostRuntimeController {
activeConnectionId &&
previousActiveConnection &&
nextActiveConnection &&
(!equal(previousActiveConnection, nextActiveConnection) || passwordChanged)
(connectionNeedsNewClient(previousActiveConnection, nextActiveConnection) || passwordChanged)
) {
this.connectionLastProbedAt.delete(activeConnectionId);
await this.switchToConnection({ connectionId: activeConnectionId });
@@ -1576,7 +1606,12 @@ export class HostRuntimeStore {
replicaRowStore?: ReplicaRowStore;
revokePushNotifications?: typeof revokePushNotifications;
}) {
this.deps = input?.deps ?? createDefaultDeps((serverId) => this.getClient(serverId));
this.deps =
input?.deps ??
createDefaultDeps(
(serverId) => this.getClient(serverId),
(pin) => void this.pinRelayProtectedSecurity(pin),
);
this.storage = input?.storage ?? AsyncStorage;
this.replicaCache = new ReplicaCache(input?.replicaRowStore ?? createReplicaRowStore());
this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications;
@@ -2139,6 +2174,17 @@ export class HostRuntimeStore {
});
}
private async pinRelayProtectedSecurity(input: {
serverId: string;
connectionId: string;
daemonPublicKeyB64: string;
}): Promise<void> {
const next = pinRelayProtectedSecurityInProfiles({ profiles: this.hosts, ...input });
if (next === this.hosts) return;
this.setHostsAndSync(next);
await this.persistHosts();
}
private async updateHost(
serverId: string,
apply: (host: HostProfile) => HostProfile,
@@ -4,6 +4,8 @@ import {
createRemoteSshHostConnection,
normalizeStoredHostProfile,
orderHostsLocalFirst,
pinRelayProtectedSecurityInProfiles,
relayConnectionFromOffer,
resolveActiveHostServerId,
upsertHostConnectionInProfiles,
type HostConnection,
@@ -314,6 +316,92 @@ describe("upsertHostConnectionInProfiles", () => {
});
});
describe("relay endpoint security pin", () => {
const pinned: HostConnection = {
id: "relay:wss:relay.example.com:443",
type: "relay",
relayEndpoint: "relay.example.com:443",
useTls: true,
daemonPublicKeyB64: "pk_a",
minimumSecurityMode: "protected",
};
const unpinned: HostConnection = {
id: pinned.id,
type: "relay",
relayEndpoint: "relay.example.com:443",
useTls: true,
daemonPublicKeyB64: "pk_a",
};
it("survives storage", () => {
const profile = normalizeStoredHostProfile({ serverId: "srv", connections: [pinned] });
expect(profile?.connections[0]).toEqual(pinned);
});
it("comes from a pairing offer that requires protected security", () => {
const offer: Parameters<typeof relayConnectionFromOffer>[0] = {
v: 2,
serverId: "srv",
daemonPublicKeyB64: "pk_a",
relay: { endpoint: "relay.example.com:443", useTls: true },
minimumSecurityMode: "protected",
};
expect(relayConnectionFromOffer(offer)).toEqual(pinned);
expect(relayConnectionFromOffer({ ...offer, minimumSecurityMode: undefined })).toEqual(
unpinned,
);
});
it("is kept when the same daemon identity is paired again without it", () => {
const profiles = upsertHostConnectionInProfiles({
profiles: [{ ...makeHost("srv"), connections: [pinned], preferredConnectionId: pinned.id }],
serverId: "srv",
connection: unpinned,
});
expect(profiles[0].connections).toEqual([pinned]);
});
it("does not carry over to a different daemon identity", () => {
const rotated: HostConnection = { ...unpinned, daemonPublicKeyB64: "pk_b" };
const profiles = upsertHostConnectionInProfiles({
profiles: [{ ...makeHost("srv"), connections: [pinned], preferredConnectionId: pinned.id }],
serverId: "srv",
connection: rotated,
});
expect(profiles[0].connections).toEqual([rotated]);
});
it("is learned for the identity that negotiated protected security", () => {
const profiles = [{ ...makeHost("srv"), connections: [unpinned] }];
const next = pinRelayProtectedSecurityInProfiles({
profiles,
serverId: "srv",
connectionId: unpinned.id,
daemonPublicKeyB64: "pk_a",
now: "2026-02-01T00:00:00.000Z",
});
expect(next[0].connections).toEqual([pinned]);
expect(next[0].updatedAt).toBe("2026-02-01T00:00:00.000Z");
expect(
pinRelayProtectedSecurityInProfiles({
profiles: next,
serverId: "srv",
connectionId: unpinned.id,
daemonPublicKeyB64: "pk_a",
}),
).toBe(next);
expect(
pinRelayProtectedSecurityInProfiles({
profiles,
serverId: "srv",
connectionId: unpinned.id,
daemonPublicKeyB64: "pk_other",
}),
).toBe(profiles);
});
});
describe("resolveActiveHostServerId", () => {
it("uses the selected host when one is set", () => {
expect(
+57 -1
View File
@@ -49,6 +49,12 @@ export interface RelayHostConnection {
relayEndpoint: string;
useTls?: boolean;
daemonPublicKeyB64: string;
/**
* Pinned once this daemon identity has negotiated protected endpoint
* security, or when its pairing offer required it. Never cleared by a
* connection failure; a new daemon identity is a different connection.
*/
minimumSecurityMode?: "protected";
}
export interface RoutedHostConnection {
@@ -188,13 +194,58 @@ function upsertHostConnectionById(
}
if (replaced) continue;
next.push(connection);
next.push(keepRelaySecurityPin(existing, connection));
replaced = true;
}
if (!replaced) next.push(connection);
return next;
}
/** A re-import of the same daemon identity never loses an earlier security pin. */
function keepRelaySecurityPin(existing: HostConnection, incoming: HostConnection): HostConnection {
if (
existing.type !== "relay" ||
incoming.type !== "relay" ||
existing.minimumSecurityMode !== "protected" ||
existing.daemonPublicKeyB64 !== incoming.daemonPublicKeyB64
) {
return incoming;
}
return { ...incoming, minimumSecurityMode: "protected" };
}
/**
* Records that the relay daemon identity behind `connectionId` negotiated
* protected endpoint security. Returns the same array when nothing changes.
*/
export function pinRelayProtectedSecurityInProfiles(input: {
profiles: HostProfile[];
serverId: string;
connectionId: string;
daemonPublicKeyB64: string;
now?: string;
}): HostProfile[] {
const now = input.now ?? new Date().toISOString();
let changed = false;
const next = input.profiles.map((host) => {
if (host.serverId !== input.serverId) return host;
const connections = host.connections.map((connection) => {
if (
connection.type !== "relay" ||
connection.id !== input.connectionId ||
connection.daemonPublicKeyB64 !== input.daemonPublicKeyB64 ||
connection.minimumSecurityMode === "protected"
) {
return connection;
}
changed = true;
return { ...connection, minimumSecurityMode: "protected" as const };
});
return changed ? { ...host, connections, updatedAt: now } : host;
});
return changed ? next : input.profiles;
}
function hasProfileChanged(input: {
matchingCount: number;
previous: HostProfile;
@@ -377,6 +428,7 @@ export function relayConnectionFromOffer(offer: ConnectionOffer): RelayHostConne
relayEndpoint,
useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64.trim(),
...(offer.minimumSecurityMode ? { minimumSecurityMode: offer.minimumSecurityMode } : {}),
};
}
@@ -441,6 +493,7 @@ const StoredHostConnectionSchema = z.discriminatedUnion("type", [
relayEndpoint: z.string(),
useTls: z.boolean().optional(),
daemonPublicKeyB64: z.string(),
minimumSecurityMode: z.literal("protected").optional(),
}),
]);
const StoredHostProfileSchema = z.strictObject({
@@ -504,6 +557,9 @@ function normalizeStoredConnection(connection: StoredHostConnection): HostConnec
relayEndpoint,
...(useTls !== undefined ? { useTls } : {}),
daemonPublicKeyB64,
...(connection.minimumSecurityMode
? { minimumSecurityMode: connection.minimumSecurityMode }
: {}),
};
} catch {
return null;
@@ -202,7 +202,11 @@ export async function buildClientConfig(
useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
serverId,
}),
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
e2ee: {
enabled: true,
daemonPublicKeyB64: connection.daemonPublicKeyB64,
...(connection.minimumSecurityMode ? { minimumMode: connection.minimumSecurityMode } : {}),
},
};
}
+1
View File
@@ -92,6 +92,7 @@ export async function resolveLocalPairingOffer(options: {
relayPublicEndpoint: config.relayPublicEndpoint,
relayUseTls: config.relayUseTls,
relayPublicUseTls: config.relayPublicUseTls,
relayRequireProtectedSecurity: config.relayRequireProtectedSecurity,
appBaseUrl: config.appBaseUrl,
includeQr: true,
});
@@ -1,7 +1,9 @@
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
import {
openClientSecureChannel,
PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE,
type SecureChannel,
type SecurityMode,
type Transport as RelayTransport,
} from "@getpaseo/relay/e2ee";
import type {
@@ -20,21 +22,64 @@ type CloseHandler = (event?: unknown) => void;
type ErrorHandler = (event?: unknown) => void;
type MessageHandler = (data: unknown, isBinary: boolean) => void;
export function createRelayE2eeTransportFactory(args: {
baseFactory: DaemonTransportFactory;
export interface RelayE2eeTransportOptions {
daemonPublicKeyB64: string;
logger: TransportLogger;
assertOutboundCapacity?: (wireBytes: number) => void;
}): DaemonTransportFactory {
return ({ url }) => {
const base = args.baseFactory({ url });
return createEncryptedTransport(
base,
args.daemonPublicKeyB64,
args.logger,
args.assertOutboundCapacity,
);
};
/** Refuse to open unless the daemon negotiates at least this endpoint security mode. */
minimumMode?: SecurityMode;
/** Reports the negotiated mode once the daemon has authenticated the handshake. */
onSecurityMode?: (mode: SecurityMode) => void;
}
export function createRelayE2eeTransportFactory(
args: RelayE2eeTransportOptions & { baseFactory: DaemonTransportFactory },
): DaemonTransportFactory {
const { baseFactory, ...options } = args;
return ({ url }) => createEncryptedTransport(baseFactory({ url }), options);
}
const DAEMON_REQUIRES_PROTECTED_REASON = "daemon requires protected endpoint security";
/**
* Returns a user-facing message when this client closed the session itself
* because endpoint security could not be established, so reconnecting cannot help.
*
* The daemon's own policy close never reaches the client: both relays replace a
* daemon-side close with their own code (1012 on the wrangler relay).
*/
export function describeEndpointSecurityClose(event: unknown): string | null {
if (!event || typeof event !== "object") return null;
const code = "code" in event ? event.code : undefined;
const reason = "reason" in event && typeof event.reason === "string" ? event.reason : "";
if (code === PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE) {
return `Endpoint security handshake failed: ${reason || "protected mode unavailable"}`;
}
return null;
}
/**
* The first session message from the daemon is `server_info`. A daemon that can
* negotiate protected security advertises it there, so a legacy session with such
* a daemon was downgraded in transit and must not proceed to admission.
*/
function readServerInfoProtectedSupport(text: string): boolean | null {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return null;
}
if (!isRecord(parsed) || parsed.type !== "session" || !isRecord(parsed.message)) return null;
const message = parsed.message;
if (message.type !== "status" || !isRecord(message.payload)) return null;
if (message.payload.status !== "server_info") return null;
const features = message.payload.features;
return isRecord(features) && features.relayProtectedSecurity === true;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
/**
@@ -49,13 +94,14 @@ export function createRelayUpgradeTransportFactory(
export function createEncryptedTransport(
base: DaemonTransport,
daemonPublicKeyB64: string,
logger: TransportLogger,
assertOutboundCapacity: (wireBytes: number) => void = () => {},
options: RelayE2eeTransportOptions,
): DaemonTransport {
const { daemonPublicKeyB64, logger, minimumMode, onSecurityMode } = options;
const assertOutboundCapacity = options.assertOutboundCapacity ?? (() => {});
let channel: SecureChannel | null = null;
let opened = false;
let closed = false;
let awaitingServerInfo = true;
const openHandlers = new Set<OpenHandler>();
const closeHandlers = new Set<CloseHandler>();
@@ -89,6 +135,23 @@ export function createEncryptedTransport(
if (closed) {
return;
}
if (awaitingServerInfo && typeof data === "string") {
const daemonSupportsProtected = readServerInfoProtectedSupport(data);
if (daemonSupportsProtected !== null) {
awaitingServerInfo = false;
if (daemonSupportsProtected && channel?.securityMode() === "legacy") {
logger.warn(
{ reason: DAEMON_REQUIRES_PROTECTED_REASON },
"relay_e2ee_downgrade_rejected",
);
const code = PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE;
emitError(new Error(DAEMON_REQUIRES_PROTECTED_REASON));
channel.close(code, DAEMON_REQUIRES_PROTECTED_REASON);
emitClose({ code, reason: DAEMON_REQUIRES_PROTECTED_REASON });
return;
}
}
}
emitHandlers(messageHandlers, data, data instanceof ArrayBuffer);
};
@@ -118,8 +181,12 @@ export function createEncryptedTransport(
try {
channel = await openClientSecureChannel(relayTransport, {
daemonPublicKeyB64,
...(minimumMode ? { minimumMode } : {}),
events: {
onopen: emitOpen,
onopen: () => {
onSecurityMode?.(channel?.securityMode() ?? "legacy");
emitOpen();
},
onmessage: (data) => emitMessage(data),
onclose: (code, reason) => emitClose({ code, reason }),
onerror: (error) => emitError(error),
@@ -12,7 +12,8 @@ import {
const openClientSecureChannelMock = vi.hoisted(() => vi.fn());
vi.mock("@getpaseo/relay/e2ee", () => ({
vi.mock("@getpaseo/relay/e2ee", async (importOriginal) => ({
...(await importOriginal<typeof import("@getpaseo/relay/e2ee")>()),
openClientSecureChannel: openClientSecureChannelMock,
}));
@@ -40,8 +41,7 @@ describe("daemon-client transport helpers", () => {
onError: () => () => {},
onMessage: () => () => {},
},
"daemon-public-key",
{ warn: vi.fn() },
{ daemonPublicKeyB64: "daemon-public-key", logger: { warn: vi.fn() } },
);
expect(connection.open).not.toBeNull();
@@ -184,3 +184,127 @@ describe("daemon-client transport helpers", () => {
expect(decodeMessageData(bytes)).toBe("hello");
});
});
describe("relay E2EE transport endpoint security", () => {
interface ChannelEvents {
onopen?: () => void;
onmessage?: (data: string | ArrayBuffer) => void;
onclose?: (code: number, reason: string) => void;
onerror?: (error: Error) => void;
}
function serverInfoFrame(features: Record<string, boolean>): string {
return JSON.stringify({
type: "session",
message: {
type: "status",
payload: { status: "server_info", serverId: "srv_test", features },
},
});
}
function setup(securityMode: "legacy" | "protected", minimumMode?: "protected") {
openClientSecureChannelMock.mockReset();
let events: ChannelEvents = {};
let options: Record<string, unknown> = {};
const channel = {
securityMode: () => securityMode,
close: vi.fn(),
send: vi.fn(async () => {}),
isOpen: () => true,
outboundWireByteLength: () => 0,
onTransitionToOpen: () => () => {},
onClose: () => () => {},
};
openClientSecureChannelMock.mockImplementation(
async (
_transport: unknown,
received: { events?: ChannelEvents } & Record<string, unknown>,
) => {
events = received.events ?? {};
options = received;
return channel;
},
);
const connection: { open: (() => void) | null } = { open: null };
const modes: string[] = [];
const transport = createEncryptedTransport(
{
send: vi.fn(),
close: vi.fn(),
onOpen: (handler) => {
connection.open = handler;
return () => {};
},
onClose: () => () => {},
onError: () => () => {},
onMessage: () => () => {},
},
{
daemonPublicKeyB64: "daemon-public-key",
logger: { warn: vi.fn() },
...(minimumMode ? { minimumMode } : {}),
onSecurityMode: (mode) => modes.push(mode),
},
);
const delivered: unknown[] = [];
const closes: unknown[] = [];
const errors: unknown[] = [];
transport.onMessage((data) => delivered.push(data));
transport.onClose((event) => closes.push(event));
transport.onError((event) => errors.push(event));
return {
channel,
connection,
delivered,
closes,
errors,
modes,
events: () => events,
options: () => options,
};
}
test("rejects a legacy session once a protected-capable daemon identifies itself", async () => {
const t = setup("legacy");
t.connection.open?.();
await vi.waitFor(() => expect(t.events().onopen).toBeDefined());
t.events().onopen?.();
expect(t.modes).toEqual(["legacy"]);
t.events().onmessage?.(serverInfoFrame({ relayProtectedSecurity: true }));
expect(t.delivered).toEqual([]);
expect(t.channel.close).toHaveBeenCalledWith(4002, expect.stringContaining("protected"));
expect(t.closes).toEqual([{ code: 4002, reason: expect.stringContaining("protected") }]);
expect(t.errors).toHaveLength(1);
});
test("delivers server_info from a daemon that only speaks legacy security", async () => {
const t = setup("legacy");
t.connection.open?.();
await vi.waitFor(() => expect(t.events().onopen).toBeDefined());
t.events().onopen?.();
const frame = serverInfoFrame({ connectedHosts: true });
t.events().onmessage?.(frame);
t.events().onmessage?.(JSON.stringify({ type: "pong" }));
expect(t.delivered).toEqual([frame, JSON.stringify({ type: "pong" })]);
expect(t.channel.close).not.toHaveBeenCalled();
});
test("passes the minimum mode to the handshake and reports a protected result", async () => {
const t = setup("protected", "protected");
t.connection.open?.();
await vi.waitFor(() => expect(t.events().onopen).toBeDefined());
t.events().onopen?.();
t.events().onmessage?.(serverInfoFrame({ relayProtectedSecurity: true }));
expect(t.options().minimumMode).toBe("protected");
expect(t.modes).toEqual(["protected"]);
expect(t.delivered).toHaveLength(1);
expect(t.channel.close).not.toHaveBeenCalled();
});
});
@@ -18,6 +18,7 @@ export {
createEncryptedTransport,
createRelayE2eeTransportFactory,
createRelayUpgradeTransportFactory,
describeEndpointSecurityClose,
} from "./daemon-client-relay-e2ee-transport.js";
export {
bindWsHandler,
+34
View File
@@ -4310,6 +4310,40 @@ test("reconnects after relay close with replaced-by-new-connection reason", asyn
}
});
test("stops reconnecting after the transport closes for an endpoint security failure", async () => {
const close = { code: 4002, reason: "legacy security below required minimum" };
const error = "Endpoint security handshake failed: legacy security below required minimum";
useHeartbeatClock();
try {
let attempts = 0;
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
clientId: "clsk_security_close_test",
e2ee: { enabled: true, daemonPublicKeyB64: "daemon-public-key" },
reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 },
transportFactory: () => {
attempts += 1;
return mock.transport;
},
});
clients.push(client);
const connecting = client.connect();
void connecting.catch(() => {});
await vi.waitFor(() => expect(attempts).toBe(1));
mock.triggerClose(close);
await expect(connecting).rejects.toThrow(error);
await vi.advanceTimersByTimeAsync(50);
expect(attempts).toBe(1);
expect(client.getConnectionState().status).toBe("disconnected");
expect(client.lastError).toContain(error);
} finally {
vi.useRealTimers();
}
});
test("requires non-empty clientId", () => {
expect(() => {
const _client = new DaemonClient({
+16 -1
View File
@@ -166,6 +166,7 @@ import {
createWebSocketTransportFactory,
decodeMessageData,
defaultWebSocketFactory,
describeEndpointSecurityClose,
describeTransportClose,
describeTransportError,
type DaemonTransport,
@@ -394,6 +395,9 @@ export type DaemonEventHandler = (event: DaemonEvent) => void;
export type BrowserAutomationExecuteRequestMessage = BrowserAutomationExecuteRequest;
export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteResponse;
/** Endpoint security negotiated on a relay connection. See docs/relay-endpoint-security.md. */
export type RelaySecurityMode = "legacy" | "protected";
export interface DaemonClientConfig {
/** Deliver compact bodies/hash references to a caller-owned snapshot cache.
* The default keeps public SDK snapshot entries expanded. */
@@ -415,6 +419,10 @@ export interface DaemonClientConfig {
e2ee?: {
enabled?: boolean;
daemonPublicKeyB64?: string;
/** Refuse connections whose negotiated endpoint security is below this mode. */
minimumMode?: RelaySecurityMode;
/** Called with the negotiated mode after each authenticated relay handshake. */
onSecurityMode?: (mode: RelaySecurityMode) => void;
};
reconnect?: {
enabled?: boolean;
@@ -1464,6 +1472,10 @@ export class DaemonClient {
baseFactory: transportFactory,
daemonPublicKeyB64,
logger: this.logger,
...(this.config.e2ee.minimumMode ? { minimumMode: this.config.e2ee.minimumMode } : {}),
...(this.config.e2ee.onSecurityMode
? { onSecurityMode: this.config.e2ee.onSecurityMode }
: {}),
});
}
const transportUrl = this.resolveTransportUrlForAttempt();
@@ -1521,9 +1533,12 @@ export class DaemonClient {
this.pendingGenericTransportErrorTimeout = null;
}
this.authFailureReasonValue ??= authFailureFromLegacyClose(event);
// An endpoint security policy close cannot be fixed by retrying.
const securityFailure = describeEndpointSecurityClose(event);
if (securityFailure) this.setReconnectEnabled(false);
const reason = this.authFailureReasonValue
? new DaemonAuthenticationError(this.authFailureReasonValue).message
: describeTransportClose(event);
: (securityFailure ?? describeTransportClose(event));
if (reason) {
this.lastErrorValue = reason;
}
+3
View File
@@ -120,6 +120,9 @@ export function createHostTransportFactory(
factory = createRelayE2eeTransportFactory({
baseFactory: factory,
daemonPublicKeyB64: socketTarget.offer.daemonPublicKeyB64,
...(socketTarget.offer.minimumSecurityMode
? { minimumMode: socketTarget.offer.minimumSecurityMode }
: {}),
logger: { warn() {} },
assertOutboundCapacity: (bytes) => assertOutboundCapacity(bytes),
});
@@ -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();
@@ -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.
+3
View File
@@ -35,6 +35,9 @@
"test:watch": "vitest"
},
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
"@noble/hashes": "^2.4.0",
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
"ws": "^8.14.2"
+8 -1
View File
@@ -1,4 +1,11 @@
export { openClientSecureChannel, openDaemonSecureChannel } from "./secure-channel.js";
export {
openClientSecureChannel,
openDaemonSecureChannel,
PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE,
PROTECTED_MODE_REQUIRED_CLOSE_CODE,
PROTECTED_MODE_REQUIRED_CLOSE_REASON,
SecureChannelRejectedError,
} from "./secure-channel.js";
export type {
ClientSecureChannelOptions,
DaemonSecureChannelOptions,
+1
View File
@@ -16,4 +16,5 @@ export {
EncryptedChannel,
maxBase64EncryptedPlaintextByteLength,
} from "./encrypted-channel.js";
export { maxSecuredPlaintextByteLength } from "./secure-channel.js";
export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js";
+76
View File
@@ -0,0 +1,76 @@
import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
/** Noise spec §5.1 CipherState for ChaChaPoly. Nonce is 4 zero bytes then 64-bit little-endian n. */
export const NOISE_TAG_BYTES = 16;
export const NOISE_MAX_MESSAGE_BYTES = 65535;
const MAX_NONCE = (1n << 64n) - 1n;
export class NoiseProtocolError extends Error {
constructor(message: string) {
super(message);
this.name = "NoiseProtocolError";
}
}
export class CipherState {
private k: Uint8Array | null = null;
private n = 0n;
initializeKey(key: Uint8Array | null, nonce = 0n): void {
this.k = key;
this.n = nonce;
}
hasKey(): boolean {
return this.k !== null;
}
nonce(): bigint {
return this.n;
}
encryptWithAd(ad: Uint8Array, plaintext: Uint8Array): Uint8Array {
if (this.k === null) return plaintext;
const nonce = this.takeNonce();
return chacha20poly1305(this.k, nonce, ad).encrypt(plaintext);
}
decryptWithAd(ad: Uint8Array, ciphertext: Uint8Array): Uint8Array {
if (this.k === null) return ciphertext;
if (this.n === MAX_NONCE) throw new NoiseProtocolError("nonce exhausted");
let plaintext: Uint8Array;
try {
plaintext = chacha20poly1305(this.k, encodeNonce(this.n), ad).decrypt(ciphertext);
} catch {
throw new NoiseProtocolError("authentication failed");
}
// Only a successful authentication advances the receive counter.
this.n += 1n;
return plaintext;
}
/** Reserves the next nonce synchronously so concurrent senders never share one. */
private takeNonce(): Uint8Array {
if (this.n === MAX_NONCE) throw new NoiseProtocolError("nonce exhausted");
const nonce = encodeNonce(this.n);
this.n += 1n;
return nonce;
}
clear(): void {
this.k?.fill(0);
this.k = null;
this.n = MAX_NONCE;
}
}
function encodeNonce(n: bigint): Uint8Array {
const nonce = new Uint8Array(12);
let value = n;
for (let i = 4; i < 12; i += 1) {
nonce[i] = Number(value & 0xffn);
value >>= 8n;
}
return nonce;
}
@@ -0,0 +1,85 @@
{
"vectors": [
{
"name": "Noise_NK_25519_ChaChaPoly_SHA256",
"pattern": "NK",
"dh": "25519",
"cipher": "ChaChaPoly",
"hash": "SHA256",
"init_prologue": "4a6f686e2047616c74",
"init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a",
"init_remote_static": "31e0303fd6418d2f8c0e78b91f22e8caed0fbe48656dcf4767e4834f701b8f62",
"resp_prologue": "4a6f686e2047616c74",
"resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893",
"resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b",
"messages": [
{
"payload": "4c756477696720766f6e204d69736573",
"ciphertext": "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c79448134d00711fdb390a0d178fa008f6d47d2891e5ea18ae136c3b4c23ac384efb0"
},
{
"payload": "4d757272617920526f746862617264",
"ciphertext": "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f1448088438ea16e3701bc0d77744f117bee22451c9afa7f4cdbbcff00c04a8ee0913c88"
},
{
"payload": "462e20412e20486179656b",
"ciphertext": "a62de29ce27cb80245d440d986ed816c156e9d757d7008df2198b0"
},
{
"payload": "4361726c204d656e676572",
"ciphertext": "174a35f11c689f4530d7208618e0564ae12f2f50ba8eb4df5382ff"
},
{
"payload": "4a65616e2d426170746973746520536179",
"ciphertext": "337e475ebb8eae60f91974c4e455a5af38d1d8628d1803b160d60442874b0a1777"
},
{
"payload": "457567656e2042f6686d20766f6e2042617765726b",
"ciphertext": "047e80e060b7bb08b53c5a23dfe9920cae135b9d1dc6302fc475003062723700366346ac9d"
}
],
"source": "https://raw.githubusercontent.com/rweather/noise-c/master/tests/vector/cacophony.txt"
},
{
"name": "Noise_NK_25519_ChaChaPoly_SHA256",
"pattern": "NK",
"dh": "25519",
"cipher": "ChaChaPoly",
"hash": "SHA256",
"init_prologue": "50726f6c6f677565313233",
"init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a",
"init_remote_static": "31e0303fd6418d2f8c0e78b91f22e8caed0fbe48656dcf4767e4834f701b8f62",
"resp_prologue": "50726f6c6f677565313233",
"resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893",
"resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b",
"messages": [
{
"payload": "4c756477696720766f6e204d69736573",
"ciphertext": "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c79448134d00711fdb390a0d178fa008f6d47f49a76e297aa164052a3d842aa8ff7d8"
},
{
"payload": "4d757272617920526f746862617264",
"ciphertext": "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f1448088438ea16e3701bc0d77744f117bee22451628b075da65d4114bd343e2d93006c4"
},
{
"payload": "462e20412e20486179656b",
"ciphertext": "a62de29ce27cb80245d440d986ed816c156e9d757d7008df2198b0"
},
{
"payload": "4361726c204d656e676572",
"ciphertext": "174a35f11c689f4530d7208618e0564ae12f2f50ba8eb4df5382ff"
},
{
"payload": "4a65616e2d426170746973746520536179",
"ciphertext": "337e475ebb8eae60f91974c4e455a5af38d1d8628d1803b160d60442874b0a1777"
},
{
"payload": "457567656e2042f6686d20766f6e2042617765726b",
"ciphertext": "047e80e060b7bb08b53c5a23dfe9920cae135b9d1dc6302fc475003062723700366346ac9d"
}
],
"handshake_hash": "d5c4ce9ffe8bcd940aa50f842a5d4d90d3f7163f4b3916deb87a5d747712d718",
"source": "https://raw.githubusercontent.com/rweather/noise-c/master/tests/vector/noise-c-basic.txt"
}
]
}
+241
View File
@@ -0,0 +1,241 @@
/**
* Conforming Noise_NK_25519_ChaChaPoly_SHA256 (Noise Protocol Framework rev 34).
*
* The pattern is executed token by token from its definition, so the vector
* tests exercise the same code path as production. Only NK is provided; the
* daemon's static X25519 key is the responder's `s`, known to the initiator
* from pairing.
*
* NK:
* <- s
* ...
* -> e, es
* <- e, ee
*/
import { x25519 } from "@noble/curves/ed25519.js";
import { CipherState, NoiseProtocolError, NOISE_MAX_MESSAGE_BYTES } from "./cipher-state.js";
import { SymmetricState, concat } from "./symmetric-state.js";
export {
CipherState,
NoiseProtocolError,
NOISE_MAX_MESSAGE_BYTES,
NOISE_TAG_BYTES,
} from "./cipher-state.js";
export const NOISE_NK_PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_SHA256";
export const DH_BYTES = 32;
export interface NoiseKeyPair {
publicKey: Uint8Array;
secretKey: Uint8Array;
}
export interface NoiseInitiatorOptions {
/** Responder's static public key, trusted out of band. */
remoteStatic: Uint8Array;
prologue: Uint8Array;
/** Test injection only; production generates a fresh ephemeral. */
ephemeral?: NoiseKeyPair;
}
export interface NoiseResponderOptions {
localStatic: NoiseKeyPair;
prologue: Uint8Array;
/** Test injection only; production generates a fresh ephemeral. */
ephemeral?: NoiseKeyPair;
}
export interface TransportKeys {
/** Initiator → responder. */
send: CipherState;
/** Responder → initiator. */
receive: CipherState;
}
export interface NoiseHandshake {
/** Builds the next handshake message this side is due to write. */
writeMessage(payload: Uint8Array): Uint8Array;
/** Consumes the next handshake message this side is due to read; returns its payload. */
readMessage(message: Uint8Array): Uint8Array;
/** True once both handshake messages have been processed. */
isComplete(): boolean;
handshakeHash(): Uint8Array;
/** Derives transport keys, oriented for this side. Ends the handshake state. */
split(): { send: CipherState; receive: CipherState };
}
type Token = "e" | "es" | "ee";
const NK_MESSAGES: ReadonlyArray<ReadonlyArray<Token>> = [
["e", "es"],
["e", "ee"],
];
export function generateNoiseKeyPair(): NoiseKeyPair {
const secretKey = x25519.utils.randomSecretKey();
return { secretKey, publicKey: x25519.getPublicKey(secretKey) };
}
export function createNoiseInitiator(options: NoiseInitiatorOptions): NoiseHandshake {
requireKeyLength(options.remoteStatic, "remote static key");
return new HandshakeState({
initiator: true,
prologue: options.prologue,
s: null,
rs: options.remoteStatic,
ephemeral: options.ephemeral ?? null,
});
}
export function createNoiseResponder(options: NoiseResponderOptions): NoiseHandshake {
requireKeyLength(options.localStatic.publicKey, "local static public key");
requireKeyLength(options.localStatic.secretKey, "local static secret key");
return new HandshakeState({
initiator: false,
prologue: options.prologue,
s: options.localStatic,
rs: null,
ephemeral: options.ephemeral ?? null,
});
}
class HandshakeState implements NoiseHandshake {
private readonly symmetric = new SymmetricState(NOISE_NK_PROTOCOL_NAME);
private readonly initiator: boolean;
private readonly s: NoiseKeyPair | null;
private e: NoiseKeyPair | null;
private rs: Uint8Array | null;
private re: Uint8Array | null = null;
private messageIndex = 0;
private finished = false;
constructor(params: {
initiator: boolean;
prologue: Uint8Array;
s: NoiseKeyPair | null;
rs: Uint8Array | null;
ephemeral: NoiseKeyPair | null;
}) {
this.initiator = params.initiator;
this.s = params.s;
this.rs = params.rs;
this.e = params.ephemeral;
this.symmetric.mixHash(params.prologue);
// Pre-message "<- s": the responder's static public key.
const preMessageStatic = this.initiator ? this.rs : this.s?.publicKey;
if (!preMessageStatic) throw new NoiseProtocolError("missing responder static key");
this.symmetric.mixHash(preMessageStatic);
}
writeMessage(payload: Uint8Array): Uint8Array {
const tokens = this.nextTokens(this.initiator);
const parts: Uint8Array[] = [];
for (const token of tokens) {
switch (token) {
case "e": {
if (!this.e) this.e = generateNoiseKeyPair();
parts.push(this.e.publicKey);
this.symmetric.mixHash(this.e.publicKey);
break;
}
case "es":
this.mixDh(this.initiator ? this.e : this.s, this.initiator ? this.rs : this.re);
break;
case "ee":
this.mixDh(this.e, this.re);
break;
}
}
parts.push(this.symmetric.encryptAndHash(payload));
const message = concat(...parts);
if (message.byteLength > NOISE_MAX_MESSAGE_BYTES) {
throw new NoiseProtocolError("handshake message too large");
}
this.advance();
return message;
}
readMessage(message: Uint8Array): Uint8Array {
if (message.byteLength > NOISE_MAX_MESSAGE_BYTES) {
throw new NoiseProtocolError("handshake message too large");
}
const tokens = this.nextTokens(!this.initiator);
let offset = 0;
for (const token of tokens) {
switch (token) {
case "e": {
if (message.byteLength < offset + DH_BYTES) {
throw new NoiseProtocolError("handshake message truncated");
}
this.re = message.slice(offset, offset + DH_BYTES);
offset += DH_BYTES;
this.symmetric.mixHash(this.re);
break;
}
case "es":
this.mixDh(this.initiator ? this.e : this.s, this.initiator ? this.rs : this.re);
break;
case "ee":
this.mixDh(this.e, this.re);
break;
}
}
const payload = this.symmetric.decryptAndHash(message.slice(offset));
this.advance();
return payload;
}
isComplete(): boolean {
return this.messageIndex >= NK_MESSAGES.length;
}
handshakeHash(): Uint8Array {
return this.symmetric.handshakeHash();
}
split(): { send: CipherState; receive: CipherState } {
if (!this.isComplete()) throw new NoiseProtocolError("handshake incomplete");
if (this.finished) throw new NoiseProtocolError("handshake already split");
this.finished = true;
const [c1, c2] = this.symmetric.split();
this.e?.secretKey.fill(0);
this.e = null;
return this.initiator ? { send: c1, receive: c2 } : { send: c2, receive: c1 };
}
private nextTokens(expectedWriterIsInitiator: boolean): ReadonlyArray<Token> {
if (this.isComplete() || this.finished) throw new NoiseProtocolError("handshake complete");
const writerIsInitiator = this.messageIndex % 2 === 0;
if (writerIsInitiator !== expectedWriterIsInitiator) {
throw new NoiseProtocolError("handshake message out of turn");
}
return NK_MESSAGES[this.messageIndex];
}
private advance(): void {
this.messageIndex += 1;
}
private mixDh(local: NoiseKeyPair | null, remote: Uint8Array | null): void {
if (!local || !remote) throw new NoiseProtocolError("missing DH key");
let shared: Uint8Array;
try {
shared = x25519.getSharedSecret(local.secretKey, remote);
} catch {
throw new NoiseProtocolError("invalid DH public key");
}
if (isAllZero(shared)) throw new NoiseProtocolError("invalid DH public key");
this.symmetric.mixKey(shared);
shared.fill(0);
}
}
function requireKeyLength(key: Uint8Array, label: string): void {
if (key.byteLength !== DH_BYTES) throw new NoiseProtocolError(`${label} must be 32 bytes`);
}
function isAllZero(bytes: Uint8Array): boolean {
let acc = 0;
for (const byte of bytes) acc |= byte;
return acc === 0;
}
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, test } from "vitest";
import {
createNoiseInitiator,
createNoiseResponder,
generateNoiseKeyPair,
NoiseProtocolError,
NOISE_NK_PROTOCOL_NAME,
} from "./index.js";
import vectors from "./fixtures/nk-25519-chachapoly-sha256.json" with { type: "json" };
import { x25519 } from "@noble/curves/ed25519.js";
function hex(bytes: Uint8Array): string {
return Buffer.from(bytes).toString("hex");
}
function fromHex(hexText: string): Uint8Array {
return new Uint8Array(Buffer.from(hexText, "hex"));
}
function keyPairFromSecret(secretHex: string) {
const secretKey = fromHex(secretHex);
return { secretKey, publicKey: x25519.getPublicKey(secretKey) };
}
describe(NOISE_NK_PROTOCOL_NAME, () => {
for (const vector of vectors.vectors) {
test(`matches vector from ${vector.source}`, () => {
expect(vector.name).toBe(NOISE_NK_PROTOCOL_NAME);
const responderStatic = keyPairFromSecret(vector.resp_static);
expect(hex(responderStatic.publicKey)).toBe(vector.init_remote_static);
const initiator = createNoiseInitiator({
remoteStatic: fromHex(vector.init_remote_static),
prologue: fromHex(vector.init_prologue),
ephemeral: keyPairFromSecret(vector.init_ephemeral),
});
const responder = createNoiseResponder({
localStatic: responderStatic,
prologue: fromHex(vector.resp_prologue),
ephemeral: keyPairFromSecret(vector.resp_ephemeral),
});
const [m1, m2, ...transport] = vector.messages;
const msg1 = initiator.writeMessage(fromHex(m1.payload));
expect(hex(msg1)).toBe(m1.ciphertext);
expect(hex(responder.readMessage(msg1))).toBe(m1.payload);
const msg2 = responder.writeMessage(fromHex(m2.payload));
expect(hex(msg2)).toBe(m2.ciphertext);
expect(hex(initiator.readMessage(msg2))).toBe(m2.payload);
expect(initiator.isComplete()).toBe(true);
expect(responder.isComplete()).toBe(true);
if ("handshake_hash" in vector) {
expect(hex(initiator.handshakeHash())).toBe(vector.handshake_hash);
expect(hex(responder.handshakeHash())).toBe(vector.handshake_hash);
}
const initiatorKeys = initiator.split();
const responderKeys = responder.split();
const empty = new Uint8Array(0);
transport.forEach((message, index) => {
const fromInitiator = index % 2 === 0;
const sender = fromInitiator ? initiatorKeys.send : responderKeys.send;
const receiver = fromInitiator ? responderKeys.receive : initiatorKeys.receive;
const ciphertext = sender.encryptWithAd(empty, fromHex(message.payload));
expect(hex(ciphertext)).toBe(message.ciphertext);
expect(hex(receiver.decryptWithAd(empty, ciphertext))).toBe(message.payload);
});
});
}
test("rejects a replayed, reflected, or out-of-order transport message", () => {
const daemon = generateNoiseKeyPair();
const prologue = new TextEncoder().encode("test");
const initiator = createNoiseInitiator({ remoteStatic: daemon.publicKey, prologue });
const responder = createNoiseResponder({ localStatic: daemon, prologue });
const empty = new Uint8Array(0);
responder.readMessage(initiator.writeMessage(empty));
initiator.readMessage(responder.writeMessage(empty));
const client = initiator.split();
const server = responder.split();
const first = client.send.encryptWithAd(empty, new Uint8Array([1]));
const second = client.send.encryptWithAd(empty, new Uint8Array([2]));
expect(server.receive.decryptWithAd(empty, first)).toEqual(new Uint8Array([1]));
// duplicate
expect(() => server.receive.decryptWithAd(empty, first)).toThrow(NoiseProtocolError);
// a failed attempt did not advance the counter: the genuine next record still decrypts
expect(server.receive.decryptWithAd(empty, second)).toEqual(new Uint8Array([2]));
// reflection: a record the client sent, handed back to the client
expect(() => client.receive.decryptWithAd(empty, second)).toThrow(NoiseProtocolError);
// out of order
const third = client.send.encryptWithAd(empty, new Uint8Array([3]));
const fourth = client.send.encryptWithAd(empty, new Uint8Array([4]));
expect(() => server.receive.decryptWithAd(empty, fourth)).toThrow(NoiseProtocolError);
expect(server.receive.decryptWithAd(empty, third)).toEqual(new Uint8Array([3]));
expect(server.receive.decryptWithAd(empty, fourth)).toEqual(new Uint8Array([4]));
});
test("a recorded first message cannot derive the keys of a fresh responder", () => {
const daemon = generateNoiseKeyPair();
const prologue = new TextEncoder().encode("test");
const empty = new Uint8Array(0);
const initiator = createNoiseInitiator({ remoteStatic: daemon.publicKey, prologue });
const msg1 = initiator.writeMessage(empty);
const original = createNoiseResponder({ localStatic: daemon, prologue });
original.readMessage(msg1);
initiator.readMessage(original.writeMessage(empty));
const clientKeys = initiator.split();
const record = clientKeys.send.encryptWithAd(empty, new Uint8Array([7]));
const replayTarget = createNoiseResponder({ localStatic: daemon, prologue });
replayTarget.readMessage(msg1);
replayTarget.writeMessage(empty);
const replayKeys = replayTarget.split();
expect(() => replayKeys.receive.decryptWithAd(empty, record)).toThrow(NoiseProtocolError);
});
test("rejects a low-order remote ephemeral and a wrong responder key", () => {
const daemon = generateNoiseKeyPair();
const prologue = new TextEncoder().encode("test");
const empty = new Uint8Array(0);
const responder = createNoiseResponder({ localStatic: daemon, prologue });
const zeroEphemeral = new Uint8Array(32 + 16);
expect(() => responder.readMessage(zeroEphemeral)).toThrow(NoiseProtocolError);
const other = generateNoiseKeyPair();
const initiator = createNoiseInitiator({ remoteStatic: other.publicKey, prologue });
const victim = createNoiseResponder({ localStatic: daemon, prologue });
expect(() => victim.readMessage(initiator.writeMessage(empty))).toThrow(NoiseProtocolError);
});
test("refuses to proceed out of turn or after completion", () => {
const daemon = generateNoiseKeyPair();
const prologue = new Uint8Array(0);
const empty = new Uint8Array(0);
const initiator = createNoiseInitiator({ remoteStatic: daemon.publicKey, prologue });
const responder = createNoiseResponder({ localStatic: daemon, prologue });
expect(() => responder.writeMessage(empty)).toThrow(NoiseProtocolError);
expect(() => initiator.split()).toThrow(NoiseProtocolError);
responder.readMessage(initiator.writeMessage(empty));
expect(() => initiator.writeMessage(empty)).toThrow(NoiseProtocolError);
initiator.readMessage(responder.writeMessage(empty));
expect(() => initiator.readMessage(empty)).toThrow(NoiseProtocolError);
initiator.split();
expect(() => initiator.split()).toThrow(NoiseProtocolError);
});
});
@@ -0,0 +1,83 @@
import { sha256 } from "@noble/hashes/sha2.js";
import { hmac } from "@noble/hashes/hmac.js";
import { CipherState } from "./cipher-state.js";
/** Noise spec §5.2 SymmetricState for SHA256 (HASHLEN 32). */
const HASHLEN = 32;
const EMPTY = new Uint8Array(0);
export class SymmetricState {
readonly cipher = new CipherState();
private ck: Uint8Array;
private h: Uint8Array;
constructor(protocolName: string) {
const name = new TextEncoder().encode(protocolName);
if (name.byteLength <= HASHLEN) {
this.h = new Uint8Array(HASHLEN);
this.h.set(name);
} else {
this.h = sha256(name);
}
this.ck = this.h.slice();
this.cipher.initializeKey(null);
}
mixKey(inputKeyMaterial: Uint8Array): void {
const [ck, tempK] = hkdf2(this.ck, inputKeyMaterial);
this.ck = ck;
this.cipher.initializeKey(tempK);
}
mixHash(data: Uint8Array): void {
this.h = sha256(concat(this.h, data));
}
handshakeHash(): Uint8Array {
return this.h.slice();
}
encryptAndHash(plaintext: Uint8Array): Uint8Array {
const ciphertext = this.cipher.encryptWithAd(this.h, plaintext);
this.mixHash(ciphertext);
return ciphertext;
}
decryptAndHash(ciphertext: Uint8Array): Uint8Array {
const plaintext = this.cipher.decryptWithAd(this.h, ciphertext);
this.mixHash(ciphertext);
return plaintext;
}
split(): [CipherState, CipherState] {
const [k1, k2] = hkdf2(this.ck, EMPTY);
const c1 = new CipherState();
const c2 = new CipherState();
c1.initializeKey(k1);
c2.initializeKey(k2);
this.ck.fill(0);
this.cipher.clear();
return [c1, c2];
}
}
/** Noise spec §4.3 HKDF with two outputs: HMAC chain with 0x01 and 0x02 info bytes. */
function hkdf2(chainingKey: Uint8Array, inputKeyMaterial: Uint8Array): [Uint8Array, Uint8Array] {
const tempKey = hmac(sha256, chainingKey, inputKeyMaterial);
const output1 = hmac(sha256, tempKey, new Uint8Array([0x01]));
const output2 = hmac(sha256, tempKey, concat(output1, new Uint8Array([0x02])));
tempKey.fill(0);
return [output1, output2];
}
export function concat(...parts: Uint8Array[]): Uint8Array {
const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.byteLength;
}
return out;
}
@@ -0,0 +1,160 @@
import { describe, expect, test } from "vitest";
import { CipherState, NoiseProtocolError } from "./noise/index.js";
import {
decodeProtectedRecord,
encodeProtectedRecord,
MAX_CHUNK_DATA_BYTES,
protectedRecordByteLength,
} from "./protected-records.js";
function pair(): { sender: CipherState; receiver: CipherState } {
const key = new Uint8Array(32).map((_, i) => i + 1);
const sender = new CipherState();
const receiver = new CipherState();
sender.initializeKey(key.slice());
receiver.initializeKey(key.slice());
return { sender, receiver };
}
function frameOf(length: number): Uint8Array {
return new Uint8Array(length).map((_, i) => (i * 7 + 3) & 0xff);
}
const EMPTY = new Uint8Array(0);
/** Re-encrypts one chunk's plaintext under the next sender nonce. */
function reencrypt(sender: CipherState, plaintext: Uint8Array): Uint8Array {
const ct = sender.encryptWithAd(EMPTY, plaintext);
const out = new Uint8Array(2 + ct.byteLength);
out[0] = ct.byteLength >>> 8;
out[1] = ct.byteLength & 0xff;
out.set(ct, 2);
return out;
}
function chunkPlaintext(kind: number, flags: number, total: number, data: Uint8Array): Uint8Array {
const pt = new Uint8Array(6 + data.byteLength);
pt[0] = kind;
pt[1] = flags;
pt[2] = (total >>> 24) & 0xff;
pt[3] = (total >>> 16) & 0xff;
pt[4] = (total >>> 8) & 0xff;
pt[5] = total & 0xff;
pt.set(data, 6);
return pt;
}
function concat(...parts: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0));
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.byteLength;
}
return out;
}
describe("protected records", () => {
test.each([
["empty text", "text", 0],
["empty binary", "binary", 0],
["small binary", "binary", 100],
["exactly one chunk", "text", MAX_CHUNK_DATA_BYTES],
["one byte over a chunk", "binary", MAX_CHUNK_DATA_BYTES + 1],
["exact multiple of chunks", "text", MAX_CHUNK_DATA_BYTES * 3],
["three and a bit chunks", "binary", MAX_CHUNK_DATA_BYTES * 3 + 17],
] as const)("round-trips %s", (_label, kind, length) => {
const { sender, receiver } = pair();
const frame = frameOf(length);
const record = encodeProtectedRecord(kind, frame, sender);
expect(record.byteLength).toBe(protectedRecordByteLength(length));
const decoded = decodeProtectedRecord(record, receiver);
expect(decoded.kind).toBe(kind);
expect(Buffer.from(decoded.data).equals(Buffer.from(frame))).toBe(true);
expect(receiver.nonce()).toBe(sender.nonce());
});
test("rejects two valid records concatenated into one message", () => {
const { sender, receiver } = pair();
const a = encodeProtectedRecord("text", frameOf(10), sender);
const b = encodeProtectedRecord("text", frameOf(10), sender);
expect(() => decodeProtectedRecord(concat(a, b), receiver)).toThrow(NoiseProtocolError);
});
test("rejects a multi-chunk record split across messages", () => {
const { sender, receiver } = pair();
const record = encodeProtectedRecord("binary", frameOf(MAX_CHUNK_DATA_BYTES + 5), sender);
const firstChunkLength = 2 + ((record[0] << 8) | record[1]);
const firstOnly = record.subarray(0, firstChunkLength);
expect(() => decodeProtectedRecord(firstOnly, receiver)).toThrow(NoiseProtocolError);
});
test("rejects a record whose complete final chunk is removed", () => {
const { sender, receiver } = pair();
const record = encodeProtectedRecord("binary", frameOf(MAX_CHUNK_DATA_BYTES * 2), sender);
const firstChunkLength = 2 + ((record[0] << 8) | record[1]);
expect(() => decodeProtectedRecord(record.subarray(0, firstChunkLength), receiver)).toThrow(
NoiseProtocolError,
);
});
test("rejects zero chunks and trailing bytes", () => {
const { sender, receiver } = pair();
expect(() => decodeProtectedRecord(new Uint8Array(0), receiver)).toThrow(NoiseProtocolError);
const record = encodeProtectedRecord("text", frameOf(3), sender);
expect(() => decodeProtectedRecord(concat(record, new Uint8Array([0])), receiver)).toThrow(
NoiseProtocolError,
);
});
test("rejects an extra zero-byte FINAL chunk after an exact-multiple frame", () => {
const { sender, receiver } = pair();
const total = MAX_CHUNK_DATA_BYTES;
const data = frameOf(total);
const forged = concat(
reencrypt(sender, chunkPlaintext(0x00, 0x01, total, data)),
reencrypt(sender, chunkPlaintext(0x00, 0x02, total, EMPTY)),
);
expect(() => decodeProtectedRecord(forged, receiver)).toThrow(NoiseProtocolError);
});
test("rejects reserved flags, invalid kind, changed kind, and changed total length", () => {
const cases: Array<[Uint8Array[]]> = [
[[chunkPlaintext(0x00, 0x01 | 0x02 | 0x04, 1, new Uint8Array([1]))]],
[[chunkPlaintext(0x02, 0x03, 1, new Uint8Array([1]))]],
[
[
chunkPlaintext(0x00, 0x01, MAX_CHUNK_DATA_BYTES + 1, frameOf(MAX_CHUNK_DATA_BYTES)),
chunkPlaintext(0x01, 0x02, MAX_CHUNK_DATA_BYTES + 1, new Uint8Array([1])),
],
],
[
[
chunkPlaintext(0x00, 0x01, MAX_CHUNK_DATA_BYTES + 1, frameOf(MAX_CHUNK_DATA_BYTES)),
chunkPlaintext(0x00, 0x02, MAX_CHUNK_DATA_BYTES + 2, new Uint8Array([1, 2])),
],
],
];
for (const [plaintexts] of cases) {
const { sender, receiver } = pair();
const forged = concat(...plaintexts.map((pt) => reencrypt(sender, pt)));
expect(() => decodeProtectedRecord(forged, receiver)).toThrow(NoiseProtocolError);
}
});
test("rejects a tampered chunk without advancing past it, and later chunks never escape", () => {
const { sender, receiver } = pair();
const record = encodeProtectedRecord("binary", frameOf(MAX_CHUNK_DATA_BYTES + 5), sender);
const tampered = record.slice();
tampered[2 + 10] ^= 0x01;
expect(() => decodeProtectedRecord(tampered, receiver)).toThrow(NoiseProtocolError);
expect(receiver.nonce()).toBe(0n);
});
test("a record delivered twice fails the second time", () => {
const { sender, receiver } = pair();
const record = encodeProtectedRecord("text", frameOf(20), sender);
decodeProtectedRecord(record, receiver);
expect(() => decodeProtectedRecord(record, receiver)).toThrow(NoiseProtocolError);
});
});
+221
View File
@@ -0,0 +1,221 @@
/**
* Authenticated application-frame framing over Noise transport messages.
*
* One application frame becomes one wire record. Every Noise transport message
* (chunk) carries, inside its authenticated plaintext, the frame kind, FIRST
* and FINAL flags, and the total frame length, so a relay cannot merge two
* records, split one across WebSocket messages, truncate a final chunk, or
* re-chunk a frame without an authentication failure or a framing violation.
* Transport AD is always empty, as in Noise §5.
*
* chunk plaintext = kind(1) || flags(1) || totalLength(u32 BE) || data
* wire record = for each chunk: ciphertextLength(u16 BE) || ciphertext
*/
import {
CipherState,
NoiseProtocolError,
NOISE_MAX_MESSAGE_BYTES,
NOISE_TAG_BYTES,
} from "./noise/index.js";
export type FrameKind = "text" | "binary";
const KIND_TEXT = 0x00;
const KIND_BINARY = 0x01;
const FLAG_FIRST = 0x01;
const FLAG_FINAL = 0x02;
const HEADER_BYTES = 6;
const LENGTH_PREFIX_BYTES = 2;
const EMPTY_AD = new Uint8Array(0);
/** Largest data payload a single chunk carries. */
export const MAX_CHUNK_DATA_BYTES = NOISE_MAX_MESSAGE_BYTES - NOISE_TAG_BYTES - HEADER_BYTES;
/** Largest application frame a record may carry; bounds allocation before decryption. */
export const MAX_FRAME_BYTES = 0xffff_ffff;
const CHUNK_OVERHEAD_BYTES = LENGTH_PREFIX_BYTES + HEADER_BYTES + NOISE_TAG_BYTES;
export function chunkCount(frameBytes: number): number {
return Math.max(1, Math.ceil(frameBytes / MAX_CHUNK_DATA_BYTES));
}
/** Wire bytes a frame of `frameBytes` occupies once protected. */
export function protectedRecordByteLength(frameBytes: number): number {
return frameBytes + chunkCount(frameBytes) * CHUNK_OVERHEAD_BYTES;
}
export function encodeProtectedRecord(
kind: FrameKind,
frame: Uint8Array,
sender: CipherState,
): Uint8Array {
if (frame.byteLength > MAX_FRAME_BYTES) throw new NoiseProtocolError("frame too large");
const chunks = chunkCount(frame.byteLength);
const out = new Uint8Array(protectedRecordByteLength(frame.byteLength));
let offset = 0;
for (let index = 0; index < chunks; index += 1) {
const start = index * MAX_CHUNK_DATA_BYTES;
const data = frame.subarray(start, Math.min(frame.byteLength, start + MAX_CHUNK_DATA_BYTES));
const plaintext = new Uint8Array(HEADER_BYTES + data.byteLength);
plaintext[0] = kind === "text" ? KIND_TEXT : KIND_BINARY;
plaintext[1] = (index === 0 ? FLAG_FIRST : 0) | (index === chunks - 1 ? FLAG_FINAL : 0);
writeU32(plaintext, 2, frame.byteLength);
plaintext.set(data, HEADER_BYTES);
const ciphertext = sender.encryptWithAd(EMPTY_AD, plaintext);
writeU16(out, offset, ciphertext.byteLength);
out.set(ciphertext, offset + LENGTH_PREFIX_BYTES);
offset += LENGTH_PREFIX_BYTES + ciphertext.byteLength;
}
return out;
}
export interface DecodedFrame {
kind: FrameKind;
data: Uint8Array;
}
/**
* Decodes one wire record. Chunks are authenticated in order and the receive
* counter advances per authenticated chunk; any framing violation throws and
* nothing partial is returned. The caller must close the channel on error.
*/
export function decodeProtectedRecord(record: Uint8Array, receiver: CipherState): DecodedFrame {
let offset = 0;
let assembly: RecordAssembly | null = null;
while (offset < record.byteLength) {
if (assembly !== null && assembly.index >= assembly.expectedChunks) {
throw new NoiseProtocolError("unexpected chunk after final");
}
const { chunk, next } = readChunk(record, offset, receiver);
offset = next;
assembly = assembly === null ? startAssembly(chunk) : continueAssembly(assembly, chunk);
}
if (assembly === null) throw new NoiseProtocolError("empty record");
if (assembly.index !== assembly.expectedChunks || assembly.written !== assembly.totalLength) {
throw new NoiseProtocolError("record incomplete");
}
return { kind: assembly.kind, data: assembly.frame };
}
interface Chunk {
kind: FrameKind;
isFirst: boolean;
isFinal: boolean;
totalLength: number;
data: Uint8Array;
}
interface RecordAssembly {
kind: FrameKind;
totalLength: number;
expectedChunks: number;
frame: Uint8Array;
written: number;
index: number;
}
function readChunk(
record: Uint8Array,
offset: number,
receiver: CipherState,
): { chunk: Chunk; next: number } {
if (record.byteLength - offset < LENGTH_PREFIX_BYTES) {
throw new NoiseProtocolError("record truncated");
}
const ciphertextLength = readU16(record, offset);
const start = offset + LENGTH_PREFIX_BYTES;
if (
ciphertextLength < NOISE_TAG_BYTES + HEADER_BYTES ||
ciphertextLength > NOISE_MAX_MESSAGE_BYTES
) {
throw new NoiseProtocolError("invalid chunk length");
}
if (record.byteLength - start < ciphertextLength) {
throw new NoiseProtocolError("record truncated");
}
const plaintext = receiver.decryptWithAd(
EMPTY_AD,
record.subarray(start, start + ciphertextLength),
);
const flags = plaintext[1];
if ((flags & ~(FLAG_FIRST | FLAG_FINAL)) !== 0) {
throw new NoiseProtocolError("reserved flag set");
}
return {
chunk: {
kind: decodeKind(plaintext[0]),
isFirst: (flags & FLAG_FIRST) !== 0,
isFinal: (flags & FLAG_FINAL) !== 0,
totalLength: readU32(plaintext, 2),
data: plaintext.subarray(HEADER_BYTES),
},
next: start + ciphertextLength,
};
}
function startAssembly(chunk: Chunk): RecordAssembly {
if (!chunk.isFirst) throw new NoiseProtocolError("record does not start with FIRST");
const assembly: RecordAssembly = {
kind: chunk.kind,
totalLength: chunk.totalLength,
expectedChunks: chunkCount(chunk.totalLength),
frame: new Uint8Array(chunk.totalLength),
written: 0,
index: 0,
};
return appendChunk(assembly, chunk);
}
function continueAssembly(assembly: RecordAssembly, chunk: Chunk): RecordAssembly {
if (chunk.isFirst) throw new NoiseProtocolError("FIRST repeated inside record");
if (chunk.kind !== assembly.kind) {
throw new NoiseProtocolError("frame kind changed inside record");
}
if (chunk.totalLength !== assembly.totalLength) {
throw new NoiseProtocolError("frame length changed inside record");
}
return appendChunk(assembly, chunk);
}
function appendChunk(assembly: RecordAssembly, chunk: Chunk): RecordAssembly {
const isLastExpected = assembly.index === assembly.expectedChunks - 1;
if (chunk.isFinal !== isLastExpected) throw new NoiseProtocolError("FINAL flag mismatch");
const expectedData = isLastExpected
? assembly.totalLength - assembly.index * MAX_CHUNK_DATA_BYTES
: MAX_CHUNK_DATA_BYTES;
if (chunk.data.byteLength !== expectedData) throw new NoiseProtocolError("chunk size mismatch");
assembly.frame.set(chunk.data, assembly.written);
assembly.written += chunk.data.byteLength;
assembly.index += 1;
return assembly;
}
function decodeKind(byte: number): FrameKind {
if (byte === KIND_TEXT) return "text";
if (byte === KIND_BINARY) return "binary";
throw new NoiseProtocolError("invalid frame kind");
}
function writeU16(target: Uint8Array, offset: number, value: number): void {
target[offset] = (value >>> 8) & 0xff;
target[offset + 1] = value & 0xff;
}
function readU16(source: Uint8Array, offset: number): number {
return (source[offset] << 8) | source[offset + 1];
}
function writeU32(target: Uint8Array, offset: number, value: number): void {
target[offset] = (value >>> 24) & 0xff;
target[offset + 1] = (value >>> 16) & 0xff;
target[offset + 2] = (value >>> 8) & 0xff;
target[offset + 3] = value & 0xff;
}
function readU32(source: Uint8Array, offset: number): number {
return (
((source[offset] << 24) >>> 0) +
(source[offset + 1] << 16) +
(source[offset + 2] << 8) +
source[offset + 3]
);
}
+498
View File
@@ -0,0 +1,498 @@
import { describe, expect, it, vi } from "vitest";
import {
openClientSecureChannel,
openDaemonSecureChannel,
SecureChannelRejectedError,
type SecureChannel,
} from "./secure-channel.js";
import { createClientChannel, createDaemonChannel, type Transport } from "./encrypted-channel.js";
import { exportPublicKey, generateKeyPair } from "./crypto.js";
import { MAX_CHUNK_DATA_BYTES } from "./protected-records.js";
interface Frame {
data: string | ArrayBuffer;
isBinary: boolean;
}
type Interceptor = (frame: Frame) => Frame | null;
/**
* Two transports wired back to back through an optional interceptor per
* direction, standing in for a relay that may record, drop, reorder, or
* rewrite frames.
*/
function createLink() {
const toDaemon: Frame[] = [];
const toClient: Frame[] = [];
let interceptToDaemon: Interceptor = (frame) => frame;
let interceptToClient: Interceptor = (frame) => frame;
const daemon: Transport = {
send: vi.fn((data: string | ArrayBuffer) => {
const recorded = toFrame(data);
toClient.push(recorded);
const frame = interceptToClient(recorded);
if (frame) setTimeout(() => client.onmessage?.(frame), 0);
}),
close: vi.fn(),
onmessage: null,
onclose: null,
onerror: null,
};
const client: Transport = {
send: vi.fn((data: string | ArrayBuffer) => {
const recorded = toFrame(data);
toDaemon.push(recorded);
const frame = interceptToDaemon(recorded);
if (frame) setTimeout(() => daemon.onmessage?.(frame), 0);
}),
close: vi.fn(),
onmessage: null,
onclose: null,
onerror: null,
};
return {
daemon,
client,
recordedToDaemon: toDaemon,
recordedToClient: toClient,
setToDaemon(fn: Interceptor) {
interceptToDaemon = fn;
},
setToClient(fn: Interceptor) {
interceptToClient = fn;
},
};
}
function toFrame(data: string | ArrayBuffer): Frame {
return { data, isBinary: data instanceof ArrayBuffer };
}
async function settle(ms = 30): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
function deferred<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
interface Pair {
link: ReturnType<typeof createLink>;
daemonKeyPair: ReturnType<typeof generateKeyPair>;
client: SecureChannel;
daemon: SecureChannel;
daemonReceived: Array<string | ArrayBuffer>;
clientReceived: Array<string | ArrayBuffer>;
}
async function openProtectedPair(
options: {
link?: ReturnType<typeof createLink>;
daemonKeyPair?: ReturnType<typeof generateKeyPair>;
clientMinimum?: "legacy" | "protected";
daemonMinimum?: "legacy" | "protected";
} = {},
): Promise<Pair> {
const link = options.link ?? createLink();
const daemonKeyPair = options.daemonKeyPair ?? generateKeyPair();
const daemonReceived: Array<string | ArrayBuffer> = [];
const clientReceived: Array<string | ArrayBuffer> = [];
const opened = deferred();
const daemonPromise = openDaemonSecureChannel(link.daemon, {
daemonKeyPair,
minimumMode: options.daemonMinimum,
events: { onmessage: (data) => daemonReceived.push(data) },
});
const client = await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
minimumMode: options.clientMinimum,
events: { onopen: () => opened.resolve(), onmessage: (data) => clientReceived.push(data) },
});
const daemon = await daemonPromise;
await opened.promise;
return { link, daemonKeyPair, client, daemon, daemonReceived, clientReceived };
}
function bytes(length: number): ArrayBuffer {
return new Uint8Array(length).map((_, i) => (i * 13 + 1) & 0xff).buffer;
}
describe("secure channel: protected mode", () => {
it("negotiates protected mode between two updated peers and carries both frame kinds", async () => {
const pair = await openProtectedPair();
expect(pair.client.securityMode()).toBe("protected");
expect(pair.daemon.securityMode()).toBe("protected");
await pair.client.send("hello daemon");
await pair.client.send(bytes(5));
await pair.daemon.send("hello client");
await pair.daemon.send(bytes(7));
await settle();
expect(pair.daemonReceived[0]).toBe("hello daemon");
expect(new Uint8Array(pair.daemonReceived[1] as ArrayBuffer)).toEqual(new Uint8Array(bytes(5)));
expect(pair.clientReceived[0]).toBe("hello client");
expect(new Uint8Array(pair.clientReceived[1] as ArrayBuffer)).toEqual(new Uint8Array(bytes(7)));
});
it("keeps text as base64 text frames and binary as binary frames when negotiated", async () => {
const pair = await openProtectedPair();
pair.link.recordedToDaemon.length = 0;
await pair.client.send("text");
await pair.client.send(bytes(3));
await settle();
expect(pair.link.recordedToDaemon.map((frame) => frame.isBinary)).toEqual([false, true]);
expect(pair.link.recordedToDaemon[0].data).not.toContain("text");
expect(pair.client.outboundWireByteLength(bytes(3))).toBe(3 + 2 + 6 + 16);
expect(pair.client.outboundWireByteLength("text")).toBe(4 * Math.ceil((4 + 24) / 3));
});
it("carries frames larger than one Noise message", async () => {
const pair = await openProtectedPair();
const big = bytes(MAX_CHUNK_DATA_BYTES * 2 + 100);
await pair.client.send(big);
await settle(100);
expect(new Uint8Array(pair.daemonReceived[0] as ArrayBuffer)).toEqual(new Uint8Array(big));
});
it("flushes sends queued before the handshake completes, in order", async () => {
const link = createLink();
const daemonKeyPair = generateKeyPair();
const daemonReceived: Array<string | ArrayBuffer> = [];
const daemonPromise = openDaemonSecureChannel(link.daemon, {
daemonKeyPair,
events: { onmessage: (data) => daemonReceived.push(data) },
});
const client = await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
});
await client.send("one");
await client.send("two");
expect(client.isOpen()).toBe(false);
await daemonPromise;
await settle();
expect(daemonReceived).toEqual(["one", "two"]);
});
it("rejects an exact duplicate record and delivers nothing twice", async () => {
const pair = await openProtectedPair();
let captured: Frame | null = null;
pair.link.setToDaemon((frame) => {
captured ??= frame;
return frame;
});
await pair.client.send("once");
await settle();
expect(pair.daemonReceived).toEqual(["once"]);
pair.link.daemon.onmessage?.(captured!);
await settle();
expect(pair.daemonReceived).toEqual(["once"]);
expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String));
});
it("rejects a reflected record", async () => {
const pair = await openProtectedPair();
let captured: Frame | null = null;
pair.link.setToDaemon((frame) => {
captured ??= frame;
return frame;
});
await pair.client.send("mine");
await settle();
pair.link.client.onmessage?.(captured!);
await settle();
expect(pair.clientReceived).toEqual([]);
expect(pair.link.client.close).toHaveBeenCalledWith(1011, expect.any(String));
});
it("rejects out-of-order records and delivers nothing from them", async () => {
const pair = await openProtectedPair();
const held: Frame[] = [];
pair.link.setToDaemon((frame) => {
held.push(frame);
return null;
});
await pair.client.send("first");
await pair.client.send("second");
await settle();
expect(held).toHaveLength(2);
pair.link.daemon.onmessage?.(held[1]);
await settle();
expect(pair.daemonReceived).toEqual([]);
expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String));
pair.link.daemon.onmessage?.(held[0]);
await settle();
expect(pair.daemonReceived).toEqual([]);
});
it("rejects a tampered record without delivering it", async () => {
const pair = await openProtectedPair();
pair.link.setToDaemon((frame) => {
const tampered = new Uint8Array((frame.data as ArrayBuffer).slice(0));
tampered[tampered.byteLength - 1] ^= 0x01;
return { data: tampered.buffer, isBinary: true };
});
await pair.client.send(bytes(10));
await settle();
expect(pair.daemonReceived).toEqual([]);
expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String));
});
it("closes on plaintext application traffic", async () => {
const pair = await openProtectedPair();
pair.link.daemon.onmessage?.({ data: JSON.stringify({ type: "hello" }), isBinary: false });
await settle();
expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String));
expect(pair.daemonReceived).toEqual([]);
});
it("answers an identical hello retry with the identical ready and keeps counters", async () => {
const pair = await openProtectedPair();
const hello = pair.link.recordedToDaemon.find(
(frame) => typeof frame.data === "string" && frame.data.includes('"e2ee_hello"'),
);
const readyFrames = () =>
(pair.link.daemon.send as ReturnType<typeof vi.fn>).mock.calls
.map(([data]) => data)
.filter((data) => typeof data === "string" && data.includes('"e2ee_ready"'));
expect(hello).toBeDefined();
expect(readyFrames()).toHaveLength(1);
pair.link.daemon.onmessage?.(hello!);
await settle();
expect(readyFrames()).toHaveLength(2);
expect(readyFrames()[0]).toBe(readyFrames()[1]);
expect(pair.link.daemon.close).not.toHaveBeenCalled();
await pair.client.send("still protected");
await pair.daemon.send("both ways");
await settle();
expect(pair.daemonReceived).toEqual(["still protected"]);
expect(pair.clientReceived).toEqual(["both ways"]);
});
it("closes when a different hello arrives on an open channel", async () => {
const pair = await openProtectedPair();
const hello = pair.link.recordedToDaemon.find(
(frame) => typeof frame.data === "string" && frame.data.includes('"e2ee_hello"'),
)!;
const changed = (hello.data as string).replace('"v":1', '"v":1 ');
expect(changed).not.toBe(hello.data);
pair.link.daemon.onmessage?.({ data: changed, isBinary: false });
await settle();
expect(pair.link.daemon.close).toHaveBeenCalledWith(1008, expect.stringContaining("mismatch"));
});
it("does not let a recorded hello and records replay into a fresh daemon channel", async () => {
// Session 1: a legitimate client talks to the daemon; the relay records everything.
const first = await openProtectedPair();
await first.client.send("secret command");
await first.client.send(bytes(4));
await settle();
expect(first.daemonReceived).toHaveLength(2);
const transcript = first.link.recordedToDaemon.slice();
expect(transcript.length).toBeGreaterThanOrEqual(3);
// Session 2: the same daemon key pair (restart or reconnect), attacker replays the transcript.
const link = createLink();
const replayed: Array<string | ArrayBuffer> = [];
const daemonPromise = openDaemonSecureChannel(link.daemon, {
daemonKeyPair: first.daemonKeyPair,
events: { onmessage: (data) => replayed.push(data) },
});
for (const frame of transcript) link.daemon.onmessage?.(frame);
const daemon = await daemonPromise;
await settle();
expect(daemon.securityMode()).toBe("protected");
expect(replayed).toEqual([]);
expect(link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String));
});
});
describe("secure channel: negotiation and policy", () => {
it("rejects a present but invalid protected hello instead of falling back", async () => {
for (const mutate of [
(hello: Record<string, unknown>) => ({ ...hello, protected: { v: 2, message: "AAAA" } }),
(hello: Record<string, unknown>) => ({ ...hello, protected: { v: 1, message: "AAAA" } }),
(hello: Record<string, unknown>) => ({ ...hello, protected: "nope" }),
]) {
const link = createLink();
const daemonKeyPair = generateKeyPair();
link.setToDaemon((frame) => {
if (typeof frame.data !== "string") return frame;
return { data: JSON.stringify(mutate(JSON.parse(frame.data))), isBinary: false };
});
const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair });
await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
});
await expect(daemonPromise).rejects.toThrow();
expect((link.daemon.send as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(0);
}
});
it("rejects a tampered protected ready and never retries legacy", async () => {
const link = createLink();
const daemonKeyPair = generateKeyPair();
link.setToClient((frame) => {
if (typeof frame.data !== "string") return frame;
const ready = JSON.parse(frame.data);
const message = Buffer.from(ready.protected.message, "base64");
message[message.byteLength - 1] ^= 0x01;
ready.protected.message = message.toString("base64");
return { data: JSON.stringify(ready), isBinary: false };
});
const errors: Error[] = [];
const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair });
const client = await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
events: { onerror: (error) => errors.push(error) },
});
await daemonPromise;
await settle();
expect(client.isOpen()).toBe(false);
expect(link.client.close).toHaveBeenCalledWith(4002, expect.any(String));
expect(errors).toHaveLength(1);
await expect(client.send("x")).rejects.toThrow("Channel not open");
});
it("rejects a ready that selects an unoffered parameter", async () => {
// The daemon cannot do this by itself; a forged msg2 would need the keys, so the
// check is exercised by driving the decoder with a crafted selection.
const link = createLink();
const daemonKeyPair = generateKeyPair();
link.setToClient((frame) => {
if (typeof frame.data !== "string") return frame;
const ready = JSON.parse(frame.data);
ready.capabilities = { binaryCiphertext: true };
return { data: JSON.stringify(ready), isBinary: false };
});
const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair });
const client = await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
});
await daemonPromise;
await settle();
// Unauthenticated outer capabilities are ignored in protected mode.
expect(client.isOpen()).toBe(true);
expect(client.securityMode()).toBe("protected");
});
it("stripping protected from the ready does not downgrade a protected daemon", async () => {
const link = createLink();
const daemonKeyPair = generateKeyPair();
link.setToClient((frame) => {
if (typeof frame.data !== "string") return frame;
const ready = JSON.parse(frame.data);
delete ready.protected;
return { data: JSON.stringify(ready), isBinary: false };
});
const daemonReceived: unknown[] = [];
const daemonPromise = openDaemonSecureChannel(link.daemon, {
daemonKeyPair,
events: { onmessage: (data) => daemonReceived.push(data) },
});
const client = await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
});
await daemonPromise;
await settle();
expect(client.securityMode()).toBe("legacy");
await client.send("legacy record");
await settle();
expect(daemonReceived).toEqual([]);
expect(link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String));
});
it("client with a protected minimum refuses a legacy-only daemon", async () => {
const link = createLink();
const daemonKeyPair = generateKeyPair();
const errors: Error[] = [];
const daemonPromise = createDaemonChannel(link.daemon, daemonKeyPair);
const client = await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
minimumMode: "protected",
events: { onerror: (error) => errors.push(error) },
});
await daemonPromise;
await settle();
expect(client.isOpen()).toBe(false);
expect(link.client.close).toHaveBeenCalledWith(4002, expect.stringContaining("legacy"));
});
it("daemon with a protected minimum refuses a legacy-only client", async () => {
const link = createLink();
const daemonKeyPair = generateKeyPair();
const daemonPromise = openDaemonSecureChannel(link.daemon, {
daemonKeyPair,
minimumMode: () => "protected",
});
await createClientChannel(link.client, exportPublicKey(daemonKeyPair.publicKey));
await expect(daemonPromise).rejects.toBeInstanceOf(SecureChannelRejectedError);
await expect(daemonPromise).rejects.toMatchObject({
closeCode: 1008,
closeReason: "protected endpoint security required",
});
expect((link.daemon.send as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(0);
});
});
describe("secure channel: legacy compatibility", () => {
it("new client against an old daemon negotiates legacy and exchanges records", async () => {
const link = createLink();
const daemonKeyPair = generateKeyPair();
const daemonReceived: unknown[] = [];
const clientReceived: unknown[] = [];
const opened = deferred();
const daemonPromise = createDaemonChannel(link.daemon, daemonKeyPair, {
onmessage: (data) => daemonReceived.push(data),
});
const client = await openClientSecureChannel(link.client, {
daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey),
events: { onopen: () => opened.resolve(), onmessage: (data) => clientReceived.push(data) },
});
const daemon = await daemonPromise;
await opened.promise;
expect(client.securityMode()).toBe("legacy");
await client.send("from new client");
await daemon.send("from old daemon");
await settle();
expect(daemonReceived).toEqual(["from new client"]);
expect(clientReceived).toEqual(["from old daemon"]);
});
it("old client against a new daemon negotiates legacy and keeps legacy retry semantics", async () => {
const link = createLink();
const daemonKeyPair = generateKeyPair();
const daemonReceived: unknown[] = [];
const opened = deferred();
const daemonPromise = openDaemonSecureChannel(link.daemon, {
daemonKeyPair,
events: { onmessage: (data) => daemonReceived.push(data) },
});
const client = await createClientChannel(
link.client,
exportPublicKey(daemonKeyPair.publicKey),
{
onopen: () => opened.resolve(),
},
);
const daemon = await daemonPromise;
await opened.promise;
expect(daemon.securityMode()).toBe("legacy");
const hello = (link.client.send as ReturnType<typeof vi.fn>).mock.calls.find(
([data]) => typeof data === "string" && data.includes('"e2ee_hello"'),
)?.[0] as string;
link.daemon.onmessage?.({ data: hello, isBinary: false });
await settle();
expect(link.daemon.close).not.toHaveBeenCalled();
await client.send("legacy still works");
await settle();
expect(daemonReceived).toEqual(["legacy still works"]);
});
});
+767 -29
View File
@@ -3,23 +3,55 @@
* Endpoint security for relay-carried daemon sessions.
*
* This is the one entrypoint both endpoints use to secure a transport. It owns
* the handshake, record protection, retries, termination, and the negotiated
* security mode. Relay adapters own routing and physical I/O; daemon admission
* owns credentials and authorization.
* the handshake, record protection, counters, retries, termination, and the
* negotiated security mode. Relay adapters own routing and physical I/O;
* daemon admission owns credentials and authorization.
*
* "legacy" is the pre-existing ephemeral-static box construction in
* `encrypted-channel.ts`. It has no replay protection.
* Two modes exist during migration:
*
* - "protected": conforming Noise_NK_25519_ChaChaPoly_SHA256 bound to the
* daemon's static key, with authenticated record framing. Handshake
* messages travel byte-exact (base64) inside the existing `e2ee_hello` /
* `e2ee_ready` JSON envelopes under `protected`.
* - "legacy": the pre-existing ephemeral-static box construction in
* `encrypted-channel.ts`. It has no replay protection. Chosen only when the
* peer omits `protected` (an older peer) and policy allows it.
*
* A present but invalid `protected` field never falls back to legacy.
*
* See docs/relay-endpoint-security.md for the byte-level mapping.
*/
import {
createClientChannel,
createDaemonChannel,
EncryptedChannel,
base64EncryptedWireByteLength,
maxBase64EncryptedPlaintextByteLength,
type EncryptedChannelEvents,
type Transport,
type TransportMessage,
} from "./encrypted-channel.js";
import type { KeyPair } from "./crypto.js";
import {
deriveSharedKey,
exportPublicKey,
generateKeyPair,
importPublicKey,
type KeyPair,
} from "./crypto.js";
import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";
import {
createNoiseInitiator,
createNoiseResponder,
NoiseProtocolError,
type CipherState,
type NoiseHandshake,
} from "./noise/index.js";
import {
decodeProtectedRecord,
encodeProtectedRecord,
protectedRecordByteLength,
} from "./protected-records.js";
export type SecurityMode = "legacy";
export type SecurityMode = "legacy" | "protected";
export type SecureChannelEvents = EncryptedChannelEvents;
@@ -36,49 +68,755 @@ export interface SecureChannel {
export interface ClientSecureChannelOptions {
daemonPublicKeyB64: string;
/** Lowest mode this client accepts. "protected" refuses a daemon that only offers legacy. */
minimumMode?: SecurityMode;
events?: SecureChannelEvents;
}
export interface DaemonSecureChannelOptions {
daemonKeyPair: KeyPair;
/**
* Lowest mode this daemon admits. "protected" refuses a client that only
* offers legacy. A function is evaluated when the hello arrives so a policy
* change applies to every admission from that moment on.
*/
minimumMode?: SecurityMode | (() => SecurityMode);
events?: SecureChannelEvents;
}
/** Thrown by {@link openDaemonSecureChannel} when the handshake is refused; carries the close to send. */
export class SecureChannelRejectedError extends Error {
constructor(
message: string,
readonly closeCode: number,
readonly closeReason: string,
) {
super(message);
this.name = "SecureChannelRejectedError";
}
}
export const PROTECTED_MODE_REQUIRED_CLOSE_CODE = 1008;
export const PROTECTED_MODE_REQUIRED_CLOSE_REASON = "protected endpoint security required";
/** Client-side close when a protected handshake fails. Browser-safe (4000-4999). */
export const PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE = 4002;
const RECORD_FAILURE_CLOSE_CODE = 1011;
const REHANDSHAKE_REJECTION_CODE = 1008;
const REHANDSHAKE_MISMATCH_CLOSE_REASON = "E2EE re-handshake transcript mismatch";
const NOISE_PROLOGUE = new TextEncoder().encode("paseo-relay-e2ee/1");
const PROTECTED_ENVELOPE_VERSION = 1;
const PARAMS_VERSION = 0x01;
const PARAM_FLAG_BINARY_CIPHERTEXT = 0x01;
const PARAM_FLAGS_KNOWN = PARAM_FLAG_BINARY_CIPHERTEXT;
const HANDSHAKE_RETRY_MS = 1000;
const MAX_PENDING_SENDS = 200;
interface ProtectedEnvelope {
v: number;
message: string;
}
interface HelloEnvelope {
type: "e2ee_hello";
key: string;
capabilities?: { binaryCiphertext?: boolean };
protected?: ProtectedEnvelope;
}
interface ReadyEnvelope {
type: "e2ee_ready";
capabilities?: { binaryCiphertext?: boolean };
protected?: ProtectedEnvelope;
}
interface NegotiatedParams {
binaryCiphertext: boolean;
}
// ---------------------------------------------------------------------------
// Client
// ---------------------------------------------------------------------------
/**
* Secures a transport as the initiator. Resolves once the handshake has been
* started; `events.onopen` fires when the channel is usable.
* Secures a transport as the initiator. Resolves once the hello has been sent;
* `events.onopen` fires when the channel is usable. Sends queued before open
* are flushed in order after the handshake, capped at {@link MAX_PENDING_SENDS}.
*/
export async function openClientSecureChannel(
transport: Transport,
options: ClientSecureChannelOptions,
): Promise<SecureChannel> {
const channel = await createClientChannel(transport, options.daemonPublicKeyB64, options.events);
return withSecurityMode(channel, "legacy");
const events = options.events ?? {};
const minimumMode = options.minimumMode ?? "legacy";
const daemonPublicKey = importPublicKey(options.daemonPublicKeyB64);
// Separate ephemeral keys per protocol: the legacy box key is routing and
// compatibility input only; the Noise ephemeral lives inside the handshake.
const legacyKeyPair = generateKeyPair();
const legacySharedKey = deriveSharedKey(legacyKeyPair.secretKey, daemonPublicKey);
const noise = createNoiseInitiator({ remoteStatic: daemonPublicKey, prologue: NOISE_PROLOGUE });
const offered: NegotiatedParams = { binaryCiphertext: true };
const msg1 = noise.writeMessage(encodeParams(offered));
const hello: HelloEnvelope = {
type: "e2ee_hello",
key: exportPublicKey(legacyKeyPair.publicKey),
capabilities: { binaryCiphertext: true },
protected: { v: PROTECTED_ENVELOPE_VERSION, message: arrayBufferToBase64(toArrayBuffer(msg1)) },
};
const helloText = JSON.stringify(hello);
const channel = new ClientSecureChannel(transport, events, {
minimumMode,
noise,
offered,
legacySharedKey,
});
let retry: ReturnType<typeof setInterval> | null = null;
const emitSendError = (error: unknown) => {
events.onerror?.(error instanceof Error ? error : new Error(String(error)));
};
const sendHello = () => {
try {
const result = transport.send(helloText);
if (result) void result.catch(emitSendError);
} catch (error) {
// Daemon restarts move the socket through CLOSING/CLOSED; report, never throw from timers.
emitSendError(error);
}
};
const clearRetry = () => {
if (retry) {
clearInterval(retry);
retry = null;
}
};
channel.onTransitionToOpen(clearRetry);
channel.onClose(clearRetry);
sendHello();
retry = setInterval(() => {
if (channel.isOpen()) {
clearRetry();
return;
}
sendHello();
}, HANDSHAKE_RETRY_MS);
unref(retry);
return channel;
}
interface ClientHandshakeState {
minimumMode: SecurityMode;
noise: NoiseHandshake;
offered: NegotiatedParams;
legacySharedKey: Uint8Array;
}
/**
* Secures a transport as the responder. Resolves once the peer's handshake has
* been accepted and the channel is open.
* Client channel: waits for the daemon's ready, selects the mode, then
* delegates records to the mode's implementation.
*/
class ClientSecureChannel implements SecureChannel {
private state: "handshaking" | "open" | "closed" = "handshaking";
private closeNotified = false;
private inner: InnerChannel | null = null;
private pendingSends: Array<string | ArrayBuffer> = [];
private readonly onOpenCallbacks: Array<() => void> = [];
private readonly onCloseCallbacks: Array<() => void> = [];
private handshake: ClientHandshakeState | null;
constructor(
private readonly transport: Transport,
private readonly events: SecureChannelEvents,
handshake: ClientHandshakeState,
) {
this.handshake = handshake;
Object.assign(transport, {
onmessage: (message: TransportMessage) => this.handleHandshakeMessage(message),
onclose: (code: number, reason: string) => this.handleTransportClose(code, reason),
onerror: (error: Error) => this.events.onerror?.(error),
});
}
private handleHandshakeMessage(message: TransportMessage): void {
if (this.state !== "handshaking" || !this.handshake) return;
let ready: ReadyEnvelope | null;
try {
if (message.isBinary) return;
const parsed: unknown = JSON.parse(decodeTransportText(message.data));
ready = isReadyEnvelope(parsed) ? parsed : null;
} catch {
return; // not handshake traffic; ignore while handshaking
}
if (!ready) return;
try {
if (ready.protected !== undefined) {
this.openProtected(ready.protected);
} else {
this.openLegacy(ready);
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.events.onerror?.(err);
this.fail(PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE, err.message);
}
}
private openProtected(envelope: ProtectedEnvelope): void {
const handshake = this.handshake!;
if (envelope.v !== PROTECTED_ENVELOPE_VERSION) {
throw new NoiseProtocolError("unsupported protected envelope version");
}
const msg2 = new Uint8Array(base64ToArrayBuffer(envelope.message));
const selected = decodeParams(handshake.noise.readMessage(msg2));
if (selected.binaryCiphertext && !handshake.offered.binaryCiphertext) {
throw new NoiseProtocolError("daemon selected an unoffered parameter");
}
const keys = handshake.noise.split();
this.becomeOpen(
new ProtectedChannel(this.transport, this.events, keys.send, keys.receive, selected, null),
);
}
private openLegacy(ready: ReadyEnvelope): void {
const handshake = this.handshake!;
if (handshake.minimumMode === "protected") {
throw new Error("daemon offered only legacy endpoint security");
}
const legacy = new EncryptedChannel(this.transport, handshake.legacySharedKey, this.events, {
binaryCiphertext: ready.capabilities?.binaryCiphertext === true,
});
legacy.setState("open");
this.becomeOpen(new LegacyInner(legacy));
}
private becomeOpen(inner: InnerChannel): void {
this.handshake = null;
this.inner = inner;
this.state = "open";
// The mode implementation now owns the transport callbacks; mirror its close.
inner.onClose(() => this.notifyClosed());
this.events.onopen?.();
for (const cb of this.onOpenCallbacks) cb();
const pending = this.pendingSends;
this.pendingSends = [];
void (async () => {
try {
for (const item of pending) await inner.send(item);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.events.onerror?.(err);
this.fail(RECORD_FAILURE_CLOSE_CODE, err.message);
}
})();
}
/** Transport closed while this wrapper still owned its callbacks (handshaking). */
private handleTransportClose(code: number, reason: string): void {
this.events.onclose?.(code, reason);
this.notifyClosed();
}
private notifyClosed(): void {
this.state = "closed";
this.handshake = null;
if (this.closeNotified) return;
this.closeNotified = true;
for (const cb of this.onCloseCallbacks) cb();
}
private fail(code: number, reason: string): void {
if (this.state === "closed") return;
this.state = "closed";
this.handshake = null;
try {
this.transport.close(code, reason);
} catch {
// ignore
}
}
async send(data: string | ArrayBuffer): Promise<void> {
if (this.state === "handshaking") {
if (this.pendingSends.length >= MAX_PENDING_SENDS) this.pendingSends.shift();
this.pendingSends.push(data);
return;
}
if (this.state !== "open" || !this.inner) throw new Error("Channel not open");
await this.inner.send(data);
}
close(code = 1000, reason = "Normal closure"): void {
if (this.inner) {
this.inner.close(code, reason);
} else {
this.transport.close(code, reason);
}
this.state = "closed";
this.handshake = null;
}
isOpen(): boolean {
return this.state === "open" && this.inner?.isOpen() === true;
}
securityMode(): SecurityMode {
if (!this.inner) throw new Error("security mode is unknown until the channel opens");
return this.inner.mode;
}
outboundWireByteLength(data: string | ArrayBuffer): number {
if (this.inner) return this.inner.outboundWireByteLength(data);
// Mode unknown while handshaking: report the larger of the two so callers stay within limits.
const plaintextBytes = typeof data === "string" ? utf8ByteLength(data) : data.byteLength;
return Math.max(
base64EncryptedWireByteLength(plaintextBytes),
4 * Math.ceil(protectedRecordByteLength(plaintextBytes) / 3),
);
}
onTransitionToOpen(cb: () => void): void {
this.onOpenCallbacks.push(cb);
}
onClose(cb: () => void): void {
this.onCloseCallbacks.push(cb);
}
}
// ---------------------------------------------------------------------------
// Daemon
// ---------------------------------------------------------------------------
/**
* Secures a transport as the responder. Resolves once the peer's hello has
* been accepted and the ready sent. Rejects with
* {@link SecureChannelRejectedError} when policy refuses the peer, and with a
* plain error for malformed or failed handshakes; the caller closes the socket.
*/
export async function openDaemonSecureChannel(
transport: Transport,
options: DaemonSecureChannelOptions,
): Promise<SecureChannel> {
const channel = await createDaemonChannel(transport, options.daemonKeyPair, options.events);
return withSecurityMode(channel, "legacy");
const events = options.events ?? {};
const minimumMode = (): SecurityMode =>
typeof options.minimumMode === "function"
? options.minimumMode()
: (options.minimumMode ?? "legacy");
return new Promise((resolve, reject) => {
const buffered: TransportMessage[] = [];
const handleHello = async (message: TransportMessage): Promise<void> => {
try {
if (message.isBinary) throw new Error("Invalid hello message (binary frame)");
const helloText = decodeTransportText(message.data);
let parsed: unknown;
try {
parsed = JSON.parse(helloText);
} catch {
throw new Error(`Invalid hello message: ${preview(helloText)}`);
}
if (!isHelloEnvelope(parsed)) throw new Error(`Invalid hello message: ${preview(helloText)}`);
// Buffer traffic that arrives while the ready is in flight so an early
// record is not mistaken for a second hello.
Object.assign(transport, { onmessage: (next: TransportMessage) => buffered.push(next) });
const channel =
parsed.protected !== undefined
? await acceptProtectedHello(transport, events, options.daemonKeyPair, parsed, helloText)
: await acceptLegacyHello(
transport,
events,
options.daemonKeyPair,
parsed,
minimumMode(),
);
events.onopen?.();
for (const next of buffered) {
if (isHandshakeEnvelopeText(next)) continue;
transport.onmessage?.(next);
}
resolve(channel);
} catch (error) {
reject(error);
}
};
Object.assign(transport, {
onmessage: handleHello,
onerror: (error: Error) => reject(error),
onclose: (code: number, reason: string) =>
reject(new Error(`Connection closed during handshake: ${code} ${reason}`)),
});
});
}
function withSecurityMode(
channel: Omit<SecureChannel, "securityMode">,
mode: SecurityMode,
): SecureChannel {
return {
send: (data) => channel.send(data),
close: (code, reason) => channel.close(code, reason),
isOpen: () => channel.isOpen(),
securityMode: () => mode,
outboundWireByteLength: (data) => channel.outboundWireByteLength(data),
onTransitionToOpen: (cb) => channel.onTransitionToOpen(cb),
onClose: (cb) => channel.onClose(cb),
async function acceptProtectedHello(
transport: Transport,
events: SecureChannelEvents,
daemonKeyPair: KeyPair,
hello: HelloEnvelope,
helloText: string,
): Promise<SecureChannel> {
const envelope = hello.protected!;
if (envelope.v !== PROTECTED_ENVELOPE_VERSION) {
throw new NoiseProtocolError("unsupported protected envelope version");
}
const noise = createNoiseResponder({ localStatic: daemonKeyPair, prologue: NOISE_PROLOGUE });
const msg1 = new Uint8Array(base64ToArrayBuffer(envelope.message));
const offered = decodeParams(noise.readMessage(msg1));
const selected: NegotiatedParams = { binaryCiphertext: offered.binaryCiphertext };
const msg2 = noise.writeMessage(encodeParams(selected));
const ready: ReadyEnvelope = {
type: "e2ee_ready",
protected: { v: PROTECTED_ENVELOPE_VERSION, message: arrayBufferToBase64(toArrayBuffer(msg2)) },
};
const readyText = JSON.stringify(ready);
await transport.send(readyText);
const keys = noise.split();
return new ProtectedChannel(transport, events, keys.send, keys.receive, selected, {
helloText,
readyText,
});
}
async function acceptLegacyHello(
transport: Transport,
events: SecureChannelEvents,
daemonKeyPair: KeyPair,
hello: HelloEnvelope,
minimumMode: SecurityMode,
): Promise<SecureChannel> {
if (minimumMode === "protected") {
throw new SecureChannelRejectedError(
"client offered only legacy endpoint security",
PROTECTED_MODE_REQUIRED_CLOSE_CODE,
PROTECTED_MODE_REQUIRED_CLOSE_REASON,
);
}
const clientPublicKey = importPublicKey(hello.key);
const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey);
const binaryCiphertext = hello.capabilities?.binaryCiphertext === true;
await transport.send(
JSON.stringify({
type: "e2ee_ready",
...(binaryCiphertext ? { capabilities: { binaryCiphertext: true } } : {}),
} satisfies ReadyEnvelope),
);
const legacy = new EncryptedChannel(transport, sharedKey, events, {
daemonKeyPair,
binaryCiphertext,
});
legacy.setState("open");
return new LegacyInner(legacy);
}
// ---------------------------------------------------------------------------
// Mode implementations
// ---------------------------------------------------------------------------
interface InnerChannel extends SecureChannel {
readonly mode: SecurityMode;
}
class LegacyInner implements InnerChannel {
readonly mode = "legacy" as const;
constructor(readonly legacy: EncryptedChannel) {}
send(data: string | ArrayBuffer): Promise<void> {
return this.legacy.send(data);
}
close(code?: number, reason?: string): void {
this.legacy.close(code, reason);
}
isOpen(): boolean {
return this.legacy.isOpen();
}
securityMode(): SecurityMode {
return this.mode;
}
outboundWireByteLength(data: string | ArrayBuffer): number {
return this.legacy.outboundWireByteLength(data);
}
onTransitionToOpen(cb: () => void): void {
this.legacy.onTransitionToOpen(cb);
}
onClose(cb: () => void): void {
this.legacy.onClose(cb);
}
}
interface DaemonRetryTranscript {
helloText: string;
readyText: string;
}
/**
* Protected records over an open transport. Every inbound record is
* authenticated and framing-checked before delivery; any failure closes the
* channel and clears the keys.
*/
class ProtectedChannel implements InnerChannel {
readonly mode = "protected" as const;
private state: "open" | "closed" = "open";
private readonly onCloseCallbacks: Array<() => void> = [];
constructor(
private readonly transport: Transport,
private readonly events: SecureChannelEvents,
private readonly sender: CipherState,
private readonly receiver: CipherState,
private readonly params: NegotiatedParams,
/** Daemon side only: exact transcript used to answer identical retries. */
private readonly retry: DaemonRetryTranscript | null,
) {
Object.assign(transport, {
onmessage: (message: TransportMessage) => this.handleMessage(message),
onclose: (code: number, reason: string) => {
this.markClosed();
this.events.onclose?.(code, reason);
},
onerror: (error: Error) => this.events.onerror?.(error),
});
}
private async handleMessage(message: TransportMessage): Promise<void> {
if (this.state !== "open") return;
try {
const handshake = !message.isBinary ? parseHandshakeEnvelope(message.data) : null;
if (handshake) {
await this.handleHandshakeTraffic(handshake);
return;
}
const record = message.isBinary
? new Uint8Array(requireArrayBuffer(message.data))
: new Uint8Array(base64ToArrayBuffer(decodeTransportText(message.data)));
const frame = decodeProtectedRecord(record, this.receiver);
const plaintext =
frame.kind === "text"
? new TextDecoder("utf-8", { fatal: true }).decode(frame.data)
: toArrayBuffer(frame.data);
this.events.onmessage?.(plaintext);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.fail(RECORD_FAILURE_CLOSE_CODE, err.message);
}
}
private async handleHandshakeTraffic(
handshake: { kind: "hello"; text: string } | { kind: "ready" },
): Promise<void> {
if (handshake.kind === "ready") return; // client ignores duplicate ready after open
if (!this.retry) return; // a client never answers hellos
if (handshake.text === this.retry.helloText) {
await this.transport.send(this.retry.readyText);
return;
}
this.fail(REHANDSHAKE_REJECTION_CODE, REHANDSHAKE_MISMATCH_CLOSE_REASON);
}
async send(data: string | ArrayBuffer): Promise<void> {
if (this.state !== "open") throw new Error("Channel not open");
const kind = typeof data === "string" ? "text" : "binary";
const frame = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
let wire: Uint8Array;
try {
// Synchronous up to here: the nonce is reserved before any await.
wire = encodeProtectedRecord(kind, frame, this.sender);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.fail(RECORD_FAILURE_CLOSE_CODE, err.message);
throw err;
}
if (this.params.binaryCiphertext && kind === "binary") {
await this.transport.send(toArrayBuffer(wire));
return;
}
await this.transport.send(arrayBufferToBase64(toArrayBuffer(wire)));
}
outboundWireByteLength(data: string | ArrayBuffer): number {
const plaintextBytes = typeof data === "string" ? utf8ByteLength(data) : data.byteLength;
const wireBytes = protectedRecordByteLength(plaintextBytes);
if (this.params.binaryCiphertext && typeof data !== "string") return wireBytes;
return 4 * Math.ceil(wireBytes / 3);
}
close(code = 1000, reason = "Normal closure"): void {
this.markClosed();
this.transport.close(code, reason);
}
private fail(code: number, reason: string): void {
if (this.state !== "open") return;
this.markClosed();
try {
this.transport.close(code, reason);
} catch {
// ignore
}
}
private markClosed(): void {
if (this.state === "closed") return;
this.state = "closed";
this.sender.clear();
this.receiver.clear();
for (const cb of this.onCloseCallbacks) cb();
}
isOpen(): boolean {
return this.state === "open";
}
securityMode(): SecurityMode {
return this.mode;
}
onTransitionToOpen(cb: () => void): void {
cb();
}
onClose(cb: () => void): void {
this.onCloseCallbacks.push(cb);
}
}
/**
* Largest plaintext that fits in `wireBytes` as a base64 text frame in either
* mode. Callers size payloads before the mode is known, so take the tighter bound.
*/
export function maxSecuredPlaintextByteLength(wireBytes: number): number {
const legacy = maxBase64EncryptedPlaintextByteLength(wireBytes);
const base64Capacity = Math.floor(wireBytes / 4) * 3;
let protectedBound = base64Capacity;
while (protectedBound > 0 && protectedRecordByteLength(protectedBound) > base64Capacity) {
protectedBound = base64Capacity - (protectedRecordByteLength(protectedBound) - protectedBound);
}
return Math.max(0, Math.min(legacy, protectedBound));
}
// ---------------------------------------------------------------------------
// Envelope parsing and parameter encoding
// ---------------------------------------------------------------------------
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isCapabilities(value: unknown): value is { binaryCiphertext?: boolean } {
return (
value === undefined ||
(isRecord(value) &&
(value.binaryCiphertext === undefined || typeof value.binaryCiphertext === "boolean"))
);
}
function isProtectedEnvelope(value: unknown): value is ProtectedEnvelope {
return (
isRecord(value) &&
typeof value.v === "number" &&
typeof value.message === "string" &&
value.message.length > 0
);
}
function isHelloEnvelope(value: unknown): value is HelloEnvelope {
return (
isRecord(value) &&
value.type === "e2ee_hello" &&
typeof value.key === "string" &&
value.key.trim().length > 0 &&
isCapabilities(value.capabilities) &&
(value.protected === undefined || isProtectedEnvelope(value.protected))
);
}
function isReadyEnvelope(value: unknown): value is ReadyEnvelope {
return (
isRecord(value) &&
value.type === "e2ee_ready" &&
isCapabilities(value.capabilities) &&
(value.protected === undefined || isProtectedEnvelope(value.protected))
);
}
/** Classifies plaintext handshake traffic seen on an open channel; null for anything else. */
function parseHandshakeEnvelope(
data: string | ArrayBuffer,
): { kind: "hello"; text: string } | { kind: "ready" } | null {
let text: string;
try {
text = decodeTransportText(data);
} catch {
return null;
}
if (!text.trim().startsWith("{")) return null;
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error("Received plaintext frame on encrypted channel");
}
if (isRecord(parsed) && parsed.type === "e2ee_hello") return { kind: "hello", text };
if (isRecord(parsed) && parsed.type === "e2ee_ready") return { kind: "ready" };
// Any other JSON is plaintext app traffic: the peer is not encrypting.
throw new Error("Received plaintext frame on encrypted channel");
}
function isHandshakeEnvelopeText(message: TransportMessage): boolean {
if (message.isBinary) return false;
try {
const parsed: unknown = JSON.parse(decodeTransportText(message.data));
return isRecord(parsed) && (parsed.type === "e2ee_hello" || parsed.type === "e2ee_ready");
} catch {
return false;
}
}
function encodeParams(params: NegotiatedParams): Uint8Array {
return new Uint8Array([
PARAMS_VERSION,
params.binaryCiphertext ? PARAM_FLAG_BINARY_CIPHERTEXT : 0,
]);
}
function decodeParams(payload: Uint8Array): NegotiatedParams {
if (payload.byteLength !== 2) throw new NoiseProtocolError("invalid parameter payload");
if (payload[0] !== PARAMS_VERSION) throw new NoiseProtocolError("unsupported parameter version");
if ((payload[1] & ~PARAM_FLAGS_KNOWN) !== 0) throw new NoiseProtocolError("reserved parameter flag set");
return { binaryCiphertext: (payload[1] & PARAM_FLAG_BINARY_CIPHERTEXT) !== 0 };
}
function preview(text: string): string {
const compact = text.replace(/\s+/g, " ").trim();
return JSON.stringify(compact.length > 160 ? `${compact.slice(0, 157)}...` : compact);
}
function decodeTransportText(data: string | ArrayBuffer): string {
return typeof data === "string" ? data : new TextDecoder().decode(data);
}
function requireArrayBuffer(data: string | ArrayBuffer): ArrayBuffer {
if (data instanceof ArrayBuffer) return data;
throw new Error("Binary WebSocket frame did not contain bytes");
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
}
function utf8ByteLength(text: string): number {
return new TextEncoder().encode(text).byteLength;
}
function unref(timer: unknown): void {
if (typeof timer === "object" && timer !== null && "unref" in timer) {
(timer as { unref: () => void }).unref();
}
}
+21 -5
View File
@@ -424,6 +424,7 @@ export interface PaseoDaemonConfig {
agentStoragePath: string;
relayEnabled?: boolean;
relayEnabledMutable?: boolean;
relayRequireProtectedSecurity?: boolean;
relayEndpoint?: string;
relayPublicEndpoint?: string;
relayUseTls?: boolean;
@@ -539,11 +540,18 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri
return config.trustedProxies ?? ["loopback"];
}
function createInitialRelayConfig(config: PaseoDaemonConfig): MutableDaemonConfig["relay"] {
return {
enabled: config.relayEnabled ?? true,
requireProtectedSecurity: config.relayRequireProtectedSecurity ?? false,
};
}
function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig {
const providers = config.providerOverrides ?? {};
const initialConfig: MutableDaemonConfig = {
relay: { enabled: config.relayEnabled ?? true },
relay: createInitialRelayConfig(config),
mcp: {
enabled: config.mcpEnabled ?? true,
injectIntoAgents: config.mcpInjectIntoAgents ?? true,
@@ -1741,14 +1749,17 @@ export async function createPaseoDaemon(
return appBaseUrl;
},
desktopManaged: config.desktopManaged === true,
getRelayConfig: () =>
relayRuntime?.getConfig() ?? {
getRelayConfig: () => ({
...(relayRuntime?.getConfig() ?? {
enabled: daemonConfigStore.get().relay?.enabled ?? relayEnabled,
endpoint: relayEndpoint,
publicEndpoint: relayPublicEndpoint,
useTls: relayUseTls,
publicUseTls: relayPublicUseTls,
},
}),
requireProtectedSecurity:
daemonConfigStore.get().relay?.requireProtectedSecurity === true,
}),
},
serviceProxyPublicBaseUrl,
browserToolsBroker,
@@ -1775,7 +1786,12 @@ export async function createPaseoDaemon(
attachSocket: (ws, metadata) =>
admitSecuredRelaySocket(
ws,
{ daemonKeyPair: daemonKeyPair.keyPair, logger: relaySecurityLogger },
{
daemonKeyPair: daemonKeyPair.keyPair,
logger: relaySecurityLogger,
requireProtectedMode: () =>
daemonConfigStore.get().relay?.requireProtectedSecurity === true,
},
async (secured) => {
if (!wsServer) throw new Error("WebSocket server is not ready");
await wsServer.attachExternalSocket(secured.socket, {
+19 -8
View File
@@ -275,6 +275,7 @@ interface ResolveRelayInput {
interface ResolvedRelay {
enabled: boolean;
enabledMutable: boolean;
requireProtectedSecurity: boolean;
endpoint: string;
publicEndpoint: string;
useTls: boolean;
@@ -297,6 +298,21 @@ function resolveTlsFromEnv(
return persistedValue ?? fallback;
}
function resolveRelayEndpoints(input: ResolveRelayInput): {
endpoint: string;
publicEndpoint: string;
} {
const endpoint =
input.env.PASEO_RELAY_ENDPOINT ??
input.persisted.daemon?.relay?.endpoint ??
DEFAULT_RELAY_ENDPOINT;
const publicEndpoint =
input.env.PASEO_RELAY_PUBLIC_ENDPOINT ??
input.persisted.daemon?.relay?.publicEndpoint ??
endpoint;
return { endpoint, publicEndpoint };
}
function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
const environmentEnabled = parseBooleanEnv(input.env.PASEO_RELAY_ENABLED);
// COMPAT(relayOptInDefault): daemons whose startup config omitted this field
@@ -306,14 +322,7 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
environmentEnabled ??
input.persisted.daemon?.relay?.enabled ??
input.enabledFallback;
const endpoint =
input.env.PASEO_RELAY_ENDPOINT ??
input.persisted.daemon?.relay?.endpoint ??
DEFAULT_RELAY_ENDPOINT;
const publicEndpoint =
input.env.PASEO_RELAY_PUBLIC_ENDPOINT ??
input.persisted.daemon?.relay?.publicEndpoint ??
endpoint;
const { endpoint, publicEndpoint } = resolveRelayEndpoints(input);
const useTls =
input.cliRelayUseTls ??
resolveTlsFromEnv(
@@ -329,6 +338,7 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
return {
enabled,
enabledMutable: input.cliRelayEnabled === undefined && environmentEnabled === undefined,
requireProtectedSecurity: input.persisted.daemon?.relay?.requireProtectedSecurity ?? false,
endpoint,
publicEndpoint,
useTls,
@@ -635,6 +645,7 @@ export function resolveConfigFromPersisted(
agentClients: {},
relayEnabled: relay.enabled,
relayEnabledMutable: relay.enabledMutable,
relayRequireProtectedSecurity: relay.requireProtectedSecurity,
relayEndpoint: relay.endpoint,
relayPublicEndpoint: relay.publicEndpoint,
relayUseTls: relay.useTls,
@@ -31,12 +31,14 @@ export async function createConnectionOfferV2(args: {
serverId: string;
daemonPublicKeyB64: string;
relay: { endpoint: string; useTls?: boolean };
minimumSecurityMode?: "protected";
}): Promise<ConnectionOffer> {
return ConnectionOfferV2Schema.parse({
v: 2,
serverId: args.serverId,
daemonPublicKeyB64: args.daemonPublicKeyB64,
relay: args.relay,
...(args.minimumSecurityMode ? { minimumSecurityMode: args.minimumSecurityMode } : {}),
});
}
@@ -120,6 +120,35 @@ describe("DaemonConfigStore", () => {
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true);
});
test("patch persists the relay protected security requirement independently of relay enablement", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(
paseoHome,
{
relay: { enabled: true },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
},
undefined,
// A launch override owns relay.enabled; the security requirement is still editable.
{ relayEnabledMutable: false },
);
const changes: unknown[] = [];
store.onFieldChange("relay.requireProtectedSecurity", (value) => changes.push(value));
store.patch({ relay: { requireProtectedSecurity: true } });
expect(changes).toEqual([true]);
expect(store.get().relay).toEqual({ enabled: true, requireProtectedSecurity: true });
expect(loadPersistedConfig(paseoHome).daemon?.relay?.requireProtectedSecurity).toBe(true);
});
test("patch round-trips agent profiles through the strictly-parsed persisted config", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -17,7 +17,7 @@ type MutableDaemonConfigPatch = import("@getpaseo/protocol/messages").MutableDae
type ProviderOverride = import("./agent/provider-launch-config.js").ProviderOverride;
interface SupportedMutableConfigPatch {
relay?: { enabled?: boolean };
relay?: { enabled?: boolean; requireProtectedSecurity?: boolean };
mcp?: { injectIntoAgents?: boolean };
browserTools?: { enabled?: boolean };
providers?: MutableDaemonConfig["providers"];
@@ -170,6 +170,7 @@ function isEqualValue(a: unknown, b: unknown): boolean {
const RELOADABLE_PATHS = [
"daemon.relay.enabled",
"daemon.relay.requireProtectedSecurity",
"daemon.mcp.enabled",
"daemon.mcp.injectIntoAgents",
"daemon.browserTools.enabled",
@@ -193,6 +194,7 @@ const RELOADABLE_PATHS = [
const PERSISTED_TO_MUTABLE_PATH = new Map<string, string>([
["daemon.relay.enabled", "relay.enabled"],
["daemon.relay.requireProtectedSecurity", "relay.requireProtectedSecurity"],
["daemon.mcp.enabled", "mcp.enabled"],
["daemon.mcp.injectIntoAgents", "mcp.injectIntoAgents"],
["daemon.browserTools.enabled", "browserTools.enabled"],
@@ -249,9 +251,21 @@ function compactOwnedPaths(paths: readonly string[], owners: readonly string[]):
return Array.from(compacted).sort();
}
function pickRelayPatch(
relay: MutableDaemonConfigPatch["relay"],
): Pick<SupportedMutableConfigPatch, "relay"> {
const picked: NonNullable<SupportedMutableConfigPatch["relay"]> = {
...(relay?.enabled !== undefined ? { enabled: relay.enabled } : {}),
...(relay?.requireProtectedSecurity !== undefined
? { requireProtectedSecurity: relay.requireProtectedSecurity }
: {}),
};
return Object.keys(picked).length > 0 ? { relay: picked } : {};
}
function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMutableConfigPatch {
return {
...(patch.relay?.enabled !== undefined ? { relay: { enabled: patch.relay.enabled } } : {}),
...pickRelayPatch(patch.relay),
...(patch.mcp?.injectIntoAgents !== undefined
? { mcp: { injectIntoAgents: patch.mcp.injectIntoAgents } }
: {}),
@@ -646,6 +660,9 @@ function mergeMutableDaemonPatch(
if (persistRelayEnabled && patch.relay?.enabled !== undefined) {
next.relay = { ...next.relay, enabled: patch.relay.enabled };
}
if (patch.relay?.requireProtectedSecurity !== undefined) {
next.relay = { ...next.relay, requireProtectedSecurity: patch.relay.requireProtectedSecurity };
}
if (patch.mcp?.injectIntoAgents !== undefined) {
next.mcp = { ...next.mcp, injectIntoAgents: patch.mcp.injectIntoAgents };
}
@@ -26,7 +26,12 @@ import os from "node:os";
import { generateLocalPairingOffer } from "../pairing-offer.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { FILE_EXPLORER_STREAM_CHUNK_BYTES } from "../file-explorer/service.js";
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
import {
createClientChannel,
openClientSecureChannel,
type SecureChannel,
type Transport,
} from "@getpaseo/relay/e2ee";
import {
deriveSharedKey,
decrypt,
@@ -59,6 +64,7 @@ async function getPairingOfferUrl(args: {
relayEnabled?: boolean;
relayEndpoint?: string;
relayPublicEndpoint?: string;
relayRequireProtectedSecurity?: boolean;
appBaseUrl?: string;
}): Promise<string> {
const pairing = await generateLocalPairingOffer({
@@ -66,6 +72,7 @@ async function getPairingOfferUrl(args: {
relayEnabled: args.relayEnabled,
relayEndpoint: args.relayEndpoint,
relayPublicEndpoint: args.relayPublicEndpoint,
relayRequireProtectedSecurity: args.relayRequireProtectedSecurity,
appBaseUrl: args.appBaseUrl,
includeQr: false,
});
@@ -90,6 +97,113 @@ function decodeOfferFromFragmentUrl(url: string): {
return { serverId: offer.serverId, daemonPublicKeyB64: offer.daemonPublicKeyB64 };
}
/**
* An updated relay client: the shared secure channel over a raw relay socket.
* Resolves once the daemon answers the app hello with server_info.
*/
async function openUpdatedRelayClient(args: {
relayPort: number;
serverId: string;
daemonPublicKeyB64: string;
clientId: string;
minimumMode?: "protected";
}): Promise<{
channel: SecureChannel;
serverInfo: { features?: Record<string, unknown> };
messages: unknown[];
waitFor: (predicate: (message: unknown) => boolean) => Promise<unknown>;
close: () => void;
}> {
const ws = new WebSocket(
buildRelayWebSocketUrl({
endpoint: `127.0.0.1:${args.relayPort}`,
useTls: false,
serverId: args.serverId,
role: "client",
}),
);
const transport: Transport = {
send: (data) => ws.send(data),
close: (code?: number, reason?: string) => ws.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
ws.on("message", (data, isBinary) => {
const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as Buffer);
transport.onmessage?.({
data: isBinary
? buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
: buffer.toString("utf8"),
isBinary,
});
});
ws.on("close", (code, reason) => transport.onclose?.(code, reason.toString()));
ws.on("error", (error) => transport.onerror?.(error));
await once(ws, "open");
const messages: unknown[] = [];
const waiters: Array<{
predicate: (message: unknown) => boolean;
resolve: (m: unknown) => void;
}> = [];
const deliver = (message: unknown) => {
messages.push(message);
for (const waiter of waiters.slice()) {
if (!waiter.predicate(message)) continue;
waiters.splice(waiters.indexOf(waiter), 1);
waiter.resolve(message);
}
};
const waitFor = (predicate: (message: unknown) => boolean) =>
withTimeout(
new Promise<unknown>((resolve) => {
const existing = messages.find(predicate);
if (existing !== undefined) resolve(existing);
else waiters.push({ predicate, resolve });
}),
20_000,
"relay client message",
);
const isServerInfo = (message: unknown) => {
const parsed = WSOutboundMessageSchema.safeParse(message);
return (
parsed.success &&
parsed.data.type === "session" &&
parsed.data.message.type === "status" &&
parsed.data.message.payload?.status === "server_info"
);
};
const channel = await openClientSecureChannel(transport, {
daemonPublicKeyB64: args.daemonPublicKeyB64,
...(args.minimumMode ? { minimumMode: args.minimumMode } : {}),
events: {
onmessage: (data) => {
if (typeof data === "string") deliver(JSON.parse(data));
},
},
});
await channel.send(
JSON.stringify({
type: "hello",
clientId: args.clientId,
clientType: "cli",
protocolVersion: 1,
}),
);
const serverInfoMessage = (await waitFor(isServerInfo)) as {
message: { payload: { features?: Record<string, unknown> } };
};
return {
channel,
serverInfo: serverInfoMessage.message.payload,
messages,
waitFor,
close: () => ws.close(),
};
}
function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
@@ -204,9 +318,18 @@ async function waitForCapturedLog(
let relayPort: number;
let relayProcess: ChildProcess | null = null;
let relayStdoutLines: string[] = [];
// PASEO_RELAY_E2E_PORT points at an already-running relay (for example the
// Elixir relay) so the same daemon/client exchanges run against both.
const externalRelayPort = Number(process.env.PASEO_RELAY_E2E_PORT ?? "");
const usesExternalRelay = Number.isInteger(externalRelayPort) && externalRelayPort > 0;
const startRelay = async (options: { useLocalRelay?: boolean; port?: number } = {}) => {
relayStdoutLines = [];
if (usesExternalRelay) {
relayPort = externalRelayPort;
await waitForRelayWebSocketReady(relayPort, 60000);
return;
}
relayPort = options.port ?? (await getAvailablePort());
const relayDir = path.resolve(process.cwd(), "../relay");
const relayArgs = [
@@ -273,144 +396,149 @@ async function waitForCapturedLog(
await withTimeout(exited, 10000, "Local relay did not stop");
};
test("connected hosts route RPC and binary traffic through local E2EE relay and recover scopes", async () => {
let a: Awaited<ReturnType<typeof createTestPaseoDaemon>> | undefined;
let b: Awaited<ReturnType<typeof createTestPaseoDaemon>> | undefined;
let origin: ReturnType<typeof createHostClient> | undefined;
let api: PaseoApi | undefined;
let remote: PaseoApi | undefined;
let sibling: PaseoApi | undefined;
let terminalRoute: Awaited<ReturnType<typeof openConnectedHostTerminal>> | undefined;
let wire: Awaited<ReturnType<typeof relayWire>> | undefined;
try {
await startRelay({ useLocalRelay: true });
const port = relayPort;
b = await createTestPaseoDaemon({
relayEnabled: true,
relayEndpoint: `127.0.0.1:${port}`,
relayUseTls: false,
relayPublicUseTls: false,
auth: { password: hashSync("relay-password", 4) },
});
a = await createTestPaseoDaemon();
origin = createHostClient(`127.0.0.1:${a.port}`, { clientId: crypto.randomUUID() });
await origin.connect();
api = createPaseoApi(origin);
const offerUrl = await getPairingOfferUrl({
paseoHome: b.paseoHome,
relayEnabled: true,
relayEndpoint: b.config.relayEndpoint,
});
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
wire = await relayWire(port);
const connection = new URL(`relay://127.0.0.1:${wire.port}/${serverId}`);
connection.searchParams.set("key", daemonPublicKeyB64);
connection.searchParams.set("password", "wrong-password");
const saved = await api.hosts.add({ connection: connection.toString() });
await expect
.poll(async () => (await api!.hosts.list()).hosts[0]?.error?.code, { timeout: 20000 })
.toBe("authentication_failed");
expect(JSON.stringify(await api.hosts.list())).not.toMatch(/wrong-password|relay-password/);
connection.searchParams.set("password", "relay-password");
await api.hosts.update(saved.registrationId, { connection: connection.toString() });
await expect
.poll(async () => (await api!.hosts.list()).hosts[0]?.status, { timeout: 20000 })
.toBe("online");
const admittedConnections = wire.connections();
expect(wire.active()).toBe(1);
remote = await api.hosts.client(serverId);
sibling = await api.hosts.client(serverId);
expect(wire.connections()).toBe(admittedConnections);
expect(wire.active()).toBe(1);
expect((await remote.workspaces.list()).entries).toEqual([]);
const observed = await remote.workspaces.list({ subscribe: {} });
let snapshots = 0;
observed.subscription.subscribe({
snapshot: () => {
snapshots++;
},
update() {},
});
const workspace = await remote.workspaces.create({
source: { kind: "directory", path: b.staticDir },
title: "Relayed workspace",
});
const terminal = await workspace.terminals.create({
command: process.execPath,
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
});
terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id);
expect(wire.connections()).toBe(admittedConnections);
terminalRoute.write("ENCRYPTED_BINARY_BEFORE\n");
await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_BEFORE");
terminalRoute.close();
terminalRoute = undefined;
const before = snapshots;
await stopRelay();
await expect
.poll(
async () => {
try {
await remote!.workspaces.list();
return "online";
} catch (error) {
return (error as { code: string }).code;
}
},
{ timeout: 15000 },
)
.toBe("DAEMON_CONNECTION_LOST");
await startRelay({ useLocalRelay: true, port });
await expect
.poll(
async () => {
try {
return (await remote!.workspaces.list()).entries.length;
} catch {
return -1;
}
},
{ timeout: 30000 },
)
.toBe(1);
await expect.poll(() => snapshots, { timeout: 15000 }).toBeGreaterThan(before);
expect(wire.active()).toBe(1);
terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id);
terminalRoute.write("ENCRYPTED_BINARY_AFTER\n");
await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_AFTER");
terminalRoute.close();
terminalRoute = undefined;
await remote.dispose();
remote = undefined;
expect((await sibling.workspaces.list()).entries).toHaveLength(1);
await sibling.terminals.ref(terminal.id).kill();
await api.dispose();
await expect(sibling.workspaces.list()).rejects.toThrow();
const direct = createHostClient(`127.0.0.1:${b.port}`, {
clientId: crypto.randomUUID(),
password: "relay-password",
reconnect: { enabled: false },
});
// Needs to stop and restart the relay, which only the spawned wrangler relay allows.
test.skipIf(usesExternalRelay)(
"connected hosts route RPC and binary traffic through local E2EE relay and recover scopes",
async () => {
let a: Awaited<ReturnType<typeof createTestPaseoDaemon>> | undefined;
let b: Awaited<ReturnType<typeof createTestPaseoDaemon>> | undefined;
let origin: ReturnType<typeof createHostClient> | undefined;
let api: PaseoApi | undefined;
let remote: PaseoApi | undefined;
let sibling: PaseoApi | undefined;
let terminalRoute: Awaited<ReturnType<typeof openConnectedHostTerminal>> | undefined;
let wire: Awaited<ReturnType<typeof relayWire>> | undefined;
try {
await direct.connect();
const directApi = createPaseoApi(direct);
expect((await directApi.workspaces.list()).entries).toHaveLength(1);
await directApi.dispose();
await startRelay({ useLocalRelay: true });
const port = relayPort;
b = await createTestPaseoDaemon({
relayEnabled: true,
relayEndpoint: `127.0.0.1:${port}`,
relayUseTls: false,
relayPublicUseTls: false,
auth: { password: hashSync("relay-password", 4) },
});
a = await createTestPaseoDaemon();
origin = createHostClient(`127.0.0.1:${a.port}`, { clientId: crypto.randomUUID() });
await origin.connect();
api = createPaseoApi(origin);
const offerUrl = await getPairingOfferUrl({
paseoHome: b.paseoHome,
relayEnabled: true,
relayEndpoint: b.config.relayEndpoint,
});
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
wire = await relayWire(port);
const connection = new URL(`relay://127.0.0.1:${wire.port}/${serverId}`);
connection.searchParams.set("key", daemonPublicKeyB64);
connection.searchParams.set("password", "wrong-password");
const saved = await api.hosts.add({ connection: connection.toString() });
await expect
.poll(async () => (await api!.hosts.list()).hosts[0]?.error?.code, { timeout: 20000 })
.toBe("authentication_failed");
expect(JSON.stringify(await api.hosts.list())).not.toMatch(/wrong-password|relay-password/);
connection.searchParams.set("password", "relay-password");
await api.hosts.update(saved.registrationId, { connection: connection.toString() });
await expect
.poll(async () => (await api!.hosts.list()).hosts[0]?.status, { timeout: 20000 })
.toBe("online");
const admittedConnections = wire.connections();
expect(wire.active()).toBe(1);
remote = await api.hosts.client(serverId);
sibling = await api.hosts.client(serverId);
expect(wire.connections()).toBe(admittedConnections);
expect(wire.active()).toBe(1);
expect((await remote.workspaces.list()).entries).toEqual([]);
const observed = await remote.workspaces.list({ subscribe: {} });
let snapshots = 0;
observed.subscription.subscribe({
snapshot: () => {
snapshots++;
},
update() {},
});
const workspace = await remote.workspaces.create({
source: { kind: "directory", path: b.staticDir },
title: "Relayed workspace",
});
const terminal = await workspace.terminals.create({
command: process.execPath,
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
});
terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id);
expect(wire.connections()).toBe(admittedConnections);
terminalRoute.write("ENCRYPTED_BINARY_BEFORE\n");
await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_BEFORE");
terminalRoute.close();
terminalRoute = undefined;
const before = snapshots;
await stopRelay();
await expect
.poll(
async () => {
try {
await remote!.workspaces.list();
return "online";
} catch (error) {
return (error as { code: string }).code;
}
},
{ timeout: 15000 },
)
.toBe("DAEMON_CONNECTION_LOST");
await startRelay({ useLocalRelay: true, port });
await expect
.poll(
async () => {
try {
return (await remote!.workspaces.list()).entries.length;
} catch {
return -1;
}
},
{ timeout: 30000 },
)
.toBe(1);
await expect.poll(() => snapshots, { timeout: 15000 }).toBeGreaterThan(before);
expect(wire.active()).toBe(1);
terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id);
terminalRoute.write("ENCRYPTED_BINARY_AFTER\n");
await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_AFTER");
terminalRoute.close();
terminalRoute = undefined;
await remote.dispose();
remote = undefined;
expect((await sibling.workspaces.list()).entries).toHaveLength(1);
await sibling.terminals.ref(terminal.id).kill();
await api.dispose();
await expect(sibling.workspaces.list()).rejects.toThrow();
const direct = createHostClient(`127.0.0.1:${b.port}`, {
clientId: crypto.randomUUID(),
password: "relay-password",
reconnect: { enabled: false },
});
try {
await direct.connect();
const directApi = createPaseoApi(direct);
expect((await directApi.workspaces.list()).entries).toHaveLength(1);
await directApi.dispose();
} finally {
await direct.close();
}
} finally {
await direct.close();
terminalRoute?.close();
await remote?.dispose();
await sibling?.dispose();
await api?.dispose();
await origin?.close();
await a?.close();
await b?.close();
await wire?.close();
await stopRelay();
}
} finally {
terminalRoute?.close();
await remote?.dispose();
await sibling?.dispose();
await api?.dispose();
await origin?.close();
await a?.close();
await b?.close();
await wire?.close();
await stopRelay();
}
}, 120000);
},
120000,
);
test("oversized attachments fail without disconnecting or resubmitting over the relay", async () => {
await startRelay({ useLocalRelay: true });
@@ -620,11 +748,11 @@ async function waitForCapturedLog(
}
}, 90000);
test("daemon closes a relay client that sends an unsupported handshake key", async () => {
test("updated client negotiates protected endpoint security through the relay", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
const { logger, lines } = createCapturingLogger();
await startRelay({ useLocalRelay: true });
await startRelay();
const daemon = await createTestPaseoDaemon({
listen: "127.0.0.1",
@@ -641,8 +769,76 @@ async function waitForCapturedLog(
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
appBaseUrl: daemon.config.appBaseUrl,
});
const { serverId } = decodeOfferFromFragmentUrl(offerUrl);
const ws = new WebSocket(
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
const client = await openUpdatedRelayClient({
relayPort,
serverId,
daemonPublicKeyB64,
clientId: "cid_relay_protected_client",
});
try {
expect(client.channel.securityMode()).toBe("protected");
expect(client.serverInfo.features?.relayProtectedSecurity).toBe(true);
await client.channel.send(JSON.stringify({ type: "ping" }));
const pong = await client.waitFor(
(message) => (message as { type?: string }).type === "pong",
);
expect(pong).toEqual({ type: "pong" });
await waitForCapturedLog(
lines,
(line) => line.includes('"securityMode":"protected"'),
5_000,
);
} finally {
client.close();
}
} catch (err) {
// eslint-disable-next-line no-console
console.error("daemon logs (tail):\n", lines.slice(-50).join(""));
throw err;
} finally {
await daemon.close();
await stopRelay();
}
}, 90000);
test("daemon requiring protected security rejects a legacy relay client and admits an updated one", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
const { logger, lines } = createCapturingLogger();
await startRelay();
const daemon = await createTestPaseoDaemon({
listen: "127.0.0.1",
logger,
relayEnabled: true,
relayEndpoint: `127.0.0.1:${relayPort}`,
relayRequireProtectedSecurity: true,
});
try {
const offerUrl = await getPairingOfferUrl({
paseoHome: daemon.paseoHome,
relayEnabled: daemon.config.relayEnabled,
relayEndpoint: daemon.config.relayEndpoint,
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
relayRequireProtectedSecurity: daemon.config.relayRequireProtectedSecurity,
appBaseUrl: daemon.config.appBaseUrl,
});
const offer = ConnectionOfferSchema.parse(
JSON.parse(
Buffer.from(
offerUrl.slice(offerUrl.indexOf("#offer=") + "#offer=".length),
"base64url",
).toString("utf8"),
),
);
expect(offer.minimumSecurityMode).toBe("protected");
const { serverId, daemonPublicKeyB64 } = offer;
// A client that only speaks the legacy construction is refused before admission.
const legacyWs = new WebSocket(
buildRelayWebSocketUrl({
endpoint: `127.0.0.1:${relayPort}`,
useTls: false,
@@ -650,92 +846,188 @@ async function waitForCapturedLog(
role: "client",
}),
);
let receivedServerInfo = false;
const isRelayDataConnectedLog = (line: string) => line.includes("relay_data_connected");
const outcome = await new Promise<
{ type: "closed"; code: number; reason: string } | { type: "errored"; error: Error }
>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("timed out waiting for daemon to reject unsupported handshake key"));
}, 20_000);
ws.on("open", async () => {
try {
await waitForCapturedLog(lines, isRelayDataConnectedLog);
ws.send(
JSON.stringify({
type: "e2ee_hello",
key: exportPublicKey(new Uint8Array(32)),
}),
);
ws.send(
encodeCiphertext(
encrypt(
UNSUPPORTED_PEER_TRANSPORT_KEY,
JSON.stringify({
type: "hello",
clientId: "cid_relay_unsupported_key",
clientType: "cli",
protocolVersion: 1,
}),
),
),
);
} catch (error) {
clearTimeout(timeout);
reject(error);
}
});
ws.on("message", (data) => {
try {
const parsed = WSOutboundMessageSchema.parse(
parseEncryptedJson(UNSUPPORTED_PEER_TRANSPORT_KEY, data.toString()),
);
if (
parsed.type === "session" &&
parsed.message.type === "status" &&
parsed.message.payload?.status === "server_info"
) {
receivedServerInfo = true;
clearTimeout(timeout);
reject(
new Error("daemon sent server_info after accepting an unsupported handshake key"),
);
}
} catch {
// Plaintext e2ee_ready cannot be decrypted and is irrelevant to this assertion.
}
});
ws.on("close", (code, reason) => {
clearTimeout(timeout);
resolve({ type: "closed", code, reason: reason.toString() });
});
ws.on("error", (error) => {
clearTimeout(timeout);
resolve({ type: "errored", error });
const legacyTransport: Transport = {
send: (data) => legacyWs.send(data),
close: (code?: number, reason?: string) => legacyWs.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
legacyWs.on("message", (data, isBinary) => {
const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as Buffer);
legacyTransport.onmessage?.({
data: isBinary
? buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
: buffer.toString("utf8"),
isBinary,
});
});
const legacyClosed = new Promise<{ code: number; reason: string }>((resolve) => {
legacyWs.on("close", (code, reason) => resolve({ code, reason: reason.toString() }));
});
await once(legacyWs, "open");
const legacyMessages: unknown[] = [];
void createClientChannel(legacyTransport, daemonPublicKeyB64, {
onmessage: (data) => legacyMessages.push(data),
}).catch(() => undefined);
const closed = await withTimeout(legacyClosed, 20_000, "legacy client close");
// The daemon closes its side with 1008; the relay reports its own close code to
// the client (the wrangler relay sends 1012 "Server disconnected"), so only the
// daemon log carries the policy reason.
expect(closed.code).not.toBe(1000);
expect(legacyMessages).toEqual([]);
await waitForCapturedLog(
lines,
(line) =>
line.includes("relay_e2ee_handshake_rejected") &&
line.includes("legacy endpoint security"),
);
expect(receivedServerInfo).toBe(false);
if (outcome.type === "closed") {
expect(outcome.code).toBeGreaterThan(0);
} else {
expect(outcome.error).toBeInstanceOf(Error);
// An updated client pinned by the offer is admitted in protected mode.
const client = await openUpdatedRelayClient({
relayPort,
serverId,
daemonPublicKeyB64,
clientId: "cid_relay_protected_required",
minimumMode: offer.minimumSecurityMode,
});
try {
expect(client.channel.securityMode()).toBe("protected");
expect(client.serverInfo.features?.relayProtectedSecurity).toBe(true);
} finally {
client.close();
}
const isRejectedHandshakeLog = (line: string) =>
line.includes("relay_e2ee_handshake_failed") && line.includes("Invalid peer public key");
await waitForCapturedLog(lines, isRejectedHandshakeLog);
expect(lines.some(isRejectedHandshakeLog)).toBe(true);
} catch (err) {
// eslint-disable-next-line no-console
console.error("daemon logs (tail):\n", lines.slice(-50).join(""));
throw err;
} finally {
await daemon.close();
await stopRelay();
}
}, 90000);
// The Elixir relay rejects the invalid key itself, so the daemon never sees this hello.
test.skipIf(usesExternalRelay)(
"daemon closes a relay client that sends an unsupported handshake key",
async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
const { logger, lines } = createCapturingLogger();
await startRelay({ useLocalRelay: true });
const daemon = await createTestPaseoDaemon({
listen: "127.0.0.1",
logger,
relayEnabled: true,
relayEndpoint: `127.0.0.1:${relayPort}`,
});
try {
const offerUrl = await getPairingOfferUrl({
paseoHome: daemon.paseoHome,
relayEnabled: daemon.config.relayEnabled,
relayEndpoint: daemon.config.relayEndpoint,
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
appBaseUrl: daemon.config.appBaseUrl,
});
const { serverId } = decodeOfferFromFragmentUrl(offerUrl);
const ws = new WebSocket(
buildRelayWebSocketUrl({
endpoint: `127.0.0.1:${relayPort}`,
useTls: false,
serverId,
role: "client",
}),
);
let receivedServerInfo = false;
const isRelayDataConnectedLog = (line: string) => line.includes("relay_data_connected");
const outcome = await new Promise<
{ type: "closed"; code: number; reason: string } | { type: "errored"; error: Error }
>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("timed out waiting for daemon to reject unsupported handshake key"));
}, 20_000);
ws.on("open", async () => {
try {
await waitForCapturedLog(lines, isRelayDataConnectedLog);
ws.send(
JSON.stringify({
type: "e2ee_hello",
key: exportPublicKey(new Uint8Array(32)),
}),
);
ws.send(
encodeCiphertext(
encrypt(
UNSUPPORTED_PEER_TRANSPORT_KEY,
JSON.stringify({
type: "hello",
clientId: "cid_relay_unsupported_key",
clientType: "cli",
protocolVersion: 1,
}),
),
),
);
} catch (error) {
clearTimeout(timeout);
reject(error);
}
});
ws.on("message", (data) => {
try {
const parsed = WSOutboundMessageSchema.parse(
parseEncryptedJson(UNSUPPORTED_PEER_TRANSPORT_KEY, data.toString()),
);
if (
parsed.type === "session" &&
parsed.message.type === "status" &&
parsed.message.payload?.status === "server_info"
) {
receivedServerInfo = true;
clearTimeout(timeout);
reject(
new Error("daemon sent server_info after accepting an unsupported handshake key"),
);
}
} catch {
// Plaintext e2ee_ready cannot be decrypted and is irrelevant to this assertion.
}
});
ws.on("close", (code, reason) => {
clearTimeout(timeout);
resolve({ type: "closed", code, reason: reason.toString() });
});
ws.on("error", (error) => {
clearTimeout(timeout);
resolve({ type: "errored", error });
});
});
expect(receivedServerInfo).toBe(false);
if (outcome.type === "closed") {
expect(outcome.code).toBeGreaterThan(0);
} else {
expect(outcome.error).toBeInstanceOf(Error);
}
const isRejectedHandshakeLog = (line: string) =>
line.includes("relay_e2ee_handshake_failed") && line.includes("Invalid peer public key");
await waitForCapturedLog(lines, isRejectedHandshakeLog);
expect(lines.some(isRejectedHandshakeLog)).toBe(true);
} finally {
await daemon.close();
await stopRelay();
}
},
90000,
);
test("daemon keeps relay socket open while idle (no handshake timeout loop)", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
@@ -18,6 +18,8 @@ export async function generateLocalPairingOffer(args: {
relayPublicEndpoint?: string;
relayUseTls?: boolean;
relayPublicUseTls?: boolean;
/** Stamp the offer so new clients require protected endpoint security from their first handshake. */
relayRequireProtectedSecurity?: boolean;
appBaseUrl?: string;
includeQr?: boolean;
logger?: Logger;
@@ -42,6 +44,9 @@ export async function generateLocalPairingOffer(args: {
serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: { endpoint: relayPublicEndpoint, useTls: relayPublicUseTls },
...(args.relayRequireProtectedSecurity === true
? { minimumSecurityMode: "protected" as const }
: {}),
});
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
@@ -278,6 +278,12 @@ export const PersistedConfigSchema = z
relay: z
.object({
enabled: z.boolean().optional(),
requireProtectedSecurity: z
.boolean()
.optional()
.describe(
"Admit only relay clients that negotiate protected endpoint security (replay protection). Defaults to false; older clients still connect while it is off.",
),
endpoint: z.string().optional(),
publicEndpoint: z.string().optional(),
useTls: z.boolean().optional(),
@@ -24,6 +24,7 @@ export interface DaemonRuntimeConfig {
publicEndpoint: string;
useTls: boolean;
publicUseTls: boolean;
requireProtectedSecurity?: boolean;
} | null;
}
@@ -224,6 +225,7 @@ export class DaemonSession {
relayPublicEndpoint: relay?.publicEndpoint,
relayUseTls: relay?.useTls,
relayPublicUseTls: relay?.publicUseTls,
relayRequireProtectedSecurity: relay?.requireProtectedSecurity ?? false,
appBaseUrl: this.daemonRuntimeConfig?.appBaseUrl,
includeQr: true,
logger: this.logger,
@@ -30,6 +30,7 @@ interface TestPaseoDaemonOptions {
relayEndpoint?: string;
relayUseTls?: boolean;
relayPublicUseTls?: boolean;
relayRequireProtectedSecurity?: boolean;
daemonStatusRpcCapability?: boolean;
relayConfigCapability?: boolean;
agentClients?: Partial<Record<AgentProvider, AgentClient>>;
@@ -200,6 +201,7 @@ async function prepareTestDaemonConfig(
relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443",
relayUseTls: options.relayUseTls,
relayPublicUseTls: options.relayPublicUseTls,
relayRequireProtectedSecurity: options.relayRequireProtectedSecurity,
appBaseUrl: "https://app.paseo.sh",
auth: options.auth,
pushNotificationSender: options.pushNotificationSender,
@@ -1909,6 +1909,8 @@ export class VoiceAssistantWebSocketServer {
features: {
connectedHosts: true,
hostMultiplexing: true,
// COMPAT(relayProtectedSecurity): added in v0.13.0, remove gate after 2027-04-21.
relayProtectedSecurity: true,
usageSources: true,
ownedSubscriptions: true,
agentRequestReceipts: true,
@@ -1,6 +1,11 @@
import { afterEach, expect, test, vi } from "vitest";
import pino from "pino";
import { exportPublicKey, generateKeyPair, openClientSecureChannel } from "@getpaseo/relay/e2ee";
import {
createClientChannel,
exportPublicKey,
generateKeyPair,
openClientSecureChannel,
} from "@getpaseo/relay/e2ee";
import type { Transport } from "@getpaseo/relay/e2ee";
import { admitSecuredRelaySocket, type SecuredRelaySocket } from "./secure-relay-ingress.js";
@@ -55,6 +60,7 @@ class FakeIngressSocket {
}
const logger = pino({ level: "silent" });
const allowLegacy = { requireProtectedMode: () => false };
/** Wires a client secure channel to the fake socket so both ends talk directly. */
async function openClientAgainst(
@@ -78,6 +84,24 @@ async function openClientAgainst(
return openClientSecureChannel(clientTransport, { daemonPublicKeyB64, events });
}
/** An older client that only speaks the legacy construction. */
async function openLegacyClientAgainst(socket: FakeIngressSocket, daemonPublicKeyB64: string) {
const clientTransport: Transport = {
send: (data) => socket.message(data),
close: () => undefined,
onmessage: null,
onclose: null,
onerror: null,
};
socket.onSend = (data) => {
clientTransport.onmessage?.({
data: data instanceof Uint8Array ? data.slice().buffer : data,
isBinary: data instanceof ArrayBuffer || data instanceof Uint8Array,
});
};
return createClientChannel(clientTransport, daemonPublicKeyB64);
}
afterEach(() => {
vi.useRealTimers();
});
@@ -88,7 +112,7 @@ test("terminates a relay socket whose peer never completes the handshake", async
let attachments = 0;
const admitted = admitSecuredRelaySocket(
socket,
{ daemonKeyPair: generateKeyPair(), logger },
{ daemonKeyPair: generateKeyPair(), logger, ...allowLegacy },
async () => {
attachments += 1;
},
@@ -103,16 +127,72 @@ test("admits the secured socket with its negotiated security mode", async () =>
const socket = new FakeIngressSocket();
const daemonKeyPair = generateKeyPair();
let secured: SecuredRelaySocket | null = null;
const admitted = admitSecuredRelaySocket(socket, { daemonKeyPair, logger }, async (next) => {
secured = next;
});
const admitted = admitSecuredRelaySocket(
socket,
{ daemonKeyPair, logger, ...allowLegacy },
async (next) => {
secured = next;
},
);
await openClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey));
await admitted;
expect(secured).not.toBeNull();
expect(secured!.securityMode).toBe("legacy");
expect(secured!.securityMode).toBe("protected");
expect(secured!.socket.readyState).toBe(1);
});
test("admits a legacy client while protected mode is not required", async () => {
const socket = new FakeIngressSocket();
const daemonKeyPair = generateKeyPair();
let secured: SecuredRelaySocket | null = null;
const admitted = admitSecuredRelaySocket(
socket,
{ daemonKeyPair, logger, ...allowLegacy },
async (next) => {
secured = next;
},
);
await openLegacyClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey));
await admitted;
expect(secured!.securityMode).toBe("legacy");
});
test("rejects a legacy client with a policy close when protected mode is required", async () => {
const socket = new FakeIngressSocket();
const daemonKeyPair = generateKeyPair();
let attachments = 0;
const admitted = admitSecuredRelaySocket(
socket,
{ daemonKeyPair, logger, requireProtectedMode: () => true },
async () => {
attachments += 1;
},
);
await openLegacyClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey));
await admitted;
expect(attachments).toBe(0);
expect(socket.closes).toEqual([{ code: 1008, reason: "protected endpoint security required" }]);
});
test("reads the policy when the hello arrives, not when the socket was accepted", async () => {
const socket = new FakeIngressSocket();
const daemonKeyPair = generateKeyPair();
let required = false;
let attachments = 0;
const admitted = admitSecuredRelaySocket(
socket,
{ daemonKeyPair, logger, requireProtectedMode: () => required },
async () => {
attachments += 1;
},
);
required = true;
await openLegacyClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey));
await admitted;
expect(attachments).toBe(0);
expect(socket.closes[0]?.code).toBe(1008);
});
test("delivers records received before admission completes, in order, after attach", async () => {
const socket = new FakeIngressSocket();
const daemonKeyPair = generateKeyPair();
@@ -122,11 +202,15 @@ test("delivers records received before admission completes, in order, after atta
});
const received: unknown[] = [];
let secured: SecuredRelaySocket | null = null;
const admitted = admitSecuredRelaySocket(socket, { daemonKeyPair, logger }, async (next) => {
secured = next;
next.socket.on("message", (data) => received.push(data));
await attachGate;
});
const admitted = admitSecuredRelaySocket(
socket,
{ daemonKeyPair, logger, ...allowLegacy },
async (next) => {
secured = next;
next.socket.on("message", (data) => received.push(data));
await attachGate;
},
);
let clientOpen: () => void = () => undefined;
const opened = new Promise<void>((resolve) => {
clientOpen = resolve;
@@ -152,7 +236,7 @@ test("encrypted sends wait for the physical socket send callback", async () => {
const attached = new Promise<SecuredRelaySocket>((resolve) => {
resolveAttached = resolve;
});
void admitSecuredRelaySocket(socket, { daemonKeyPair, logger }, async (next) =>
void admitSecuredRelaySocket(socket, { daemonKeyPair, logger, ...allowLegacy }, async (next) =>
resolveAttached?.(next),
);
let resolveClientOpen: (() => void) | undefined;
@@ -11,6 +11,7 @@ import { EventEmitter } from "node:events";
import type pino from "pino";
import {
openDaemonSecureChannel,
SecureChannelRejectedError,
type KeyPair,
type SecurityMode,
type Transport as RelayTransport,
@@ -36,6 +37,11 @@ export interface SecuredRelaySocket {
export interface SecureRelayIngressOptions {
daemonKeyPair: KeyPair;
logger: pino.Logger;
/**
* Current policy, read when the peer's hello arrives so a flag flipped at
* runtime applies to every admission from that moment on.
*/
requireProtectedMode: () => boolean;
}
/**
@@ -69,6 +75,7 @@ export async function admitSecuredRelaySocket(
};
const channel = await openDaemonSecureChannel(relayTransport, {
daemonKeyPair: options.daemonKeyPair,
minimumMode: () => (options.requireProtectedMode() ? "protected" : "legacy"),
events: {
onmessage: emitMessage,
onclose: (code, reason) => emitter.emit("close", code, reason),
@@ -92,6 +99,15 @@ export async function admitSecuredRelaySocket(
}
pendingMessages.length = 0;
} catch (error) {
if (error instanceof SecureChannelRejectedError) {
logger.warn({ reason: error.message }, "relay_e2ee_handshake_rejected");
try {
socket.close(error.closeCode, error.closeReason);
} catch {
// ignore
}
return;
}
logger.warn({ err: error }, "relay_e2ee_handshake_failed");
try {
socket.close(1011, "E2EE handshake failed");
@@ -14,7 +14,7 @@ import { join } from "path";
import { win32 } from "node:path";
import { tmpdir } from "os";
import pino from "pino";
import { base64EncryptedWireByteLength } from "@getpaseo/relay";
import { base64EncryptedWireByteLength, maxSecuredPlaintextByteLength } from "@getpaseo/relay";
import {
__resetCheckoutShortstatCacheForTests,
__resetPullRequestStatusCacheForTests,
@@ -1572,9 +1572,14 @@ const x = 1;
CHECKOUT_DIFF_MAX_STRUCTURED_BYTES + frameEnvelopeHeadroomBytes,
);
expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBe(24_117_208);
// The cap is sized for the tighter of the legacy and protected relay framings.
expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBe(
maxSecuredPlaintextByteLength(relayFrameBytes) - frameEnvelopeHeadroomBytes,
);
expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBeLessThanOrEqual(24_117_208);
expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBeGreaterThan(24_000_000);
expect(diffWireBytes).toBeLessThan(relayFrameBytes);
expect(maximumFrameWireBytes).toBe(relayFrameBytes);
expect(maximumFrameWireBytes).toBeLessThanOrEqual(relayFrameBytes);
});
it("marks tracked generated one-line diffs as too_large by content size", async () => {
+2 -2
View File
@@ -6,7 +6,7 @@ import { setImmediate } from "node:timers/promises";
import { TTLCache } from "@isaacs/ttlcache";
import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages";
import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { maxBase64EncryptedPlaintextByteLength } from "@getpaseo/relay";
import { maxSecuredPlaintextByteLength } from "@getpaseo/relay";
import type { Logger } from "pino";
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
import {
@@ -2156,7 +2156,7 @@ const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024;
// binding: string frames are encrypted and base64-encoded. Reserve 1 MiB plaintext for
// the surrounding WebSocket JSON envelope after inverting that exact wire expansion.
export const CHECKOUT_DIFF_MAX_STRUCTURED_BYTES =
maxBase64EncryptedPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) -
maxSecuredPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) -
CHECKOUT_DIFF_FRAME_HEADROOM_BYTES;
interface StructuredDiffAccumulator {
@@ -194,6 +194,10 @@
"enabled": {
"type": "boolean"
},
"requireProtectedSecurity": {
"description": "Admit only relay clients that negotiate protected endpoint security (replay protection). Defaults to false; older clients still connect while it is off.",
"type": "boolean"
},
"endpoint": {
"type": "string"
},