UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

205 lines (195 loc) • 6.65 kB
// Browser Relay Authentication v2 for the unpacked MV3 extension. // This module is intentionally browser-native ESM: no Node globals or bundling. import { RELAY_AUTH_VERSION, canonicalRelayAuthProofBytes, computeRelayAuthProof, deriveRelayAuthKeyId, extensionRelayAuthResource, importRelayHmacKey, randomRelayBase64Url, relayBytesFromBase64Url, requireRelayCrypto, } from "./relay-auth-v2-crypto.js"; import { hasExactKeys, parseStrictJsonObject } from "./strict-json.js"; export const EXTENSION_RELAY_V2_PROTOCOL = "openclaw-extension-relay.v2"; const CHALLENGE_LIFETIME_MS = 10_000; const MAX_CLOCK_SKEW_MS = 30_000; const MAX_AUTH_JSON_BYTES = 16 * 1024; const authTextEncoder = new TextEncoder(); const CHALLENGE_KEYS = [ "type", "v", "keyId", "instanceId", "sessionId", "clientNonce", "serverNonce", "issuedAtMs", "expiresAtMs", "role", "transport", "method", "resource", "flow", "serverProof", ]; const OK_KEYS = ["type", "v", "sessionId", "acceptProof"]; /** Parse an authentication frame without allowing JSON duplicate-key shadowing. */ export function parseRelayAuthJson(raw) { if ( typeof raw !== "string" || raw.length > MAX_AUTH_JSON_BYTES || authTextEncoder.encode(raw).byteLength > MAX_AUTH_JSON_BYTES ) { return null; } return parseStrictJsonObject(raw); } function assertSafeTimestamp(value, field) { if (!Number.isSafeInteger(value) || value < 0) { throw new Error(`${field} must be a non-negative safe integer`); } } /** * One connection-bound client state machine. Any invalid or out-of-order * security frame permanently fails this instance; callers must close the socket. */ export async function createExtensionRelayAuthClient({ token, relayUrl, cryptoApi = globalThis.crypto, now = () => Date.now(), clientNonce, }) { const runtime = requireRelayCrypto(cryptoApi); const keyId = await deriveRelayAuthKeyId(token, runtime); const key = await importRelayHmacKey(token, runtime); const nonce = clientNonce ?? randomRelayBase64Url(runtime, 32); relayBytesFromBase64Url(nonce, 32, "clientNonce"); const resource = extensionRelayAuthResource(relayUrl); let state = "new"; let challenge = null; let clientProof = null; const fail = (error) => { state = "failed"; throw error instanceof Error ? error : new Error(String(error)); }; return { keyId, clientNonce: nonce, get authenticated() { return state === "authenticated"; }, start() { if (state !== "new") { return fail(new Error("relay auth hello is out of sequence")); } state = "waiting-challenge"; return { type: "auth.hello", v: RELAY_AUTH_VERSION, keyId, clientNonce: nonce }; }, async acceptChallenge(message) { if (state !== "waiting-challenge") { return fail(new Error("relay auth challenge is out of sequence")); } state = "verifying-challenge"; try { if (!hasExactKeys(message, CHALLENGE_KEYS) || message.type !== "auth.challenge") { throw new Error("invalid relay auth challenge shape"); } if ( message.v !== RELAY_AUTH_VERSION || message.keyId !== keyId || message.clientNonce !== nonce || message.role !== "extension" || message.transport !== "websocket" || message.method !== "GET" || message.resource !== resource || message.flow !== "extension" ) { throw new Error("relay auth challenge binding mismatch"); } relayBytesFromBase64Url(message.instanceId, 16, "instanceId"); relayBytesFromBase64Url(message.sessionId, 16, "sessionId"); relayBytesFromBase64Url(message.serverNonce, 32, "serverNonce"); const serverProofBytes = relayBytesFromBase64Url(message.serverProof, 32, "serverProof"); assertSafeTimestamp(message.issuedAtMs, "issuedAtMs"); assertSafeTimestamp(message.expiresAtMs, "expiresAtMs"); const currentTime = now(); assertSafeTimestamp(currentTime, "current time"); if ( message.expiresAtMs <= message.issuedAtMs || message.expiresAtMs - message.issuedAtMs > CHALLENGE_LIFETIME_MS || Math.abs(currentTime - message.issuedAtMs) > MAX_CLOCK_SKEW_MS || currentTime > message.expiresAtMs ) { throw new Error("relay auth challenge is expired or outside the allowed clock skew"); } const fields = { keyId: message.keyId, instanceId: message.instanceId, sessionId: message.sessionId, clientNonce: message.clientNonce, serverNonce: message.serverNonce, issuedAtMs: message.issuedAtMs, expiresAtMs: message.expiresAtMs, role: message.role, transport: message.transport, method: message.method, resource: message.resource, flow: message.flow, }; const valid = await runtime.subtle.verify( "HMAC", key, serverProofBytes, canonicalRelayAuthProofBytes("server", fields), ); if (!valid) { throw new Error("relay auth server proof is invalid"); } clientProof = await computeRelayAuthProof(token, "client", fields, undefined, runtime); challenge = fields; state = "waiting-ok"; return { type: "auth.response", v: RELAY_AUTH_VERSION, sessionId: fields.sessionId, clientProof, }; } catch (error) { return fail(error); } }, async acceptOk(message) { if (state !== "waiting-ok" || !challenge || !clientProof) { return fail(new Error("relay auth ok is out of sequence")); } state = "verifying-ok"; try { if ( !hasExactKeys(message, OK_KEYS) || message.type !== "auth.ok" || message.v !== RELAY_AUTH_VERSION || message.sessionId !== challenge.sessionId ) { throw new Error("invalid relay auth ok binding"); } const acceptProofBytes = relayBytesFromBase64Url(message.acceptProof, 32, "acceptProof"); const valid = await runtime.subtle.verify( "HMAC", key, acceptProofBytes, canonicalRelayAuthProofBytes("accept", challenge, clientProof), ); if (!valid) { throw new Error("relay auth accept proof is invalid"); } state = "authenticated"; return undefined; } catch (error) { return fail(error); } }, }; }