clawmatrix 0.1.23 → 0.2.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/README.md +4 -1
- package/package.json +4 -2
- package/src/acp-proxy.ts +2073 -0
- package/src/audit.ts +42 -0
- package/src/auth.ts +2 -3
- package/src/cli.ts +76 -2
- package/src/cluster-service.ts +243 -3
- package/src/compat.ts +84 -3
- package/src/config.ts +117 -4
- package/src/connection.ts +288 -85
- package/src/crypto.ts +179 -0
- package/src/debug.ts +15 -2
- package/src/e2e/helpers.ts +318 -0
- package/src/handoff.ts +131 -86
- package/src/identity.ts +95 -0
- package/src/index.ts +467 -52
- package/src/knowledge-sync.ts +776 -207
- package/src/model-proxy.ts +144 -39
- package/src/peer-approval.ts +628 -0
- package/src/peer-manager.ts +261 -32
- package/src/rate-limiter.ts +88 -0
- package/src/router.ts +32 -10
- package/src/sentinel-manager.ts +142 -0
- package/src/sentinel.ts +618 -0
- package/src/task-activity.ts +74 -0
- package/src/terminal.ts +566 -0
- package/src/tool-proxy.ts +127 -3
- package/src/tools/cluster-acp.ts +237 -0
- package/src/tools/cluster-batch.ts +76 -0
- package/src/tools/cluster-diagnostic.ts +174 -0
- package/src/tools/cluster-edit.ts +70 -0
- package/src/tools/cluster-peers.ts +59 -14
- package/src/tools/cluster-terminal.ts +232 -0
- package/src/tools/cluster-tool.ts +26 -11
- package/src/types.ts +475 -3
- package/src/web.ts +2 -2
package/src/handoff.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PeerManager } from "./peer-manager.ts";
|
|
2
2
|
import type { ClawMatrixConfig } from "./config.ts";
|
|
3
|
-
import {
|
|
3
|
+
import type { GatewayInfo } from "./tool-proxy.ts";
|
|
4
4
|
import type {
|
|
5
5
|
HandoffRequest,
|
|
6
6
|
HandoffResponse,
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
HandoffInput,
|
|
13
13
|
HandoffStatus,
|
|
14
14
|
} from "./types.ts";
|
|
15
|
+
import { TaskActivityBroadcaster } from "./task-activity.ts";
|
|
15
16
|
|
|
16
17
|
const DEFAULT_HANDOFF_TIMEOUT = 600_000; // 10 minutes (resets on each stream chunk)
|
|
17
18
|
const MAX_RETRIES = 2;
|
|
@@ -32,7 +33,7 @@ interface PendingHandoff {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
interface ActiveHandoff {
|
|
35
|
-
|
|
36
|
+
abortController: AbortController | null;
|
|
36
37
|
status: HandoffStatus;
|
|
37
38
|
sessionId: string;
|
|
38
39
|
agent: string;
|
|
@@ -44,14 +45,18 @@ interface ActiveHandoff {
|
|
|
44
45
|
export class HandoffManager {
|
|
45
46
|
private config: ClawMatrixConfig;
|
|
46
47
|
private peerManager: PeerManager;
|
|
48
|
+
private gatewayInfo: GatewayInfo;
|
|
47
49
|
private pending = new Map<string, PendingHandoff>();
|
|
48
50
|
private active = new Map<string, ActiveHandoff>();
|
|
49
51
|
private inputRequiredTargets = new Map<string, string>(); // handoffId → targetNodeId
|
|
50
52
|
private staleCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
53
|
+
private taskActivity: TaskActivityBroadcaster;
|
|
51
54
|
|
|
52
|
-
constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
|
|
55
|
+
constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo) {
|
|
53
56
|
this.config = config;
|
|
54
57
|
this.peerManager = peerManager;
|
|
58
|
+
this.gatewayInfo = gatewayInfo;
|
|
59
|
+
this.taskActivity = new TaskActivityBroadcaster(config, peerManager);
|
|
55
60
|
|
|
56
61
|
// Periodically clean up stale input_required entries
|
|
57
62
|
this.staleCleanupTimer = setInterval(() => this.cleanupStale(), STALE_CLEANUP_INTERVAL);
|
|
@@ -260,13 +265,18 @@ export class HandoffManager {
|
|
|
260
265
|
return;
|
|
261
266
|
}
|
|
262
267
|
|
|
263
|
-
|
|
268
|
+
// Derive a stable session key from agent + requester nodeId.
|
|
269
|
+
// This keeps OpenClaw sessions persistent across handoffs from the same peer,
|
|
270
|
+
// avoiding repeated Session Startup (bootstrap file loading).
|
|
271
|
+
const sessionId = payload.sessionId ?? `handoff-${from}`;
|
|
272
|
+
const sessionKey = `agent:${agent.id}:clawmatrix-handoff:${sessionId}`;
|
|
264
273
|
const message = payload.context
|
|
265
274
|
? `${payload.task}\n\nContext:\n${payload.context}`
|
|
266
275
|
: payload.task;
|
|
276
|
+
const images = payload.images;
|
|
267
277
|
|
|
268
278
|
const activeEntry: ActiveHandoff = {
|
|
269
|
-
|
|
279
|
+
abortController: null,
|
|
270
280
|
status: "working",
|
|
271
281
|
sessionId,
|
|
272
282
|
agent: agent.id,
|
|
@@ -276,7 +286,14 @@ export class HandoffManager {
|
|
|
276
286
|
};
|
|
277
287
|
this.active.set(id, activeEntry);
|
|
278
288
|
|
|
279
|
-
|
|
289
|
+
// Broadcast task started to mobile nodes
|
|
290
|
+
this.taskActivity.broadcast(id, "handoff", "started", agent.id, activeEntry.startedAt, payload.task.slice(0, 100));
|
|
291
|
+
|
|
292
|
+
// Send the user's message directly — no special startup handling needed.
|
|
293
|
+
// If this is a new OpenClaw session, the agent will execute its Session Startup
|
|
294
|
+
// naturally (reading AGENTS.md, SOUL.md, etc.) before responding, just like
|
|
295
|
+
// Feishu/Telegram do on a new session with a user message.
|
|
296
|
+
await this.runAgentTurn(id, agent.id, message, sessionKey, from, activeEntry, images);
|
|
280
297
|
}
|
|
281
298
|
|
|
282
299
|
/** Handle incoming handoff_input (resume agent with user reply). */
|
|
@@ -288,66 +305,81 @@ export class HandoffManager {
|
|
|
288
305
|
if (frame.from !== entry.from) return;
|
|
289
306
|
|
|
290
307
|
entry.status = "working";
|
|
291
|
-
|
|
308
|
+
const sessionKey = `agent:${entry.agent}:clawmatrix-handoff:${entry.sessionId}`;
|
|
309
|
+
await this.runAgentTurn(frame.id, entry.agent, frame.payload.message, sessionKey, entry.from, entry, frame.payload.images);
|
|
292
310
|
}
|
|
293
311
|
|
|
294
|
-
/**
|
|
312
|
+
/** Invoke agent via local gateway HTTP API (/v1/chat/completions). */
|
|
295
313
|
private async runAgentTurn(
|
|
296
314
|
id: string,
|
|
297
315
|
agent: string,
|
|
298
316
|
message: string,
|
|
299
|
-
|
|
317
|
+
sessionKey: string,
|
|
300
318
|
from: string,
|
|
301
319
|
activeEntry: ActiveHandoff,
|
|
320
|
+
images?: import("./types.ts").ImageContent[],
|
|
302
321
|
): Promise<void> {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
{ stdout: "pipe", stderr: "pipe" },
|
|
307
|
-
);
|
|
308
|
-
activeEntry.proc = proc;
|
|
322
|
+
const { port, authHeader } = this.gatewayInfo;
|
|
323
|
+
const abortController = new AbortController();
|
|
324
|
+
activeEntry.abortController = abortController;
|
|
309
325
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
326
|
+
if (activeEntry.status === "canceled") {
|
|
327
|
+
this.active.delete(id);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
const headers: Record<string, string> = {
|
|
333
|
+
"Content-Type": "application/json",
|
|
334
|
+
"X-OpenClaw-Agent-Id": agent,
|
|
335
|
+
"X-OpenClaw-Session-Key": sessionKey,
|
|
336
|
+
"X-OpenClaw-Message-Channel": "clawmatrix",
|
|
337
|
+
};
|
|
338
|
+
if (authHeader) headers["Authorization"] = authHeader;
|
|
339
|
+
|
|
340
|
+
// Build message content: plain string if no images, multimodal array otherwise
|
|
341
|
+
let content: string | unknown[];
|
|
342
|
+
if (images && images.length > 0) {
|
|
343
|
+
content = [
|
|
344
|
+
{ type: "text", text: message },
|
|
345
|
+
...images.map((img) => ({
|
|
346
|
+
type: "image_url",
|
|
347
|
+
image_url: { url: `data:${img.mediaType};base64,${img.data}` },
|
|
348
|
+
})),
|
|
349
|
+
];
|
|
350
|
+
} else {
|
|
351
|
+
content = message;
|
|
315
352
|
}
|
|
316
353
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
354
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
355
|
+
method: "POST",
|
|
356
|
+
headers,
|
|
357
|
+
body: JSON.stringify({
|
|
358
|
+
model: "openclaw",
|
|
359
|
+
stream: true,
|
|
360
|
+
messages: [{ role: "user", content }],
|
|
361
|
+
}),
|
|
362
|
+
signal: abortController.signal,
|
|
363
|
+
});
|
|
321
364
|
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
|
|
365
|
+
if (!res.ok) {
|
|
366
|
+
const text = await res.text();
|
|
367
|
+
throw new Error(`Gateway returned ${res.status}: ${text}`);
|
|
325
368
|
}
|
|
326
369
|
|
|
327
|
-
|
|
328
|
-
const marker = "[INPUT_REQUIRED]";
|
|
329
|
-
const question = output.slice(output.indexOf(marker) + marker.length).trim();
|
|
330
|
-
activeEntry.status = "input_required";
|
|
331
|
-
activeEntry.inputRequiredAt = Date.now();
|
|
370
|
+
const output = await this.streamSSE(res, id, from);
|
|
332
371
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
id,
|
|
336
|
-
from: this.config.nodeId,
|
|
337
|
-
to: from,
|
|
338
|
-
timestamp: Date.now(),
|
|
339
|
-
payload: { message: question },
|
|
340
|
-
} satisfies HandoffInputRequired);
|
|
372
|
+
if (activeEntry.status === "canceled") {
|
|
373
|
+
this.active.delete(id);
|
|
341
374
|
return;
|
|
342
375
|
}
|
|
343
376
|
|
|
344
|
-
if (exitCode !== 0) {
|
|
345
|
-
throw new Error(`Agent exited with code ${exitCode}: ${stderr}`);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
377
|
activeEntry.status = "completed";
|
|
349
378
|
this.active.delete(id);
|
|
350
379
|
|
|
380
|
+
// Broadcast task completed
|
|
381
|
+
this.taskActivity.broadcast(id, "handoff", "completed", agent, activeEntry.startedAt);
|
|
382
|
+
|
|
351
383
|
this.peerManager.sendTo(from, {
|
|
352
384
|
type: "handoff_res",
|
|
353
385
|
id,
|
|
@@ -359,6 +391,7 @@ export class HandoffManager {
|
|
|
359
391
|
nodeId: this.config.nodeId,
|
|
360
392
|
agent,
|
|
361
393
|
result: output.trim(),
|
|
394
|
+
sessionId: activeEntry.sessionId,
|
|
362
395
|
},
|
|
363
396
|
} satisfies HandoffResponse);
|
|
364
397
|
} catch (err) {
|
|
@@ -369,6 +402,12 @@ export class HandoffManager {
|
|
|
369
402
|
activeEntry.status = "failed";
|
|
370
403
|
this.active.delete(id);
|
|
371
404
|
|
|
405
|
+
// Broadcast task failed
|
|
406
|
+
this.taskActivity.broadcast(
|
|
407
|
+
id, "handoff", "failed", agent, activeEntry.startedAt,
|
|
408
|
+
err instanceof Error ? err.message : String(err),
|
|
409
|
+
);
|
|
410
|
+
|
|
372
411
|
this.peerManager.sendTo(from, {
|
|
373
412
|
type: "handoff_res",
|
|
374
413
|
id,
|
|
@@ -385,44 +424,66 @@ export class HandoffManager {
|
|
|
385
424
|
}
|
|
386
425
|
}
|
|
387
426
|
|
|
388
|
-
/**
|
|
389
|
-
private async
|
|
390
|
-
|
|
427
|
+
/** Parse SSE streaming response and relay as handoff_stream chunks. */
|
|
428
|
+
private async streamSSE(
|
|
429
|
+
res: Response,
|
|
391
430
|
handoffId: string,
|
|
392
431
|
to: string,
|
|
393
|
-
): Promise<
|
|
394
|
-
|
|
432
|
+
): Promise<string> {
|
|
433
|
+
const body = res.body;
|
|
434
|
+
if (!body) return "";
|
|
395
435
|
|
|
396
|
-
const reader =
|
|
436
|
+
const reader = body.getReader();
|
|
397
437
|
const decoder = new TextDecoder();
|
|
398
438
|
let full = "";
|
|
439
|
+
let buffer = "";
|
|
399
440
|
|
|
400
441
|
try {
|
|
401
442
|
while (true) {
|
|
402
443
|
const { done, value } = await reader.read();
|
|
403
444
|
if (done) break;
|
|
404
445
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
446
|
+
buffer += decoder.decode(value, { stream: true });
|
|
447
|
+
const lines = buffer.split("\n");
|
|
448
|
+
buffer = lines.pop()!; // keep incomplete line
|
|
449
|
+
|
|
450
|
+
for (const line of lines) {
|
|
451
|
+
if (!line.startsWith("data: ")) continue;
|
|
452
|
+
const data = line.slice(6);
|
|
453
|
+
if (data === "[DONE]") continue;
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
const parsed = JSON.parse(data);
|
|
457
|
+
const delta = parsed.choices?.[0]?.delta?.content;
|
|
458
|
+
if (delta) {
|
|
459
|
+
full += delta;
|
|
460
|
+
this.peerManager.sendTo(to, {
|
|
461
|
+
type: "handoff_stream",
|
|
462
|
+
id: handoffId,
|
|
463
|
+
from: this.config.nodeId,
|
|
464
|
+
to,
|
|
465
|
+
timestamp: Date.now(),
|
|
466
|
+
payload: { delta, done: false },
|
|
467
|
+
} satisfies HandoffStreamChunk);
|
|
468
|
+
|
|
469
|
+
// Broadcast progress to mobile nodes (throttled, detail is just
|
|
470
|
+
// a heartbeat — don't send token-level deltas as they're meaningless fragments)
|
|
471
|
+
const activeEntry = this.active.get(handoffId);
|
|
472
|
+
if (activeEntry) {
|
|
473
|
+
this.taskActivity.broadcast(
|
|
474
|
+
handoffId, "handoff", "progress", activeEntry.agent, activeEntry.startedAt,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
} catch {
|
|
479
|
+
// skip malformed SSE lines
|
|
480
|
+
}
|
|
481
|
+
}
|
|
416
482
|
}
|
|
417
483
|
} finally {
|
|
418
484
|
reader.releaseLock();
|
|
419
485
|
}
|
|
420
486
|
|
|
421
|
-
const INPUT_MARKER = "[INPUT_REQUIRED]";
|
|
422
|
-
if (full.indexOf(INPUT_MARKER) !== -1) {
|
|
423
|
-
return { output: full, inputRequired: true };
|
|
424
|
-
}
|
|
425
|
-
|
|
426
487
|
// Send final done marker
|
|
427
488
|
this.peerManager.sendTo(to, {
|
|
428
489
|
type: "handoff_stream",
|
|
@@ -433,7 +494,7 @@ export class HandoffManager {
|
|
|
433
494
|
payload: { delta: "", done: true },
|
|
434
495
|
} satisfies HandoffStreamChunk);
|
|
435
496
|
|
|
436
|
-
return
|
|
497
|
+
return full;
|
|
437
498
|
}
|
|
438
499
|
|
|
439
500
|
/** Handle incoming input_required from remote (requester side). */
|
|
@@ -496,7 +557,7 @@ export class HandoffManager {
|
|
|
496
557
|
|
|
497
558
|
const wasInputRequired = entry.status === "input_required";
|
|
498
559
|
entry.status = "canceled";
|
|
499
|
-
entry.
|
|
560
|
+
entry.abortController?.abort();
|
|
500
561
|
|
|
501
562
|
// If canceled during input_required, no runAgentTurn is running to clean up
|
|
502
563
|
if (wasInputRequired) {
|
|
@@ -572,10 +633,10 @@ export class HandoffManager {
|
|
|
572
633
|
}
|
|
573
634
|
this.pending.clear();
|
|
574
635
|
|
|
575
|
-
//
|
|
636
|
+
// Abort all active tasks (working and input_required)
|
|
576
637
|
for (const [, entry] of this.active) {
|
|
577
638
|
if (entry.status === "working" || entry.status === "input_required") {
|
|
578
|
-
entry.
|
|
639
|
+
entry.abortController?.abort();
|
|
579
640
|
}
|
|
580
641
|
}
|
|
581
642
|
this.active.clear();
|
|
@@ -583,19 +644,3 @@ export class HandoffManager {
|
|
|
583
644
|
}
|
|
584
645
|
}
|
|
585
646
|
|
|
586
|
-
/** Drain a ReadableStream into a string (for stderr consumption). */
|
|
587
|
-
async function drainStream(stream: ReadableStream): Promise<string> {
|
|
588
|
-
const reader = stream.getReader();
|
|
589
|
-
const decoder = new TextDecoder();
|
|
590
|
-
let result = "";
|
|
591
|
-
try {
|
|
592
|
-
while (true) {
|
|
593
|
-
const { done, value } = await reader.read();
|
|
594
|
-
if (done) break;
|
|
595
|
-
result += decoder.decode(value, { stream: true });
|
|
596
|
-
}
|
|
597
|
-
} finally {
|
|
598
|
-
reader.releaseLock();
|
|
599
|
-
}
|
|
600
|
-
return result;
|
|
601
|
-
}
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent node identity based on X25519 key pair.
|
|
3
|
+
*
|
|
4
|
+
* ## Security model (TOFU — Trust On First Use)
|
|
5
|
+
*
|
|
6
|
+
* Each node generates a persistent X25519 key pair stored in `.clawmatrix/identity.json`.
|
|
7
|
+
* The public key serves as the node's cryptographic identity:
|
|
8
|
+
*
|
|
9
|
+
* - On first connection, a new peer's public key is recorded during approval.
|
|
10
|
+
* - On subsequent connections, the peer's public key is verified against the
|
|
11
|
+
* approved record. If the key doesn't match, the peer is treated as a new
|
|
12
|
+
* device and must be re-approved.
|
|
13
|
+
* - This prevents nodeId impersonation: even if an attacker obtains the shared
|
|
14
|
+
* secret and sets the same nodeId, they cannot forge the private key, so
|
|
15
|
+
* their public key won't match the approved record.
|
|
16
|
+
*
|
|
17
|
+
* This is the same trust model used by SSH (known_hosts) and Signal (safety
|
|
18
|
+
* numbers). The known limitation is that the very first connection is vulnerable
|
|
19
|
+
* to MITM — after that, the public key is pinned.
|
|
20
|
+
*
|
|
21
|
+
* The key pair also serves as the E2EE session key exchange material. Unlike
|
|
22
|
+
* the previous ephemeral-per-connection design, using a persistent key pair
|
|
23
|
+
* means forward secrecy is reduced (compromising the private key allows
|
|
24
|
+
* decryption of past sessions). This is an acceptable tradeoff for identity
|
|
25
|
+
* binding — and the session key is still unique per connection due to the
|
|
26
|
+
* ECDH exchange with the peer's key.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import {
|
|
32
|
+
generateX25519KeyPair,
|
|
33
|
+
publicKeyToBase64,
|
|
34
|
+
type KeyPair,
|
|
35
|
+
} from "./crypto.ts";
|
|
36
|
+
|
|
37
|
+
const IDENTITY_FILE = "identity.json";
|
|
38
|
+
|
|
39
|
+
interface IdentityData {
|
|
40
|
+
publicKey: string; // base64
|
|
41
|
+
privateKey: string; // base64 (PKCS8 DER)
|
|
42
|
+
createdAt: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load or generate a persistent X25519 identity key pair.
|
|
47
|
+
* Stored in `<stateDir>/identity.json`.
|
|
48
|
+
*/
|
|
49
|
+
export function loadOrCreateIdentity(stateDir: string): KeyPair {
|
|
50
|
+
const filePath = path.join(stateDir, IDENTITY_FILE);
|
|
51
|
+
|
|
52
|
+
// Try loading existing identity
|
|
53
|
+
try {
|
|
54
|
+
if (fs.existsSync(filePath)) {
|
|
55
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
56
|
+
const data: IdentityData = JSON.parse(raw);
|
|
57
|
+
return keyPairFromSerialized(data.publicKey, data.privateKey);
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Corrupted file — regenerate
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Generate new identity
|
|
64
|
+
const keyPair = generateX25519KeyPair();
|
|
65
|
+
const data: IdentityData = {
|
|
66
|
+
publicKey: publicKeyToBase64(keyPair.publicKey),
|
|
67
|
+
privateKey: keyPair.privateKey.toString("base64"),
|
|
68
|
+
createdAt: Date.now(),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Ensure directory exists
|
|
72
|
+
if (!fs.existsSync(stateDir)) {
|
|
73
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
76
|
+
|
|
77
|
+
return keyPair;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Reconstruct a KeyPair from serialized base64 strings. */
|
|
81
|
+
function keyPairFromSerialized(publicKeyB64: string, privateKeyB64: string): KeyPair {
|
|
82
|
+
const { createPrivateKey } = require("node:crypto");
|
|
83
|
+
const publicKey = Buffer.from(publicKeyB64, "base64");
|
|
84
|
+
const privateKey = Buffer.from(privateKeyB64, "base64");
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
publicKey,
|
|
88
|
+
privateKey,
|
|
89
|
+
_privateKeyObject: createPrivateKey({
|
|
90
|
+
key: privateKey,
|
|
91
|
+
format: "der",
|
|
92
|
+
type: "pkcs8",
|
|
93
|
+
}),
|
|
94
|
+
};
|
|
95
|
+
}
|