feat: enforce device-bound connect challenge
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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("|");
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export const ConnectParamsSchema = Type.Object(
|
||||
publicKey: NonEmptyString,
|
||||
signature: NonEmptyString,
|
||||
signedAt: Type.Integer({ minimum: 0 }),
|
||||
nonce: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
|
||||
@@ -81,6 +81,7 @@ export function listGatewayMethods(): string[] {
|
||||
}
|
||||
|
||||
export const GATEWAY_EVENTS = [
|
||||
"connect.challenge",
|
||||
"agent",
|
||||
"chat",
|
||||
"presence",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user