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:
parent
bb4b73f847
commit
a7c5b264fa
|
|
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue