Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 51 additions & 4 deletions electron/gateway/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ import { GatewayEventType, JsonRpcNotification, isNotification, isResponse } fro
import { logger } from '../utils/logger';
import { getUvMirrorEnv } from '../utils/uv-env';
import { isPythonReady, setupManagedPython } from '../utils/uv-setup';
import {
loadOrCreateDeviceIdentity,
signDevicePayload,
publicKeyRawBase64UrlFromPem,
buildDeviceAuthPayload,
type DeviceIdentity,
} from '../utils/device-identity';

/**
* Gateway connection status
Expand Down Expand Up @@ -120,10 +127,22 @@ export class GatewayManager extends EventEmitter {
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}> = new Map();
private deviceIdentity: DeviceIdentity | null = null;

constructor(config?: Partial<ReconnectConfig>) {
super();
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
this.initDeviceIdentity();
}

private initDeviceIdentity(): void {
try {
const identityPath = path.join(app.getPath('userData'), 'clawx-device-identity.json');
this.deviceIdentity = loadOrCreateDeviceIdentity(identityPath);
logger.debug(`Device identity loaded (deviceId=${this.deviceIdentity.deviceId})`);
} catch (err) {
logger.warn('Failed to load device identity, scopes will be limited:', err);
}
}

private sanitizeSpawnArgs(args: string[]): string[] {
Expand Down Expand Up @@ -757,7 +776,34 @@ export class GatewayManager extends EventEmitter {

// Send proper connect handshake as required by OpenClaw Gateway protocol
// The Gateway expects: { type: "req", id: "...", method: "connect", params: ConnectParams }
// Since 2026.2.15, scopes are only granted when a signed device identity is included.
connectId = `connect-${Date.now()}`;
const role = 'operator';
const scopes = ['operator.admin'];
const signedAtMs = Date.now();
const clientId = 'gateway-client';
const clientMode = 'ui';

const device = (() => {
if (!this.deviceIdentity) return undefined;
const payload = buildDeviceAuthPayload({
deviceId: this.deviceIdentity.deviceId,
clientId,
clientMode,
role,
scopes,
signedAtMs,
token: gatewayToken ?? null,
});
const signature = signDevicePayload(this.deviceIdentity.privateKeyPem, payload);
return {
id: this.deviceIdentity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(this.deviceIdentity.publicKeyPem),
signature,
signedAt: signedAtMs,
};
})();

const connectFrame = {
type: 'req',
id: connectId,
Expand All @@ -766,18 +812,19 @@ export class GatewayManager extends EventEmitter {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'gateway-client',
id: clientId,
displayName: 'ClawX',
version: '0.1.0',
platform: process.platform,
mode: 'ui',
mode: clientMode,
},
auth: {
token: gatewayToken,
},
caps: [],
role: 'operator',
scopes: [],
role,
scopes,
device,
},
};

Expand Down
128 changes: 128 additions & 0 deletions electron/utils/device-identity.ts
Original file line number Diff line number Diff line change
@@ -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('|');
}
64 changes: 32 additions & 32 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,69 +42,69 @@
"postversion": "git push && git push --tags"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.2",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clawhub": "^0.5.0",
"clsx": "^2.1.1",
"electron-store": "^11.0.2",
"electron-updater": "^6.8.2",
"framer-motion": "^12.33.0",
"i18next": "^25.8.4",
"electron-updater": "^6.8.3",
"framer-motion": "^12.34.1",
"i18next": "^25.8.10",
"lucide-react": "^0.563.0",
"openclaw": "2026.2.6-3",
"openclaw": "2026.2.15",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.4",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwind-merge": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"ws": "^8.19.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.49.1",
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.1.0",
"@types/node": "^25.2.1",
"@types/react": "^19.2.13",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.5.13",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"@vitejs/plugin-react": "^5.1.3",
"autoprefixer": "^10.4.20",
"electron": "^40.2.1",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.56.0",
"@typescript-eslint/parser": "^8.56.0",
"@vitejs/plugin-react": "^5.1.4",
"autoprefixer": "^10.4.24",
"electron": "^40.4.1",
"electron-builder": "^26.7.0",
"eslint": "^10.0.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.0",
"globals": "^17.3.0",
"jsdom": "^28.0.0",
"jsdom": "^28.1.0",
"png2icons": "^2.0.1",
"postcss": "^8.4.49",
"postcss": "^8.5.6",
"sharp": "^0.34.5",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.6",
"vitest": "^4.0.18",
"zx": "^8.8.5"
},
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
}
}
Loading