@dcrays/dcgchat-test 0.2.33 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +2 -1
- package/package.json +1 -1
- package/src/bot.ts +62 -39
- package/src/channel.ts +1 -1
- package/src/cron.ts +125 -0
- package/src/gateway/index.ts +458 -0
- package/src/gateway/security.ts +101 -0
- package/src/gateway/socket.ts +271 -0
- package/src/monitor.ts +42 -15
- package/src/request/api.ts +4 -18
- package/src/request/oss.ts +5 -4
- package/src/tool.ts +4 -2
- package/src/transport.ts +23 -0
- package/src/types.ts +2 -0
- package/src/utils/global.ts +3 -0
- package/src/utils/searchFile.ts +3 -1
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// Gateway connection handler - connects to local OpenClaw gateway
|
|
2
|
+
import { WebSocket } from 'ws'
|
|
3
|
+
import crypto from 'crypto'
|
|
4
|
+
import { deriveDeviceIdFromPublicKey, publicKeyRawBase64UrlFromPem, buildDeviceAuthPayloadV3, signDevicePayload } from './security.js'
|
|
5
|
+
import { dcgLogger } from '../utils/log.js'
|
|
6
|
+
import { sendDcgchatCron, updateCronJobSessionKey } from '../cron.js'
|
|
7
|
+
|
|
8
|
+
export interface GatewayEvent {
|
|
9
|
+
type: string
|
|
10
|
+
payload?: Record<string, unknown>
|
|
11
|
+
seq?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GatewayHelloOk {
|
|
15
|
+
server: {
|
|
16
|
+
connId: string
|
|
17
|
+
}
|
|
18
|
+
features: {
|
|
19
|
+
methods: string[]
|
|
20
|
+
events: string[]
|
|
21
|
+
}
|
|
22
|
+
policy?: {
|
|
23
|
+
tickIntervalMs?: number
|
|
24
|
+
}
|
|
25
|
+
auth?: {
|
|
26
|
+
deviceToken?: string
|
|
27
|
+
role?: string
|
|
28
|
+
scopes?: string[]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GatewayResponse {
|
|
33
|
+
type: 'res'
|
|
34
|
+
id: string
|
|
35
|
+
ok: boolean
|
|
36
|
+
/** 多数 RPC 成功时的返回值 */
|
|
37
|
+
result?: unknown
|
|
38
|
+
/** connect 及部分响应使用 payload(见 docs/gateway/protocol.md) */
|
|
39
|
+
payload?: unknown
|
|
40
|
+
error?: {
|
|
41
|
+
code: string
|
|
42
|
+
message: string
|
|
43
|
+
details?: Record<string, unknown>
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface GatewayEventFrame {
|
|
48
|
+
type: 'event'
|
|
49
|
+
event: string
|
|
50
|
+
payload?: Record<string, unknown>
|
|
51
|
+
seq?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface GatewayHelloOkFrame {
|
|
55
|
+
type: 'hello-ok'
|
|
56
|
+
server: {
|
|
57
|
+
connId: string
|
|
58
|
+
}
|
|
59
|
+
features: {
|
|
60
|
+
methods: string[]
|
|
61
|
+
events: string[]
|
|
62
|
+
}
|
|
63
|
+
policy?: {
|
|
64
|
+
tickIntervalMs?: number
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Union of all possible gateway messages
|
|
69
|
+
export type GatewayMessage = GatewayEventFrame | GatewayResponse | GatewayHelloOkFrame
|
|
70
|
+
|
|
71
|
+
export interface GatewayConfig {
|
|
72
|
+
url: string
|
|
73
|
+
token: string
|
|
74
|
+
role: string
|
|
75
|
+
scopes: string[]
|
|
76
|
+
reconnectInterval?: number
|
|
77
|
+
maxReconnectAttempts?: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Gateway connection handler
|
|
82
|
+
*/
|
|
83
|
+
export class GatewayConnection {
|
|
84
|
+
private ws: WebSocket | null = null
|
|
85
|
+
private config: Required<GatewayConfig>
|
|
86
|
+
private deviceId: string
|
|
87
|
+
private privateKeyPem: string
|
|
88
|
+
private publicKeyB64Url: string
|
|
89
|
+
private connected: boolean = false
|
|
90
|
+
private connId: string | null = null
|
|
91
|
+
/** 服务端 connect.challenge 提供的 nonce,须与签名载荷一致 */
|
|
92
|
+
private connectChallengeNonce: string | null = null
|
|
93
|
+
private connectSent: boolean = false
|
|
94
|
+
private messageHandlers: Map<string, (response: GatewayResponse) => void> = new Map()
|
|
95
|
+
private eventHandlers: Set<(event: GatewayEvent) => void> = new Set()
|
|
96
|
+
|
|
97
|
+
constructor(config: GatewayConfig) {
|
|
98
|
+
this.config = {
|
|
99
|
+
url: config.url,
|
|
100
|
+
token: config.token,
|
|
101
|
+
role: config.role || 'operator',
|
|
102
|
+
scopes: config.scopes || ['operator.admin'],
|
|
103
|
+
reconnectInterval: config.reconnectInterval || 5000,
|
|
104
|
+
maxReconnectAttempts: config.maxReconnectAttempts || 10
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const identity = this.loadOrCreateDeviceIdentity()
|
|
108
|
+
// 必须与公钥指纹一致(deriveDeviceIdFromPublicKey),不可用随机 UUID
|
|
109
|
+
this.deviceId = identity.deviceId
|
|
110
|
+
this.privateKeyPem = identity.privateKeyPem
|
|
111
|
+
this.publicKeyB64Url = identity.publicKeyB64Url
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private loadOrCreateDeviceIdentity() {
|
|
115
|
+
const fs = require('fs')
|
|
116
|
+
const path = require('path')
|
|
117
|
+
const stateDir = path.join(process.cwd(), '.state')
|
|
118
|
+
const deviceFile = path.join(stateDir, 'device.json')
|
|
119
|
+
|
|
120
|
+
// Try to load existing identity
|
|
121
|
+
if (fs.existsSync(deviceFile)) {
|
|
122
|
+
const stored = JSON.parse(fs.readFileSync(deviceFile, 'utf8'))
|
|
123
|
+
if (stored.deviceId && stored.publicKeyPem && stored.privateKeyPem) {
|
|
124
|
+
const derivedId = deriveDeviceIdFromPublicKey(stored.publicKeyPem)
|
|
125
|
+
const deviceId = derivedId !== stored.deviceId ? derivedId : stored.deviceId
|
|
126
|
+
if (derivedId !== stored.deviceId) {
|
|
127
|
+
try {
|
|
128
|
+
fs.writeFileSync(deviceFile, JSON.stringify({ ...stored, deviceId: derivedId }, null, 2))
|
|
129
|
+
} catch {
|
|
130
|
+
/* keep in-memory fixed id only */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
deviceId,
|
|
135
|
+
publicKeyPem: stored.publicKeyPem,
|
|
136
|
+
privateKeyPem: stored.privateKeyPem,
|
|
137
|
+
publicKeyB64Url: publicKeyRawBase64UrlFromPem(stored.publicKeyPem)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Create new identity
|
|
143
|
+
const keyPair = crypto.generateKeyPairSync('ed25519')
|
|
144
|
+
const publicKeyPem = keyPair.publicKey.export({ type: 'spki', format: 'pem' }).toString()
|
|
145
|
+
const privateKeyPem = keyPair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
|
|
146
|
+
const deviceId = deriveDeviceIdFromPublicKey(publicKeyPem)
|
|
147
|
+
const publicKeyB64Url = publicKeyRawBase64UrlFromPem(publicKeyPem)
|
|
148
|
+
|
|
149
|
+
// Ensure directory exists
|
|
150
|
+
if (!fs.existsSync(stateDir)) {
|
|
151
|
+
fs.mkdirSync(stateDir, { recursive: true })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Save identity
|
|
155
|
+
fs.writeFileSync(
|
|
156
|
+
deviceFile,
|
|
157
|
+
JSON.stringify(
|
|
158
|
+
{
|
|
159
|
+
version: 1,
|
|
160
|
+
deviceId,
|
|
161
|
+
publicKeyPem,
|
|
162
|
+
privateKeyPem,
|
|
163
|
+
createdAtMs: Date.now()
|
|
164
|
+
},
|
|
165
|
+
null,
|
|
166
|
+
2
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
fs.chmodSync(deviceFile, 0o600)
|
|
170
|
+
|
|
171
|
+
return { deviceId, publicKeyPem, privateKeyPem, publicKeyB64Url }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Connect to the gateway
|
|
176
|
+
*/
|
|
177
|
+
async connect(): Promise<GatewayHelloOk> {
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
this.connectChallengeNonce = null
|
|
180
|
+
this.connectSent = false
|
|
181
|
+
this.ws = new WebSocket(this.config.url)
|
|
182
|
+
|
|
183
|
+
let handshakeSettled = false
|
|
184
|
+
const finishHandshake = (fn: () => void) => {
|
|
185
|
+
if (handshakeSettled) return
|
|
186
|
+
handshakeSettled = true
|
|
187
|
+
clearTimeout(timeout)
|
|
188
|
+
fn()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const timeout = setTimeout(() => {
|
|
192
|
+
finishHandshake(() => reject(new Error('Gateway connection timeout')))
|
|
193
|
+
}, 15000)
|
|
194
|
+
|
|
195
|
+
this.ws.on('open', () => {
|
|
196
|
+
dcgLogger('Gateway connection opened(等待 connect.challenge)')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
this.ws.on('message', (data) => {
|
|
200
|
+
try {
|
|
201
|
+
const msg = JSON.parse(data.toString())
|
|
202
|
+
this.handleMessage(
|
|
203
|
+
msg,
|
|
204
|
+
(hello) => finishHandshake(() => resolve(hello)),
|
|
205
|
+
(err) => finishHandshake(() => reject(err)),
|
|
206
|
+
timeout
|
|
207
|
+
)
|
|
208
|
+
} catch (err) {
|
|
209
|
+
dcgLogger(`[Gateway] 解析消息失败: ${err}`, 'error')
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
this.ws.on('close', () => {
|
|
214
|
+
this.connected = false
|
|
215
|
+
finishHandshake(() => reject(new Error('Gateway 在握手完成前关闭了连接')))
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
this.ws.on('error', (err) => {
|
|
219
|
+
console.log('🚀 ~ GatewayConnection ~ connect ~ err:', err)
|
|
220
|
+
finishHandshake(() => reject(err))
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Send initial connect request
|
|
227
|
+
*/
|
|
228
|
+
private sendConnect(): void {
|
|
229
|
+
if (this.connectSent) return
|
|
230
|
+
const nonce = this.connectChallengeNonce?.trim() ?? ''
|
|
231
|
+
if (!nonce) return
|
|
232
|
+
|
|
233
|
+
this.connectSent = true
|
|
234
|
+
const signedAtMs = Date.now()
|
|
235
|
+
const platform = process.platform
|
|
236
|
+
|
|
237
|
+
const payload = buildDeviceAuthPayloadV3({
|
|
238
|
+
deviceId: this.deviceId,
|
|
239
|
+
clientId: 'gateway-client',
|
|
240
|
+
clientMode: 'backend',
|
|
241
|
+
role: this.config.role,
|
|
242
|
+
scopes: this.config.scopes,
|
|
243
|
+
signedAtMs,
|
|
244
|
+
token: this.config.token,
|
|
245
|
+
nonce,
|
|
246
|
+
platform,
|
|
247
|
+
deviceFamily: ''
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
const signature = signDevicePayload(this.privateKeyPem, payload)
|
|
251
|
+
|
|
252
|
+
this.ws?.send(
|
|
253
|
+
JSON.stringify({
|
|
254
|
+
type: 'req',
|
|
255
|
+
id: '1',
|
|
256
|
+
method: 'connect',
|
|
257
|
+
params: {
|
|
258
|
+
minProtocol: 3,
|
|
259
|
+
maxProtocol: 3,
|
|
260
|
+
client: {
|
|
261
|
+
id: 'gateway-client',
|
|
262
|
+
version: '1.0.0',
|
|
263
|
+
platform,
|
|
264
|
+
mode: 'backend'
|
|
265
|
+
},
|
|
266
|
+
auth: { token: this.config.token },
|
|
267
|
+
role: this.config.role,
|
|
268
|
+
scopes: this.config.scopes,
|
|
269
|
+
device: {
|
|
270
|
+
id: this.deviceId,
|
|
271
|
+
publicKey: this.publicKeyB64Url,
|
|
272
|
+
signature,
|
|
273
|
+
signedAt: signedAtMs,
|
|
274
|
+
nonce
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Handle incoming messages
|
|
283
|
+
*/
|
|
284
|
+
private mapHelloPayloadToHelloOk(payload: Record<string, unknown>): GatewayHelloOk {
|
|
285
|
+
const serverRaw = payload.server as Record<string, unknown> | undefined
|
|
286
|
+
const featuresRaw = payload.features as Record<string, unknown> | undefined
|
|
287
|
+
return {
|
|
288
|
+
server: { connId: typeof serverRaw?.connId === 'string' ? serverRaw.connId : '' },
|
|
289
|
+
features: {
|
|
290
|
+
methods: Array.isArray(featuresRaw?.methods) ? (featuresRaw.methods as string[]) : [],
|
|
291
|
+
events: Array.isArray(featuresRaw?.events) ? (featuresRaw.events as string[]) : []
|
|
292
|
+
},
|
|
293
|
+
policy: payload.policy as GatewayHelloOk['policy'],
|
|
294
|
+
auth: payload.auth as GatewayHelloOk['auth']
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private handleMessage(
|
|
299
|
+
msg: Record<string, any>,
|
|
300
|
+
resolveHello: (helloOk: GatewayHelloOk) => void,
|
|
301
|
+
rejectHello: (err: Error) => void,
|
|
302
|
+
timeout: NodeJS.Timeout
|
|
303
|
+
): void {
|
|
304
|
+
const msgType = msg.type as string | undefined
|
|
305
|
+
|
|
306
|
+
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
|
307
|
+
const payload = msg.payload as Record<string, unknown> | undefined
|
|
308
|
+
const nonce = typeof payload?.nonce === 'string' ? payload.nonce.trim() : ''
|
|
309
|
+
if (!nonce) {
|
|
310
|
+
rejectHello(new Error('connect.challenge 缺少 nonce'))
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
this.connectChallengeNonce = nonce
|
|
314
|
+
this.sendConnect()
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 协议 v3:握手成功为 type:"res" + payload.type:"hello-ok"(见 openclaw docs/gateway/protocol.md)
|
|
319
|
+
if (msg.type === 'res' && !this.connected) {
|
|
320
|
+
const ok = msg.ok === true
|
|
321
|
+
const inner = msg.payload as Record<string, unknown> | undefined
|
|
322
|
+
if (ok && inner && inner.type === 'hello-ok') {
|
|
323
|
+
this.connected = true
|
|
324
|
+
const serverRaw = inner.server as Record<string, unknown> | undefined
|
|
325
|
+
this.connId = typeof serverRaw?.connId === 'string' ? serverRaw.connId : null
|
|
326
|
+
clearTimeout(timeout)
|
|
327
|
+
dcgLogger(`[Gateway] 已连接 connId=${this.connId ?? '(none)'}`)
|
|
328
|
+
resolveHello(this.mapHelloPayloadToHelloOk(inner))
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
if (msg.ok === false) {
|
|
332
|
+
const errObj = msg.error as Record<string, unknown> | undefined
|
|
333
|
+
const message = (typeof errObj?.message === 'string' && errObj.message) || 'Gateway connect 被拒绝'
|
|
334
|
+
clearTimeout(timeout)
|
|
335
|
+
rejectHello(new Error(message))
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 旧式或其它实现:顶层 hello-ok
|
|
341
|
+
if (msgType === 'hello-ok' || (!msgType && msg.server)) {
|
|
342
|
+
this.connected = true
|
|
343
|
+
this.connId = ((msg.server as Record<string, unknown>)?.connId as string) || null
|
|
344
|
+
clearTimeout(timeout)
|
|
345
|
+
dcgLogger(`[Gateway] 已连接 connId=${this.connId ?? '(none)'}`)
|
|
346
|
+
resolveHello(msg as unknown as GatewayHelloOk)
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
if (msg.type === 'res') {
|
|
350
|
+
const handler = this.messageHandlers.get(msg.id as string)
|
|
351
|
+
if (handler) {
|
|
352
|
+
this.messageHandlers.delete(msg.id as string)
|
|
353
|
+
handler(msg as unknown as GatewayResponse)
|
|
354
|
+
}
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (msg.type === 'event') {
|
|
359
|
+
if (msg.event === 'cron') {
|
|
360
|
+
dcgLogger(`[Gateway] 收到事件: ${JSON.stringify(msg)}`)
|
|
361
|
+
if (msg.payload?.action === 'added') {
|
|
362
|
+
updateCronJobSessionKey(msg.payload?.jobId as string)
|
|
363
|
+
}
|
|
364
|
+
if (msg.payload?.action === 'updated') {
|
|
365
|
+
sendDcgchatCron()
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const event: GatewayEvent = {
|
|
369
|
+
type: msg.event as string,
|
|
370
|
+
payload: msg.payload as Record<string, unknown> | undefined,
|
|
371
|
+
seq: msg.seq as number | undefined
|
|
372
|
+
}
|
|
373
|
+
this.eventHandlers.forEach((h) => h(event))
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Call a gateway method
|
|
379
|
+
*/
|
|
380
|
+
async callMethod<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
|
381
|
+
const id = crypto.randomUUID()
|
|
382
|
+
|
|
383
|
+
return new Promise((resolve, reject) => {
|
|
384
|
+
if (!this.connected || !this.ws) {
|
|
385
|
+
reject(new Error('Not connected to gateway'))
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const timeout = setTimeout(() => {
|
|
390
|
+
this.messageHandlers.delete(id)
|
|
391
|
+
reject(new Error('Method call timeout'))
|
|
392
|
+
}, 30000)
|
|
393
|
+
|
|
394
|
+
this.messageHandlers.set(id, (response) => {
|
|
395
|
+
clearTimeout(timeout)
|
|
396
|
+
if (response.ok) {
|
|
397
|
+
const body = response.result !== undefined ? response.result : (response as GatewayResponse).payload
|
|
398
|
+
resolve(body as T)
|
|
399
|
+
} else {
|
|
400
|
+
reject(new Error(response.error?.message || 'Method call failed'))
|
|
401
|
+
}
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
this.ws.send(
|
|
405
|
+
JSON.stringify({
|
|
406
|
+
type: 'req',
|
|
407
|
+
id,
|
|
408
|
+
method,
|
|
409
|
+
params
|
|
410
|
+
})
|
|
411
|
+
)
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Register event handler
|
|
417
|
+
*/
|
|
418
|
+
onEvent(handler: (event: GatewayEvent) => void): () => void {
|
|
419
|
+
this.eventHandlers.add(handler)
|
|
420
|
+
return () => this.eventHandlers.delete(handler)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Close connection
|
|
425
|
+
*/
|
|
426
|
+
close(): void {
|
|
427
|
+
this.ws?.close(1000, 'Plugin stopped')
|
|
428
|
+
this.connected = false
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Check if connected
|
|
433
|
+
*/
|
|
434
|
+
isConnected(): boolean {
|
|
435
|
+
return this.connected
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Get connection ID
|
|
440
|
+
*/
|
|
441
|
+
getConnId(): string | null {
|
|
442
|
+
return this.connId
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Get the WebSocket instance (for external use)
|
|
447
|
+
*/
|
|
448
|
+
getWebSocket(): WebSocket | null {
|
|
449
|
+
return this.ws
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Ping the gateway
|
|
454
|
+
*/
|
|
455
|
+
ping(): void {
|
|
456
|
+
this.ws?.ping()
|
|
457
|
+
}
|
|
458
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Security utilities for the tunnel plugin
|
|
2
|
+
import crypto from 'crypto'
|
|
3
|
+
import fs from 'fs'
|
|
4
|
+
import jwt from 'jsonwebtoken'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
|
|
7
|
+
// ED25519 SubjectPublicKeyInfo prefix (must match openclaw gateway `ED25519_SPKI_PREFIX`)
|
|
8
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Raw 32-byte Ed25519 public key bytes from PEM (same rules as openclaw `derivePublicKeyRaw`).
|
|
12
|
+
*/
|
|
13
|
+
export function derivePublicKeyRawFromPem(publicKeyPem: string): Buffer {
|
|
14
|
+
const spki = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' }) as Buffer
|
|
15
|
+
if (
|
|
16
|
+
spki.length === ED25519_SPKI_PREFIX.length + 32 &&
|
|
17
|
+
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
|
|
18
|
+
) {
|
|
19
|
+
return spki.subarray(ED25519_SPKI_PREFIX.length)
|
|
20
|
+
}
|
|
21
|
+
return spki
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Base64url encode (no padding)
|
|
26
|
+
*/
|
|
27
|
+
export function base64UrlEncode(buffer: Buffer): string {
|
|
28
|
+
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Wire-format device public key (base64url of raw 32 bytes), matches gateway expectations. */
|
|
32
|
+
export function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string {
|
|
33
|
+
return base64UrlEncode(derivePublicKeyRawFromPem(publicKeyPem))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Derive device ID from ED25519 public key
|
|
38
|
+
* Device ID = sha256(rawPublicKey)
|
|
39
|
+
*/
|
|
40
|
+
export function deriveDeviceIdFromPublicKey(publicKeyPem: string): string {
|
|
41
|
+
const rawPublicKey = derivePublicKeyRawFromPem(publicKeyPem)
|
|
42
|
+
return crypto.createHash('sha256').update(rawPublicKey).digest('hex')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sign device payload
|
|
47
|
+
*/
|
|
48
|
+
export function signDevicePayload(privateKeyPem: string, payload: string, encoding: 'base64' | 'base64url' = 'base64url'): string {
|
|
49
|
+
const key = crypto.createPrivateKey(privateKeyPem)
|
|
50
|
+
const sig = crypto.sign(null, Buffer.from(payload, 'utf8'), key)
|
|
51
|
+
return encoding === 'base64url' ? base64UrlEncode(sig) : sig.toString('base64')
|
|
52
|
+
}
|
|
53
|
+
function normalizeTrimmedMetadata(value: unknown): string {
|
|
54
|
+
if (typeof value !== 'string') return ''
|
|
55
|
+
const trimmed = value.trim()
|
|
56
|
+
return trimmed ? trimmed : ''
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toLowerAscii(input: string): string {
|
|
60
|
+
return input.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeDeviceMetadataForAuth(value: unknown): string {
|
|
64
|
+
const trimmed = normalizeTrimmedMetadata(value)
|
|
65
|
+
if (!trimmed) return ''
|
|
66
|
+
return toLowerAscii(trimmed)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Device authentication payload v3(与 openclaw `buildDeviceAuthPayloadV3` 一致)
|
|
71
|
+
*/
|
|
72
|
+
export function buildDeviceAuthPayloadV3(params: {
|
|
73
|
+
deviceId: string
|
|
74
|
+
clientId: string
|
|
75
|
+
clientMode: string
|
|
76
|
+
role: string
|
|
77
|
+
scopes: string[]
|
|
78
|
+
signedAtMs: number
|
|
79
|
+
token: string
|
|
80
|
+
nonce: string
|
|
81
|
+
platform?: string
|
|
82
|
+
deviceFamily?: string
|
|
83
|
+
}): string {
|
|
84
|
+
const scopes = params.scopes.join(',')
|
|
85
|
+
const token = params.token ?? ''
|
|
86
|
+
const platform = normalizeDeviceMetadataForAuth(params.platform ?? '')
|
|
87
|
+
const deviceFamily = normalizeDeviceMetadataForAuth(params.deviceFamily ?? '')
|
|
88
|
+
return [
|
|
89
|
+
'v3',
|
|
90
|
+
params.deviceId,
|
|
91
|
+
params.clientId,
|
|
92
|
+
params.clientMode,
|
|
93
|
+
params.role,
|
|
94
|
+
scopes,
|
|
95
|
+
String(params.signedAtMs),
|
|
96
|
+
token,
|
|
97
|
+
params.nonce,
|
|
98
|
+
platform,
|
|
99
|
+
deviceFamily
|
|
100
|
+
].join('|')
|
|
101
|
+
}
|