fix: align Mission Control device auth handshake with OpenClaw protocol
This commit is contained in:
parent
5f7a2b5029
commit
33f28d6877
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue