Skip to content

Commit 6a6a120

Browse files
committed
fix(gateway): defend pre-auth device-signature verify against CPU-amplification DoS
Pre-auth handshakes that include a `device` block previously ran `crypto.createPublicKey` plus a v3-then-v2 `crypto.verify` per request, gated only by frame-size limits — an unauthenticated remote attacker could pin a single Gateway core (~500 forged handshakes/s/IP measured in PoC, 50x p50 / 95x p99 legit handshake degradation). Layered defense: - Schema: cap device.publicKey at 1024 chars and device.signature at 256 chars (every valid Ed25519 form fits with margin) so oversized strings never reach the crypto path. - Pre-check: short-circuit verifyDeviceSignature, deriveDeviceIdFromPublicKey, and normalizeDevicePublicKeyBase64Url on inputs that cannot decode to a 32-byte raw key or 64-byte signature. - Rate limit: gate the verify behind a new AUTH_RATE_LIMIT_SCOPE_DEVICE_SIGNATURE bucket (per-IP, fires through the always-constructed browser-origin limiter and through the regular limiter when configured). In-process integration test exercises the full WS handshake and proves the rate-limit gate truncates attacker work after maxAttempts; reverting the gate makes the test fail.
1 parent 9827490 commit 6a6a120

11 files changed

Lines changed: 965 additions & 12 deletions
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it } from "vitest";
2+
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "./client-info.js";
3+
import { validateConnectParams } from "./index.js";
4+
import { DEVICE_PUBLIC_KEY_MAX_LENGTH, DEVICE_SIGNATURE_MAX_LENGTH } from "./schema/primitives.js";
5+
6+
function makeConnectParams(deviceOverrides: Partial<Record<string, unknown>>) {
7+
return {
8+
minProtocol: 1,
9+
maxProtocol: 1,
10+
client: {
11+
id: GATEWAY_CLIENT_IDS.CLI,
12+
version: "dev",
13+
platform: "linux",
14+
mode: GATEWAY_CLIENT_MODES.CLI,
15+
},
16+
role: "node",
17+
scopes: [],
18+
device: {
19+
id: "0".repeat(64),
20+
publicKey: "abc",
21+
signature: "def",
22+
signedAt: 0,
23+
nonce: "n",
24+
...deviceOverrides,
25+
},
26+
};
27+
}
28+
29+
describe("ConnectParams device handshake bounds", () => {
30+
it("accepts realistic-sized publicKey and signature inputs", () => {
31+
const ok = validateConnectParams(
32+
makeConnectParams({
33+
publicKey: "a".repeat(DEVICE_PUBLIC_KEY_MAX_LENGTH),
34+
signature: "b".repeat(DEVICE_SIGNATURE_MAX_LENGTH),
35+
}),
36+
);
37+
expect(ok).toBe(true);
38+
});
39+
40+
it("rejects oversized device.publicKey at the schema layer (DoS amplification defense)", () => {
41+
const ok = validateConnectParams(
42+
makeConnectParams({
43+
publicKey: "a".repeat(DEVICE_PUBLIC_KEY_MAX_LENGTH + 1),
44+
}),
45+
);
46+
expect(ok).toBe(false);
47+
});
48+
49+
it("rejects oversized device.signature at the schema layer", () => {
50+
const ok = validateConnectParams(
51+
makeConnectParams({
52+
signature: "b".repeat(DEVICE_SIGNATURE_MAX_LENGTH + 1),
53+
}),
54+
);
55+
expect(ok).toBe(false);
56+
});
57+
58+
it("still accepts empty-string inputs failure mode (handled deeper in handshake)", () => {
59+
// minLength: 1 still applies; an empty string is rejected.
60+
expect(validateConnectParams(makeConnectParams({ publicKey: "" }))).toBe(false);
61+
expect(validateConnectParams(makeConnectParams({ signature: "" }))).toBe(false);
62+
});
63+
});

packages/gateway-protocol/src/schema/frames.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
// Gateway Protocol schema module defines protocol validation shapes.
22
import { Type } from "typebox";
3-
import { GatewayClientIdSchema, GatewayClientModeSchema, NonEmptyString } from "./primitives.js";
3+
import {
4+
DeviceHandshakePublicKeyString,
5+
DeviceHandshakeSignatureString,
6+
GatewayClientIdSchema,
7+
GatewayClientModeSchema,
8+
NonEmptyString,
9+
} from "./primitives.js";
410
import { SnapshotSchema, StateVersionSchema } from "./snapshot.js";
511

612
/**
@@ -54,8 +60,8 @@ export const ConnectParamsSchema = Type.Object(
5460
Type.Object(
5561
{
5662
id: NonEmptyString,
57-
publicKey: NonEmptyString,
58-
signature: NonEmptyString,
63+
publicKey: DeviceHandshakePublicKeyString,
64+
signature: DeviceHandshakeSignatureString,
5965
signedAt: Type.Integer({ minimum: 0 }),
6066
nonce: NonEmptyString,
6167
},

packages/gateway-protocol/src/schema/primitives.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@ const SESSION_LABEL_MAX_LENGTH = 512;
2121

2222
/** Non-empty string primitive for protocol fields that reject blank values. */
2323
export const NonEmptyString = Type.String({ minLength: 1 });
24+
// Bounds on the device handshake fields. Kept generous enough for every
25+
// valid Ed25519 form (PEM ~120 chars, base64url 43; signature base64url
26+
// 86) while denying attackers the ability to push 64 KB strings into
27+
// crypto.createPublicKey / crypto.verify on the pre-auth path.
28+
export const DEVICE_PUBLIC_KEY_MAX_LENGTH = 1024;
29+
export const DEVICE_SIGNATURE_MAX_LENGTH = 256;
30+
export const DeviceHandshakePublicKeyString = Type.String({
31+
minLength: 1,
32+
maxLength: DEVICE_PUBLIC_KEY_MAX_LENGTH,
33+
});
34+
export const DeviceHandshakeSignatureString = Type.String({
35+
minLength: 1,
36+
maxLength: DEVICE_SIGNATURE_MAX_LENGTH,
37+
});
2438
/** Maximum stable session key length accepted by chat-send protocol requests. */
2539
export const CHAT_SEND_SESSION_KEY_MAX_LENGTH = 512;
2640
/** Chat-send session key string primitive with bounded length. */

0 commit comments

Comments
 (0)