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? }
|
||||
* 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<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -141,10 +171,31 @@ export class OpenClawClient {
|
|||
}
|
||||
|
||||
private async sendHandshake(nonce: string): Promise<void> {
|
||||
// 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue