feat: enforce device-bound connect challenge

This commit is contained in:
Peter Steinberger
2026-01-20 11:15:10 +00:00
parent 121ae6036b
commit dfbf6ac263
21 changed files with 953 additions and 129 deletions

View File

@@ -72,11 +72,14 @@ export function describeGatewayCloseCode(code: number): string | undefined {
export class GatewayClient {
private ws: WebSocket | null = null;
private opts: GatewayClientOptions & { deviceIdentity: DeviceIdentity };
private opts: GatewayClientOptions;
private pending = new Map<string, Pending>();
private backoffMs = 1000;
private closed = false;
private lastSeq: number | null = null;
private connectNonce: string | null = null;
private connectSent = false;
private connectTimer: NodeJS.Timeout | null = null;
// Track last tick to detect silent stalls.
private lastTick: number | null = null;
private tickIntervalMs = 30_000;
@@ -121,7 +124,7 @@ export class GatewayClient {
}
this.ws = new WebSocket(url, wsOptions);
this.ws.on("open", () => this.sendConnect());
this.ws.on("open", () => this.queueConnect());
this.ws.on("message", (data) => this.handleMessage(rawDataToString(data)));
this.ws.on("close", (code, reason) => {
const reasonText = rawDataToString(reason);
@@ -147,6 +150,12 @@ export class GatewayClient {
}
private sendConnect() {
if (this.connectSent) return;
this.connectSent = true;
if (this.connectTimer) {
clearTimeout(this.connectTimer);
this.connectTimer = null;
}
const role = this.opts.role ?? "operator";
const storedToken = this.opts.deviceIdentity
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
@@ -160,24 +169,29 @@ export class GatewayClient {
}
: undefined;
const signedAtMs = Date.now();
const nonce = this.connectNonce ?? undefined;
const scopes = this.opts.scopes ?? ["operator.admin"];
const deviceIdentity = this.opts.deviceIdentity;
const payload = buildDeviceAuthPayload({
deviceId: deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
role,
scopes,
signedAtMs,
token: authToken ?? null,
});
const signature = signDevicePayload(deviceIdentity.privateKeyPem, payload);
const device = {
id: deviceIdentity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(deviceIdentity.publicKeyPem),
signature,
signedAt: signedAtMs,
};
const device = (() => {
if (!this.opts.deviceIdentity) return undefined;
const payload = buildDeviceAuthPayload({
deviceId: this.opts.deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
role,
scopes,
signedAtMs,
token: authToken ?? null,
nonce,
});
const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload);
return {
id: this.opts.deviceIdentity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(this.opts.deviceIdentity.publicKeyPem),
signature,
signedAt: signedAtMs,
nonce,
};
})();
const params: ConnectParams = {
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
@@ -235,6 +249,15 @@ export class GatewayClient {
const parsed = JSON.parse(raw);
if (validateEventFrame(parsed)) {
const evt = parsed as EventFrame;
if (evt.event === "connect.challenge") {
const payload = evt.payload as { nonce?: unknown } | undefined;
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
if (nonce) {
this.connectNonce = nonce;
this.sendConnect();
}
return;
}
const seq = typeof evt.seq === "number" ? evt.seq : null;
if (seq !== null) {
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
@@ -266,6 +289,15 @@ export class GatewayClient {
}
}
private queueConnect() {
this.connectNonce = null;
this.connectSent = false;
if (this.connectTimer) clearTimeout(this.connectTimer);
this.connectTimer = setTimeout(() => {
this.sendConnect();
}, 750);
}
private scheduleReconnect() {
if (this.closed) return;
if (this.tickTimer) {

View File

@@ -6,13 +6,16 @@ export type DeviceAuthPayloadParams = {
scopes: string[];
signedAtMs: number;
token?: string | null;
nonce?: string | null;
version?: "v1" | "v2";
};
export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
const version = params.version ?? (params.nonce ? "v2" : "v1");
const scopes = params.scopes.join(",");
const token = params.token ?? "";
return [
"v1",
const base = [
version,
params.deviceId,
params.clientId,
params.clientMode,
@@ -20,5 +23,9 @@ export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string
scopes,
String(params.signedAtMs),
token,
].join("|");
];
if (version === "v2") {
base.push(params.nonce ?? "");
}
return base.join("|");
}

View File

@@ -46,6 +46,7 @@ export const ConnectParamsSchema = Type.Object(
publicKey: NonEmptyString,
signature: NonEmptyString,
signedAt: Type.Integer({ minimum: 0 }),
nonce: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),

View File

@@ -81,6 +81,7 @@ export function listGatewayMethods(): string[] {
}
export const GATEWAY_EVENTS = [
"connect.challenge",
"agent",
"chat",
"presence",

View File

@@ -57,6 +57,22 @@ describe("gateway server auth/connect", () => {
await server.close();
});
test("sends connect challenge on open", async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
const evtPromise = onceMessage<{ payload?: unknown }>(
ws,
(o) => o.type === "event" && o.event === "connect.challenge",
);
await new Promise<void>((resolve) => ws.once("open", resolve));
const evt = await evtPromise;
const nonce = (evt.payload as { nonce?: unknown } | undefined)?.nonce;
expect(typeof nonce).toBe("string");
ws.close();
await server.close();
});
test("rejects protocol mismatch", async () => {
const { server, ws } = await startServerWithClient();
try {

View File

@@ -116,6 +116,13 @@ export function attachGatewayWsConnectionHandler(params: {
}
};
const connectNonce = randomUUID();
send({
type: "event",
event: "connect.challenge",
payload: { nonce: connectNonce, ts: Date.now() },
});
const close = (code = 1000, reason?: string) => {
if (closed) return;
closed = true;
@@ -224,6 +231,7 @@ export function attachGatewayWsConnectionHandler(params: {
requestOrigin,
requestUserAgent,
canvasHostUrl,
connectNonce,
resolvedAuth,
gatewayMethods,
events,

View File

@@ -68,6 +68,7 @@ export function attachGatewayWsMessageHandler(params: {
requestOrigin?: string;
requestUserAgent?: string;
canvasHostUrl?: string;
connectNonce: string;
resolvedAuth: ResolvedGatewayAuth;
gatewayMethods: string[];
events: string[];
@@ -96,6 +97,7 @@ export function attachGatewayWsMessageHandler(params: {
requestOrigin,
requestUserAgent,
canvasHostUrl,
connectNonce,
resolvedAuth,
gatewayMethods,
events,
@@ -307,6 +309,40 @@ export function attachGatewayWsMessageHandler(params: {
close(1008, "device signature expired");
return;
}
const nonceRequired = !isLoopbackAddress(remoteAddr);
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
if (nonceRequired && !providedNonce) {
setHandshakeState("failed");
setCloseCause("device-auth-invalid", {
reason: "device-nonce-missing",
client: connectParams.client.id,
deviceId: device.id,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "device nonce required"),
});
close(1008, "device nonce required");
return;
}
if (providedNonce && providedNonce !== connectNonce) {
setHandshakeState("failed");
setCloseCause("device-auth-invalid", {
reason: "device-nonce-mismatch",
client: connectParams.client.id,
deviceId: device.id,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "device nonce mismatch"),
});
close(1008, "device nonce mismatch");
return;
}
const payload = buildDeviceAuthPayload({
deviceId: device.id,
clientId: connectParams.client.id,
@@ -315,8 +351,41 @@ export function attachGatewayWsMessageHandler(params: {
scopes: requestedScopes,
signedAtMs: signedAt,
token: connectParams.auth?.token ?? null,
nonce: providedNonce || undefined,
version: providedNonce ? "v2" : "v1",
});
if (!verifyDeviceSignature(device.publicKey, payload, device.signature)) {
const signatureOk = verifyDeviceSignature(device.publicKey, payload, device.signature);
const allowLegacy = !nonceRequired && !providedNonce;
if (!signatureOk && allowLegacy) {
const legacyPayload = buildDeviceAuthPayload({
deviceId: device.id,
clientId: connectParams.client.id,
clientMode: connectParams.client.mode,
role,
scopes: requestedScopes,
signedAtMs: signedAt,
token: connectParams.auth?.token ?? null,
version: "v1",
});
if (verifyDeviceSignature(device.publicKey, legacyPayload, device.signature)) {
// accepted legacy loopback signature
} else {
setHandshakeState("failed");
setCloseCause("device-auth-invalid", {
reason: "device-signature",
client: connectParams.client.id,
deviceId: device.id,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "device signature invalid"),
});
close(1008, "device signature invalid");
return;
}
} else if (!signatureOk) {
setHandshakeState("failed");
setCloseCause("device-auth-invalid", {
reason: "device-signature",
@@ -460,11 +529,7 @@ export function attachGatewayWsMessageHandler(params: {
if (!ok) return;
} else {
const allowedRoles = new Set(
Array.isArray(paired.roles)
? paired.roles
: paired.role
? [paired.role]
: [],
Array.isArray(paired.roles) ? paired.roles : paired.role ? [paired.role] : [],
);
if (allowedRoles.size === 0) {
const ok = await requirePairing("role-upgrade", paired);

View File

@@ -279,6 +279,7 @@ export async function connectReq(
publicKey: string;
signature: string;
signedAt: number;
nonce?: string;
};
},
): Promise<ConnectResponse> {
@@ -310,6 +311,7 @@ export async function connectReq(
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
nonce: opts?.device?.nonce,
};
})();
ws.send(