fix: align Mission Control device auth handshake with OpenClaw protocol

This commit is contained in:
Othavio Quiliao 2026-03-03 10:51:22 -03:00 committed by GitHub
parent 5f7a2b5029
commit 33f28d6877
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 56 additions and 30 deletions

View File

@ -24,17 +24,19 @@ export interface DeviceIdentity {
// ── Helpers ────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────
function toBase64(buffer: ArrayBuffer): string { function toBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer) const bytes = new Uint8Array(buffer)
let binary = '' let binary = ''
for (let i = 0; i < bytes.byteLength; i++) { for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]) binary += String.fromCharCode(bytes[i])
} }
return btoa(binary) return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
} }
function fromBase64(b64: string): Uint8Array { function fromBase64Url(value: string): Uint8Array {
const binary = atob(b64) 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) const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) { for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i) bytes[i] = binary.charCodeAt(i)
@ -42,14 +44,18 @@ function fromBase64(b64: string): Uint8Array {
return bytes return bytes
} }
function generateUUID(): string { async function sha256Hex(buffer: ArrayBuffer): Promise<string> {
return crypto.randomUUID() 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 ─────────────────────────────────────────────── // ── Key management ───────────────────────────────────────────────
async function importPrivateKey(pkcs8Bytes: Uint8Array): Promise<CryptoKey> { async function importPrivateKey(pkcs8Bytes: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey('pkcs8', pkcs8Bytes.buffer as ArrayBuffer, 'Ed25519', false, ['sign']) return crypto.subtle.importKey('pkcs8', pkcs8Bytes as unknown as BufferSource, 'Ed25519', false, ['sign'])
} }
async function createNewIdentity(): Promise<DeviceIdentity> { async function createNewIdentity(): Promise<DeviceIdentity> {
@ -58,9 +64,10 @@ async function createNewIdentity(): Promise<DeviceIdentity> {
const pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey) const pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey)
const privPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey) const privPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)
const deviceId = generateUUID() // OpenClaw expects device.id = sha256(rawPublicKey) in lowercase hex.
const publicKeyBase64 = toBase64(pubRaw) const deviceId = await sha256Hex(pubRaw)
const privateKeyBase64 = toBase64(privPkcs8) const publicKeyBase64 = toBase64Url(pubRaw)
const privateKeyBase64 = toBase64Url(privPkcs8)
localStorage.setItem(STORAGE_DEVICE_ID, deviceId) localStorage.setItem(STORAGE_DEVICE_ID, deviceId)
localStorage.setItem(STORAGE_PUBKEY, publicKeyBase64) localStorage.setItem(STORAGE_PUBKEY, publicKeyBase64)
@ -86,7 +93,7 @@ export async function getOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
if (storedId && storedPub && storedPriv) { if (storedId && storedPub && storedPriv) {
try { try {
const privateKey = await importPrivateKey(fromBase64(storedPriv)) const privateKey = await importPrivateKey(fromBase64Url(storedPriv))
return { return {
deviceId: storedId, deviceId: storedId,
publicKeyBase64: storedPub, publicKeyBase64: storedPub,
@ -102,19 +109,19 @@ export async function getOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
} }
/** /**
* Signs a server nonce with the Ed25519 private key. * Signs an auth payload with the Ed25519 private key.
* Returns base64-encoded signature and signing timestamp. * Returns base64url signature and signing timestamp.
*/ */
export async function signChallenge( export async function signPayload(
privateKey: CryptoKey, privateKey: CryptoKey,
nonce: string payload: string,
signedAt = Date.now()
): Promise<{ signature: string; signedAt: number }> { ): Promise<{ signature: string; signedAt: number }> {
const encoder = new TextEncoder() const encoder = new TextEncoder()
const nonceBytes = encoder.encode(nonce) const payloadBytes = encoder.encode(payload)
const signedAt = Date.now() const signatureBuffer = await crypto.subtle.sign('Ed25519', privateKey, payloadBytes)
const signatureBuffer = await crypto.subtle.sign('Ed25519', privateKey, nonceBytes)
return { return {
signature: toBase64(signatureBuffer), signature: toBase64Url(signatureBuffer),
signedAt, signedAt,
} }
} }

View File

@ -5,7 +5,7 @@ import { useMissionControl } from '@/store'
import { normalizeModel } from '@/lib/utils' import { normalizeModel } from '@/lib/utils'
import { import {
getOrCreateDeviceIdentity, getOrCreateDeviceIdentity,
signChallenge, signPayload,
getCachedDeviceToken, getCachedDeviceToken,
cacheDeviceToken, cacheDeviceToken,
} from '@/lib/device-identity' } from '@/lib/device-identity'
@ -147,10 +147,33 @@ export function useWebSocket() {
nonce: string nonce: string
} | undefined } | undefined
const cachedToken = getCachedDeviceToken()
const clientId = 'gateway-client'
const clientMode = 'ui'
const role = 'operator'
const scopes = ['operator.admin']
const authToken = authTokenRef.current || undefined
const tokenForSignature = authToken ?? cachedToken ?? ''
if (nonce) { if (nonce) {
try { try {
const identity = await getOrCreateDeviceIdentity() const identity = await getOrCreateDeviceIdentity()
const { signature, signedAt } = await signChallenge(identity.privateKey, nonce) const signedAt = Date.now()
// Sign OpenClaw v2 device-auth payload (gateway accepts v2 and v3).
const payload = [
'v2',
identity.deviceId,
clientId,
clientMode,
role,
scopes.join(','),
String(signedAt),
tokenForSignature,
nonce,
].join('|')
const { signature } = await signPayload(identity.privateKey, payload, signedAt)
device = { device = {
id: identity.deviceId, id: identity.deviceId,
publicKey: identity.publicKeyBase64, publicKey: identity.publicKeyBase64,
@ -163,8 +186,6 @@ export function useWebSocket() {
} }
} }
const cachedToken = getCachedDeviceToken()
const connectRequest = { const connectRequest = {
type: 'req', type: 'req',
method: 'connect', method: 'connect',
@ -173,18 +194,16 @@ export function useWebSocket() {
minProtocol: PROTOCOL_VERSION, minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION, maxProtocol: PROTOCOL_VERSION,
client: { client: {
id: 'gateway-client', id: clientId,
displayName: 'Mission Control', displayName: 'Mission Control',
version: APP_VERSION, version: APP_VERSION,
platform: 'web', platform: 'web',
mode: 'ui', mode: clientMode,
instanceId: `mc-${Date.now()}` instanceId: `mc-${Date.now()}`
}, },
role: 'operator', role,
scopes: ['operator.admin'], scopes,
auth: authTokenRef.current auth: authToken ? { token: authToken } : undefined,
? { token: authTokenRef.current }
: undefined,
device, device,
deviceToken: cachedToken || undefined, deviceToken: cachedToken || undefined,
} }