fix(openclaw-bridge): implement correct v3 device auth protocol

- 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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-08 07:40:15 -07:00
parent bb4b73f847
commit a7c5b264fa
1 changed files with 70 additions and 19 deletions

View File

@ -6,11 +6,19 @@
* Response: { type:"res", id, ok:boolean, payload?, error? } * Response: { type:"res", id, ok:boolean, payload?, error? }
* Event: { type:"event", event, payload?, seq? } * Event: { type:"event", event, payload?, seq? }
* *
* Authentication: * Authentication (v3 device-signature protocol):
* 1. Server sends "connect.challenge" event with { nonce, timestamp } * 1. Server sends "connect.challenge" event with { nonce, timestamp }
* 2. Client signs the nonce with an ephemeral Ed25519 key * 2. Client builds payload:
* 3. Client sends "connect" req with auth.token + device.{id,publicKey,signature,signedAt} * "v3|{deviceId}|{clientId}|{clientMode}|{role}|{scopes}|{signedAtMs}|{token}|{nonce}|{platform}|"
* 4. Server responds with ok:true connection is ready * 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'; import WebSocket from 'ws';
@ -43,6 +51,21 @@ interface EventFrame {
type Frame = ReqFrame | ResFrame | 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 ──────────────────────────────────────────────────────────────────── // ── Client ────────────────────────────────────────────────────────────────────
export class OpenClawClient { export class OpenClawClient {
@ -55,13 +78,20 @@ export class OpenClawClient {
private msgCounter = 0; private msgCounter = 0;
// Ephemeral Ed25519 key pair — generated once per client instance // Ephemeral Ed25519 key pair — generated once per client instance
private readonly deviceId = crypto.randomUUID();
private readonly keyPair = crypto.generateKeyPairSync('ed25519'); 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( constructor(
private readonly gatewayUrl: string, private readonly gatewayUrl: string,
private readonly token: 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<void> { connect(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -141,10 +171,31 @@ export class OpenClawClient {
} }
private async sendHandshake(nonce: string): Promise<void> { private async sendHandshake(nonce: string): Promise<void> {
// Sign the nonce (hex string → Buffer) with our ephemeral private key const signedAt = Date.now();
const nonceBuffer = Buffer.from(nonce, 'hex'); const scopes = ['operator.read', 'operator.write'];
const signature = crypto.sign(null, nonceBuffer, this.keyPair.privateKey); const clientId = 'gateway-client';
const pubKeyDer = this.keyPair.publicKey.export({ type: 'spki', format: 'der' }); 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 = { const req: ReqFrame = {
type: 'req', type: 'req',
@ -154,20 +205,20 @@ export class OpenClawClient {
minProtocol: 3, minProtocol: 3,
maxProtocol: 3, maxProtocol: 3,
client: { client: {
id: 'gateway-client', id: clientId,
version: '1.0.0', version: '1.0.0',
platform: 'node', platform,
mode: 'backend', mode: clientMode,
}, },
role: 'operator', role,
scopes: ['operator.read', 'operator.write'], scopes,
auth: { token: this.token }, auth: { token: this.token },
device: { device: {
id: this.deviceId, id: this.deviceId,
nonce: nonce, nonce,
publicKey: pubKeyDer.toString('base64'), publicKey: this.rawPubKeyB64Url,
signature: signature.toString('base64'), signature: base64UrlEncode(sig),
signedAt: Date.now(), signedAt,
}, },
}, },
}; };