/** * Device identity utilities for OpenClaw Gateway authentication. * * OpenClaw Gateway 2026.2.15+ requires a signed device identity in the * connect handshake to grant scopes (operator.read, operator.write, etc.). * Without a device, the gateway strips all requested scopes. */ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; export interface DeviceIdentity { deviceId: string; publicKeyPem: string; privateKeyPem: string; } export interface DeviceAuthPayloadParams { deviceId: string; clientId: string; clientMode: string; role: string; scopes: string[]; signedAtMs: number; token?: string | null; nonce?: string | null; version?: 'v1' | 'v2'; } const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); function base64UrlEncode(buf: Buffer): string { return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, ''); } function derivePublicKeyRaw(publicKeyPem: string): Buffer { const spki = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' }) as Buffer; if ( spki.length === ED25519_SPKI_PREFIX.length + 32 && spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX) ) { return spki.subarray(ED25519_SPKI_PREFIX.length); } return spki; } function fingerprintPublicKey(publicKeyPem: string): string { const raw = derivePublicKeyRaw(publicKeyPem); return crypto.createHash('sha256').update(raw).digest('hex'); } function generateIdentity(): DeviceIdentity { const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519'); const publicKeyPem = (publicKey.export({ type: 'spki', format: 'pem' }) as Buffer).toString(); const privateKeyPem = (privateKey.export({ type: 'pkcs8', format: 'pem' }) as Buffer).toString(); return { deviceId: fingerprintPublicKey(publicKeyPem), publicKeyPem, privateKeyPem, }; } /** * Load device identity from disk, or create and persist a new one. * The identity file is stored at `filePath` with mode 0o600. */ export function loadOrCreateDeviceIdentity(filePath: string): DeviceIdentity { try { if (fs.existsSync(filePath)) { const raw = fs.readFileSync(filePath, 'utf8'); const parsed = JSON.parse(raw); if ( parsed?.version === 1 && typeof parsed.deviceId === 'string' && typeof parsed.publicKeyPem === 'string' && typeof parsed.privateKeyPem === 'string' ) { const derivedId = fingerprintPublicKey(parsed.publicKeyPem); if (derivedId && derivedId !== parsed.deviceId) { const updated = { ...parsed, deviceId: derivedId }; fs.writeFileSync(filePath, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 }); return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem }; } return { deviceId: parsed.deviceId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem }; } } } catch { // fall through to create a new identity } const identity = generateIdentity(); const dir = path.dirname(filePath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); const stored = { version: 1, ...identity, createdAtMs: Date.now() }; fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 }); try { fs.chmodSync(filePath, 0o600); } catch { /* ignore */ } return identity; } /** Sign a string payload with the Ed25519 private key, returns base64url signature. */ export function signDevicePayload(privateKeyPem: string, payload: string): string { const key = crypto.createPrivateKey(privateKeyPem); return base64UrlEncode(crypto.sign(null, Buffer.from(payload, 'utf8'), key)); } /** Encode the raw Ed25519 public key bytes (from PEM) as base64url. */ export function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string { return base64UrlEncode(derivePublicKeyRaw(publicKeyPem)); } /** Build the canonical payload string that must be signed for device auth. */ export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string { const version = params.version ?? (params.nonce ? 'v2' : 'v1'); const scopes = params.scopes.join(','); const token = params.token ?? ''; const base = [ version, params.deviceId, params.clientId, params.clientMode, params.role, scopes, String(params.signedAtMs), token, ]; if (version === 'v2') base.push(params.nonce ?? ''); return base.join('|'); }