mission-control/src/lib/device-identity.ts

150 lines
5.1 KiB
TypeScript

'use client'
import { createClientLogger } from '@/lib/client-logger'
const log = createClientLogger('DeviceIdentity')
/**
* Ed25519 device identity for OpenClaw gateway protocol v3 challenge-response.
*
* Generates a persistent Ed25519 key pair on first use, stores it in localStorage,
* and signs server nonces during the WebSocket connect handshake.
*
* Falls back gracefully when Ed25519 is unavailable (older browsers) —
* the handshake proceeds without device identity (auth-token-only mode).
*/
// localStorage keys
const STORAGE_DEVICE_ID = 'mc-device-id'
const STORAGE_PUBKEY = 'mc-device-pubkey'
const STORAGE_PRIVKEY = 'mc-device-privkey'
const STORAGE_DEVICE_TOKEN = 'mc-device-token'
export interface DeviceIdentity {
deviceId: string
publicKeyBase64: string
privateKey: CryptoKey
}
// ── Helpers ──────────────────────────────────────────────────────
function toBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}
function fromBase64Url(value: string): Uint8Array {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
const binary = atob(padded)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
async function sha256Hex(buffer: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new Uint8Array(buffer))
const bytes = new Uint8Array(digest)
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
// ── Key management ───────────────────────────────────────────────
async function importPrivateKey(pkcs8Bytes: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey('pkcs8', pkcs8Bytes as unknown as BufferSource, 'Ed25519', false, ['sign'])
}
async function createNewIdentity(): Promise<DeviceIdentity> {
const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
const pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey)
const privPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)
// OpenClaw expects device.id = sha256(rawPublicKey) in lowercase hex.
const deviceId = await sha256Hex(pubRaw)
const publicKeyBase64 = toBase64Url(pubRaw)
const privateKeyBase64 = toBase64Url(privPkcs8)
localStorage.setItem(STORAGE_DEVICE_ID, deviceId)
localStorage.setItem(STORAGE_PUBKEY, publicKeyBase64)
localStorage.setItem(STORAGE_PRIVKEY, privateKeyBase64)
return {
deviceId,
publicKeyBase64,
privateKey: keyPair.privateKey,
}
}
// ── Public API ───────────────────────────────────────────────────
/**
* Returns existing device identity from localStorage or generates a new one.
* Throws if Ed25519 is not supported by the browser.
*/
export async function getOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
const storedId = localStorage.getItem(STORAGE_DEVICE_ID)
const storedPub = localStorage.getItem(STORAGE_PUBKEY)
const storedPriv = localStorage.getItem(STORAGE_PRIVKEY)
if (storedId && storedPub && storedPriv) {
try {
const privateKey = await importPrivateKey(fromBase64Url(storedPriv))
return {
deviceId: storedId,
publicKeyBase64: storedPub,
privateKey,
}
} catch {
// Stored key corrupted — regenerate
log.warn('Device identity keys corrupted, regenerating...')
}
}
return createNewIdentity()
}
/**
* Signs an auth payload with the Ed25519 private key.
* Returns base64url signature and signing timestamp.
*/
export async function signPayload(
privateKey: CryptoKey,
payload: string,
signedAt = Date.now()
): Promise<{ signature: string; signedAt: number }> {
const encoder = new TextEncoder()
const payloadBytes = encoder.encode(payload)
const signatureBuffer = await crypto.subtle.sign('Ed25519', privateKey, payloadBytes)
return {
signature: toBase64Url(signatureBuffer),
signedAt,
}
}
/** Reads cached device token from localStorage (returned by gateway on successful connect). */
export function getCachedDeviceToken(): string | null {
return localStorage.getItem(STORAGE_DEVICE_TOKEN)
}
/** Caches the device token returned by the gateway after successful connect. */
export function cacheDeviceToken(token: string): void {
localStorage.setItem(STORAGE_DEVICE_TOKEN, token)
}
/** Removes all device identity data from localStorage (for troubleshooting). */
export function clearDeviceIdentity(): void {
localStorage.removeItem(STORAGE_DEVICE_ID)
localStorage.removeItem(STORAGE_PUBKEY)
localStorage.removeItem(STORAGE_PRIVKEY)
localStorage.removeItem(STORAGE_DEVICE_TOKEN)
}