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

@@ -0,0 +1,108 @@
import { ed25519 } from "@noble/ed25519";
type StoredIdentity = {
version: 1;
deviceId: string;
publicKey: string;
privateKey: string;
createdAtMs: number;
};
export type DeviceIdentity = {
deviceId: string;
publicKey: string;
privateKey: string;
};
const STORAGE_KEY = "clawdbot-device-identity-v1";
function base64UrlEncode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
}
function base64UrlDecode(input: string): Uint8Array {
const normalized = input.replaceAll("-", "+").replaceAll("_", "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
const binary = atob(padded);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
}
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async function fingerprintPublicKey(publicKey: Uint8Array): Promise<string> {
const hash = await crypto.subtle.digest("SHA-256", publicKey);
return bytesToHex(new Uint8Array(hash));
}
async function generateIdentity(): Promise<DeviceIdentity> {
const privateKey = ed25519.utils.randomPrivateKey();
const publicKey = await ed25519.getPublicKey(privateKey);
const deviceId = await fingerprintPublicKey(publicKey);
return {
deviceId,
publicKey: base64UrlEncode(publicKey),
privateKey: base64UrlEncode(privateKey),
};
}
export async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as StoredIdentity;
if (
parsed?.version === 1 &&
typeof parsed.deviceId === "string" &&
typeof parsed.publicKey === "string" &&
typeof parsed.privateKey === "string"
) {
const derivedId = await fingerprintPublicKey(base64UrlDecode(parsed.publicKey));
if (derivedId !== parsed.deviceId) {
const updated: StoredIdentity = {
...parsed,
deviceId: derivedId,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return {
deviceId: derivedId,
publicKey: parsed.publicKey,
privateKey: parsed.privateKey,
};
}
return {
deviceId: parsed.deviceId,
publicKey: parsed.publicKey,
privateKey: parsed.privateKey,
};
}
}
} catch {
// fall through to regenerate
}
const identity = await generateIdentity();
const stored: StoredIdentity = {
version: 1,
deviceId: identity.deviceId,
publicKey: identity.publicKey,
privateKey: identity.privateKey,
createdAtMs: Date.now(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
return identity;
}
export async function signDevicePayload(privateKeyBase64Url: string, payload: string) {
const key = base64UrlDecode(privateKeyBase64Url);
const data = new TextEncoder().encode(payload);
const sig = await ed25519.sign(data, key);
return base64UrlEncode(sig);
}

View File

@@ -5,6 +5,8 @@ import {
type GatewayClientMode,
type GatewayClientName,
} from "../../../src/gateway/protocol/client-info.js";
import { buildDeviceAuthPayload } from "../../../src/gateway/device-auth.js";
import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity";
export type GatewayEventFrame = {
type: "event";
@@ -58,6 +60,9 @@ export class GatewayBrowserClient {
private pending = new Map<string, Pending>();
private closed = false;
private lastSeq: number | null = null;
private connectNonce: string | null = null;
private connectSent = false;
private connectTimer: number | null = null;
private backoffMs = 800;
constructor(private opts: GatewayBrowserClientOptions) {}
@@ -81,7 +86,7 @@ export class GatewayBrowserClient {
private connect() {
if (this.closed) return;
this.ws = new WebSocket(this.opts.url);
this.ws.onopen = () => this.sendConnect();
this.ws.onopen = () => this.queueConnect();
this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? ""));
this.ws.onclose = (ev) => {
const reason = String(ev.reason ?? "");
@@ -107,7 +112,14 @@ export class GatewayBrowserClient {
this.pending.clear();
}
private sendConnect() {
private async sendConnect() {
if (this.connectSent) return;
this.connectSent = true;
if (this.connectTimer !== null) {
window.clearTimeout(this.connectTimer);
this.connectTimer = null;
}
const deviceIdentity = await loadOrCreateDeviceIdentity();
const auth =
this.opts.token || this.opts.password
? {
@@ -115,6 +127,21 @@ export class GatewayBrowserClient {
password: this.opts.password,
}
: undefined;
const scopes = ["operator.admin"];
const role = "operator";
const signedAtMs = Date.now();
const nonce = this.connectNonce ?? undefined;
const payload = buildDeviceAuthPayload({
deviceId: deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
role,
scopes,
signedAtMs,
token: this.opts.token ?? null,
nonce,
});
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
const params = {
minProtocol: 3,
maxProtocol: 3,
@@ -125,6 +152,15 @@ export class GatewayBrowserClient {
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
instanceId: this.opts.instanceId,
},
role,
scopes,
device: {
id: deviceIdentity.deviceId,
publicKey: deviceIdentity.publicKey,
signature,
signedAt: signedAtMs,
nonce,
},
caps: [],
auth,
userAgent: navigator.userAgent,
@@ -152,6 +188,15 @@ export class GatewayBrowserClient {
const frame = parsed as { type?: unknown };
if (frame.type === "event") {
const evt = parsed as GatewayEventFrame;
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;
void this.sendConnect();
}
return;
}
const seq = typeof evt.seq === "number" ? evt.seq : null;
if (seq !== null) {
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
@@ -186,4 +231,13 @@ export class GatewayBrowserClient {
this.ws.send(JSON.stringify(frame));
return p;
}
private queueConnect() {
this.connectNonce = null;
this.connectSent = false;
if (this.connectTimer !== null) window.clearTimeout(this.connectTimer);
this.connectTimer = window.setTimeout(() => {
void this.sendConnect();
}, 750);
}
}