From a7c5b264fab9373cc98c0b8d6ae5eef132f5ad3f Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 8 Mar 2026 07:40:15 -0700 Subject: [PATCH] fix(openclaw-bridge): implement correct v3 device auth protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - device.id = SHA256(rawPubKey, hex) — not a random UUID - device.publicKey = raw 32-byte key encoded as base64url (not SPKI DER) - sign the full v3 payload string (not just raw nonce bytes): "v3|{deviceId}|{clientId}|{mode}|{role}|{scopes}|{ts}|{token}|{nonce}|{platform}|" - device.signature encoded as base64url Matches buildDeviceAuthPayloadV3/verifyDeviceSignature from openclaw dist. Co-Authored-By: Claude Sonnet 4.6 --- .../openclaw-bridge/src/openclaw-client.ts | 89 +++++++++++++++---- 1 file changed, 70 insertions(+), 19 deletions(-) diff --git a/packages/openclaw-bridge/src/openclaw-client.ts b/packages/openclaw-bridge/src/openclaw-client.ts index 5848690..d2ea8b5 100644 --- a/packages/openclaw-bridge/src/openclaw-client.ts +++ b/packages/openclaw-bridge/src/openclaw-client.ts @@ -6,11 +6,19 @@ * Response: { type:"res", id, ok:boolean, payload?, error? } * Event: { type:"event", event, payload?, seq? } * - * Authentication: + * Authentication (v3 device-signature protocol): * 1. Server sends "connect.challenge" event with { nonce, timestamp } - * 2. Client signs the nonce with an ephemeral Ed25519 key - * 3. Client sends "connect" req with auth.token + device.{id,publicKey,signature,signedAt} - * 4. Server responds with ok:true → connection is ready + * 2. Client builds payload: + * "v3|{deviceId}|{clientId}|{clientMode}|{role}|{scopes}|{signedAtMs}|{token}|{nonce}|{platform}|" + * 3. Client signs payload (UTF-8) with ephemeral Ed25519 key + * 4. Client sends "connect" req with: + * - client.id = "gateway-client", client.mode = "backend", client.platform = "node" + * - auth.token = OPENCLAW_GATEWAY_TOKEN + * - device.id = SHA256(rawPubKey, hex), device.nonce = nonce + * - device.publicKey = rawPubKey (base64url, 32 bytes) + * - device.signature = base64url(signed payload) + * - device.signedAt = Date.now() + * 5. Server responds with ok:true → connection is ready */ import WebSocket from 'ws'; @@ -43,6 +51,21 @@ interface EventFrame { type Frame = ReqFrame | ResFrame | EventFrame; +// ── Helpers (mirror openclaw/openclaw internals) ────────────────────────────── + +function base64UrlEncode(buf: Buffer): string { + return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, ''); +} + +// Ed25519 SPKI prefix (ASN.1 DER) — 12 bytes +const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); + +function getRawPublicKey(keyObject: crypto.KeyObject): Buffer { + const spki = keyObject.export({ type: 'spki', format: 'der' }) as Buffer; + // Raw key is the 32 bytes after the 12-byte SPKI prefix + return spki.subarray(ED25519_SPKI_PREFIX.length); +} + // ── Client ──────────────────────────────────────────────────────────────────── export class OpenClawClient { @@ -55,13 +78,20 @@ export class OpenClawClient { private msgCounter = 0; // Ephemeral Ed25519 key pair — generated once per client instance - private readonly deviceId = crypto.randomUUID(); private readonly keyPair = crypto.generateKeyPairSync('ed25519'); + // Raw 32-byte public key (base64url) — used as device.publicKey + private readonly rawPubKeyB64Url: string; + // device.id = SHA256(rawPubKey, hex) — required by openclaw gateway + private readonly deviceId: string; constructor( private readonly gatewayUrl: string, private readonly token: string, - ) {} + ) { + const rawPub = getRawPublicKey(this.keyPair.publicKey); + this.rawPubKeyB64Url = base64UrlEncode(rawPub); + this.deviceId = crypto.createHash('sha256').update(rawPub).digest('hex'); + } connect(): Promise { return new Promise((resolve, reject) => { @@ -141,10 +171,31 @@ export class OpenClawClient { } private async sendHandshake(nonce: string): Promise { - // Sign the nonce (hex string → Buffer) with our ephemeral private key - const nonceBuffer = Buffer.from(nonce, 'hex'); - const signature = crypto.sign(null, nonceBuffer, this.keyPair.privateKey); - const pubKeyDer = this.keyPair.publicKey.export({ type: 'spki', format: 'der' }); + const signedAt = Date.now(); + const scopes = ['operator.read', 'operator.write']; + const clientId = 'gateway-client'; + const clientMode = 'backend'; + const role = 'operator'; + const platform = 'node'; + + // V3 payload (matches buildDeviceAuthPayloadV3 in openclaw source): + // "v3|{deviceId}|{clientId}|{clientMode}|{role}|{scopes}|{signedAtMs}|{token}|{nonce}|{platform}|{deviceFamily}" + const payload = [ + 'v3', + this.deviceId, + clientId, + clientMode, + role, + scopes.join(','), + String(signedAt), + this.token, + nonce, + platform, + '', // deviceFamily (empty) + ].join('|'); + + // Sign as UTF-8 string (not raw hex bytes) + const sig = crypto.sign(null, Buffer.from(payload, 'utf8'), this.keyPair.privateKey); const req: ReqFrame = { type: 'req', @@ -154,20 +205,20 @@ export class OpenClawClient { minProtocol: 3, maxProtocol: 3, client: { - id: 'gateway-client', + id: clientId, version: '1.0.0', - platform: 'node', - mode: 'backend', + platform, + mode: clientMode, }, - role: 'operator', - scopes: ['operator.read', 'operator.write'], + role, + scopes, auth: { token: this.token }, device: { id: this.deviceId, - nonce: nonce, - publicKey: pubKeyDer.toString('base64'), - signature: signature.toString('base64'), - signedAt: Date.now(), + nonce, + publicKey: this.rawPubKeyB64Url, + signature: base64UrlEncode(sig), + signedAt, }, }, };