chore(deps): update dependencies and devDependencies in package.json (#100)
This commit is contained in:
128
electron/utils/device-identity.ts
Normal file
128
electron/utils/device-identity.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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('|');
|
||||
}
|
||||
Reference in New Issue
Block a user