clawmatrix 0.1.23 → 0.2.1
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 +2183 -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 +171 -92
- package/src/identity.ts +95 -0
- package/src/index.ts +433 -58
- 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 +477 -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,19 +45,46 @@ 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;
|
|
54
|
+
// Multi-device sync: track which nodes are watching each handoff session (by sessionId)
|
|
55
|
+
private sessionWatchers = new Map<string, Set<string>>();
|
|
51
56
|
|
|
52
|
-
constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
|
|
57
|
+
constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo) {
|
|
53
58
|
this.config = config;
|
|
54
59
|
this.peerManager = peerManager;
|
|
60
|
+
this.gatewayInfo = gatewayInfo;
|
|
61
|
+
this.taskActivity = new TaskActivityBroadcaster(config, peerManager);
|
|
55
62
|
|
|
56
63
|
// Periodically clean up stale input_required entries
|
|
57
64
|
this.staleCleanupTimer = setInterval(() => this.cleanupStale(), STALE_CLEANUP_INTERVAL);
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
// ── Multi-device sync helpers ──────────────────────────────────
|
|
68
|
+
|
|
69
|
+
private addSessionWatcher(sessionId: string, nodeId: string) {
|
|
70
|
+
let watchers = this.sessionWatchers.get(sessionId);
|
|
71
|
+
if (!watchers) {
|
|
72
|
+
watchers = new Set();
|
|
73
|
+
this.sessionWatchers.set(sessionId, watchers);
|
|
74
|
+
}
|
|
75
|
+
watchers.add(nodeId);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Send a handoff frame to all session watchers except the specified node. */
|
|
79
|
+
private sendToOtherWatchers(sessionId: string, exclude: string, frame: HandoffStreamChunk | HandoffResponse | HandoffInputRequired) {
|
|
80
|
+
const watchers = this.sessionWatchers.get(sessionId);
|
|
81
|
+
if (!watchers) return;
|
|
82
|
+
for (const nodeId of watchers) {
|
|
83
|
+
if (nodeId === exclude) continue;
|
|
84
|
+
this.peerManager.sendTo(nodeId, { ...frame, to: nodeId });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
60
88
|
/** Remove stale input_required and canceled entries that exceeded TTL. */
|
|
61
89
|
private cleanupStale() {
|
|
62
90
|
const now = Date.now();
|
|
@@ -260,13 +288,21 @@ export class HandoffManager {
|
|
|
260
288
|
return;
|
|
261
289
|
}
|
|
262
290
|
|
|
263
|
-
|
|
291
|
+
// Derive a stable session key from agent + requester nodeId.
|
|
292
|
+
// This keeps OpenClaw sessions persistent across handoffs from the same peer,
|
|
293
|
+
// avoiding repeated Session Startup (bootstrap file loading).
|
|
294
|
+
const sessionId = payload.sessionId ?? `handoff-${from}`;
|
|
295
|
+
const sessionKey = `agent:${agent.id}:clawmatrix-handoff:${sessionId}`;
|
|
264
296
|
const message = payload.context
|
|
265
297
|
? `${payload.task}\n\nContext:\n${payload.context}`
|
|
266
298
|
: payload.task;
|
|
299
|
+
const images = payload.images;
|
|
300
|
+
|
|
301
|
+
// Track this node as a session watcher for multi-device sync
|
|
302
|
+
this.addSessionWatcher(sessionId, from);
|
|
267
303
|
|
|
268
304
|
const activeEntry: ActiveHandoff = {
|
|
269
|
-
|
|
305
|
+
abortController: null,
|
|
270
306
|
status: "working",
|
|
271
307
|
sessionId,
|
|
272
308
|
agent: agent.id,
|
|
@@ -276,7 +312,14 @@ export class HandoffManager {
|
|
|
276
312
|
};
|
|
277
313
|
this.active.set(id, activeEntry);
|
|
278
314
|
|
|
279
|
-
|
|
315
|
+
// Broadcast task started to mobile nodes
|
|
316
|
+
this.taskActivity.broadcast(id, "handoff", "started", agent.id, activeEntry.startedAt, payload.task.slice(0, 100));
|
|
317
|
+
|
|
318
|
+
// Send the user's message directly — no special startup handling needed.
|
|
319
|
+
// If this is a new OpenClaw session, the agent will execute its Session Startup
|
|
320
|
+
// naturally (reading AGENTS.md, SOUL.md, etc.) before responding, just like
|
|
321
|
+
// Feishu/Telegram do on a new session with a user message.
|
|
322
|
+
await this.runAgentTurn(id, agent.id, message, sessionKey, from, activeEntry, images);
|
|
280
323
|
}
|
|
281
324
|
|
|
282
325
|
/** Handle incoming handoff_input (resume agent with user reply). */
|
|
@@ -288,67 +331,82 @@ export class HandoffManager {
|
|
|
288
331
|
if (frame.from !== entry.from) return;
|
|
289
332
|
|
|
290
333
|
entry.status = "working";
|
|
291
|
-
|
|
334
|
+
const sessionKey = `agent:${entry.agent}:clawmatrix-handoff:${entry.sessionId}`;
|
|
335
|
+
await this.runAgentTurn(frame.id, entry.agent, frame.payload.message, sessionKey, entry.from, entry, frame.payload.images);
|
|
292
336
|
}
|
|
293
337
|
|
|
294
|
-
/**
|
|
338
|
+
/** Invoke agent via local gateway HTTP API (/v1/chat/completions). */
|
|
295
339
|
private async runAgentTurn(
|
|
296
340
|
id: string,
|
|
297
341
|
agent: string,
|
|
298
342
|
message: string,
|
|
299
|
-
|
|
343
|
+
sessionKey: string,
|
|
300
344
|
from: string,
|
|
301
345
|
activeEntry: ActiveHandoff,
|
|
346
|
+
images?: import("./types.ts").ImageContent[],
|
|
302
347
|
): Promise<void> {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
{ stdout: "pipe", stderr: "pipe" },
|
|
307
|
-
);
|
|
308
|
-
activeEntry.proc = proc;
|
|
348
|
+
const { port, authHeader } = this.gatewayInfo;
|
|
349
|
+
const abortController = new AbortController();
|
|
350
|
+
activeEntry.abortController = abortController;
|
|
309
351
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
352
|
+
if (activeEntry.status === "canceled") {
|
|
353
|
+
this.active.delete(id);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const headers: Record<string, string> = {
|
|
359
|
+
"Content-Type": "application/json",
|
|
360
|
+
"X-OpenClaw-Agent-Id": agent,
|
|
361
|
+
"X-OpenClaw-Session-Key": sessionKey,
|
|
362
|
+
"X-OpenClaw-Message-Channel": "clawmatrix",
|
|
363
|
+
};
|
|
364
|
+
if (authHeader) headers["Authorization"] = authHeader;
|
|
365
|
+
|
|
366
|
+
// Build message content: plain string if no images, multimodal array otherwise
|
|
367
|
+
let content: string | unknown[];
|
|
368
|
+
if (images && images.length > 0) {
|
|
369
|
+
content = [
|
|
370
|
+
{ type: "text", text: message },
|
|
371
|
+
...images.map((img) => ({
|
|
372
|
+
type: "image_url",
|
|
373
|
+
image_url: { url: `data:${img.mediaType};base64,${img.data}` },
|
|
374
|
+
})),
|
|
375
|
+
];
|
|
376
|
+
} else {
|
|
377
|
+
content = message;
|
|
315
378
|
}
|
|
316
379
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
380
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
381
|
+
method: "POST",
|
|
382
|
+
headers,
|
|
383
|
+
body: JSON.stringify({
|
|
384
|
+
model: "openclaw",
|
|
385
|
+
stream: true,
|
|
386
|
+
messages: [{ role: "user", content }],
|
|
387
|
+
}),
|
|
388
|
+
signal: abortController.signal,
|
|
389
|
+
});
|
|
321
390
|
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
|
|
391
|
+
if (!res.ok) {
|
|
392
|
+
const text = await res.text();
|
|
393
|
+
throw new Error(`Gateway returned ${res.status}: ${text}`);
|
|
325
394
|
}
|
|
326
395
|
|
|
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();
|
|
396
|
+
const output = await this.streamSSE(res, id, from, activeEntry.sessionId);
|
|
332
397
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
id,
|
|
336
|
-
from: this.config.nodeId,
|
|
337
|
-
to: from,
|
|
338
|
-
timestamp: Date.now(),
|
|
339
|
-
payload: { message: question },
|
|
340
|
-
} satisfies HandoffInputRequired);
|
|
398
|
+
if (activeEntry.status === "canceled") {
|
|
399
|
+
this.active.delete(id);
|
|
341
400
|
return;
|
|
342
401
|
}
|
|
343
402
|
|
|
344
|
-
if (exitCode !== 0) {
|
|
345
|
-
throw new Error(`Agent exited with code ${exitCode}: ${stderr}`);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
403
|
activeEntry.status = "completed";
|
|
349
404
|
this.active.delete(id);
|
|
350
405
|
|
|
351
|
-
|
|
406
|
+
// Broadcast task completed
|
|
407
|
+
this.taskActivity.broadcast(id, "handoff", "completed", agent, activeEntry.startedAt);
|
|
408
|
+
|
|
409
|
+
const resFrame: HandoffResponse = {
|
|
352
410
|
type: "handoff_res",
|
|
353
411
|
id,
|
|
354
412
|
from: this.config.nodeId,
|
|
@@ -359,8 +417,11 @@ export class HandoffManager {
|
|
|
359
417
|
nodeId: this.config.nodeId,
|
|
360
418
|
agent,
|
|
361
419
|
result: output.trim(),
|
|
420
|
+
sessionId: activeEntry.sessionId,
|
|
362
421
|
},
|
|
363
|
-
}
|
|
422
|
+
};
|
|
423
|
+
this.peerManager.sendTo(from, resFrame);
|
|
424
|
+
this.sendToOtherWatchers(activeEntry.sessionId, from, resFrame);
|
|
364
425
|
} catch (err) {
|
|
365
426
|
if (activeEntry.status === "canceled") {
|
|
366
427
|
this.active.delete(id);
|
|
@@ -369,6 +430,12 @@ export class HandoffManager {
|
|
|
369
430
|
activeEntry.status = "failed";
|
|
370
431
|
this.active.delete(id);
|
|
371
432
|
|
|
433
|
+
// Broadcast task failed
|
|
434
|
+
this.taskActivity.broadcast(
|
|
435
|
+
id, "handoff", "failed", agent, activeEntry.startedAt,
|
|
436
|
+
err instanceof Error ? err.message : String(err),
|
|
437
|
+
);
|
|
438
|
+
|
|
372
439
|
this.peerManager.sendTo(from, {
|
|
373
440
|
type: "handoff_res",
|
|
374
441
|
id,
|
|
@@ -385,55 +452,82 @@ export class HandoffManager {
|
|
|
385
452
|
}
|
|
386
453
|
}
|
|
387
454
|
|
|
388
|
-
/**
|
|
389
|
-
private async
|
|
390
|
-
|
|
455
|
+
/** Parse SSE streaming response and relay as handoff_stream chunks. */
|
|
456
|
+
private async streamSSE(
|
|
457
|
+
res: Response,
|
|
391
458
|
handoffId: string,
|
|
392
459
|
to: string,
|
|
393
|
-
|
|
394
|
-
|
|
460
|
+
sessionId?: string,
|
|
461
|
+
): Promise<string> {
|
|
462
|
+
const body = res.body;
|
|
463
|
+
if (!body) return "";
|
|
395
464
|
|
|
396
|
-
const reader =
|
|
465
|
+
const reader = body.getReader();
|
|
397
466
|
const decoder = new TextDecoder();
|
|
398
467
|
let full = "";
|
|
468
|
+
let buffer = "";
|
|
399
469
|
|
|
400
470
|
try {
|
|
401
471
|
while (true) {
|
|
402
472
|
const { done, value } = await reader.read();
|
|
403
473
|
if (done) break;
|
|
404
474
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
475
|
+
buffer += decoder.decode(value, { stream: true });
|
|
476
|
+
const lines = buffer.split("\n");
|
|
477
|
+
buffer = lines.pop()!; // keep incomplete line
|
|
478
|
+
|
|
479
|
+
for (const line of lines) {
|
|
480
|
+
if (!line.startsWith("data: ")) continue;
|
|
481
|
+
const data = line.slice(6);
|
|
482
|
+
if (data === "[DONE]") continue;
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const parsed = JSON.parse(data);
|
|
486
|
+
const delta = parsed.choices?.[0]?.delta?.content;
|
|
487
|
+
if (delta) {
|
|
488
|
+
full += delta;
|
|
489
|
+
const streamFrame: HandoffStreamChunk = {
|
|
490
|
+
type: "handoff_stream",
|
|
491
|
+
id: handoffId,
|
|
492
|
+
from: this.config.nodeId,
|
|
493
|
+
to,
|
|
494
|
+
timestamp: Date.now(),
|
|
495
|
+
payload: { delta, done: false, sessionId },
|
|
496
|
+
};
|
|
497
|
+
this.peerManager.sendTo(to, streamFrame);
|
|
498
|
+
if (sessionId) this.sendToOtherWatchers(sessionId, to, streamFrame);
|
|
499
|
+
|
|
500
|
+
// Broadcast progress to mobile nodes (throttled, detail is just
|
|
501
|
+
// a heartbeat — don't send token-level deltas as they're meaningless fragments)
|
|
502
|
+
const activeEntry = this.active.get(handoffId);
|
|
503
|
+
if (activeEntry) {
|
|
504
|
+
this.taskActivity.broadcast(
|
|
505
|
+
handoffId, "handoff", "progress", activeEntry.agent, activeEntry.startedAt,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
} catch {
|
|
510
|
+
// skip malformed SSE lines
|
|
511
|
+
}
|
|
512
|
+
}
|
|
416
513
|
}
|
|
417
514
|
} finally {
|
|
418
515
|
reader.releaseLock();
|
|
419
516
|
}
|
|
420
517
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
return { output: full, inputRequired: true };
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// Send final done marker
|
|
427
|
-
this.peerManager.sendTo(to, {
|
|
518
|
+
// Send final done marker to requester + watchers
|
|
519
|
+
const doneFrame: HandoffStreamChunk = {
|
|
428
520
|
type: "handoff_stream",
|
|
429
521
|
id: handoffId,
|
|
430
522
|
from: this.config.nodeId,
|
|
431
523
|
to,
|
|
432
524
|
timestamp: Date.now(),
|
|
433
|
-
payload: { delta: "", done: true },
|
|
434
|
-
}
|
|
525
|
+
payload: { delta: "", done: true, sessionId },
|
|
526
|
+
};
|
|
527
|
+
this.peerManager.sendTo(to, doneFrame);
|
|
528
|
+
if (sessionId) this.sendToOtherWatchers(sessionId, to, doneFrame);
|
|
435
529
|
|
|
436
|
-
return
|
|
530
|
+
return full;
|
|
437
531
|
}
|
|
438
532
|
|
|
439
533
|
/** Handle incoming input_required from remote (requester side). */
|
|
@@ -496,7 +590,7 @@ export class HandoffManager {
|
|
|
496
590
|
|
|
497
591
|
const wasInputRequired = entry.status === "input_required";
|
|
498
592
|
entry.status = "canceled";
|
|
499
|
-
entry.
|
|
593
|
+
entry.abortController?.abort();
|
|
500
594
|
|
|
501
595
|
// If canceled during input_required, no runAgentTurn is running to clean up
|
|
502
596
|
if (wasInputRequired) {
|
|
@@ -572,30 +666,15 @@ export class HandoffManager {
|
|
|
572
666
|
}
|
|
573
667
|
this.pending.clear();
|
|
574
668
|
|
|
575
|
-
//
|
|
669
|
+
// Abort all active tasks (working and input_required)
|
|
576
670
|
for (const [, entry] of this.active) {
|
|
577
671
|
if (entry.status === "working" || entry.status === "input_required") {
|
|
578
|
-
entry.
|
|
672
|
+
entry.abortController?.abort();
|
|
579
673
|
}
|
|
580
674
|
}
|
|
581
675
|
this.active.clear();
|
|
582
676
|
this.inputRequiredTargets.clear();
|
|
677
|
+
this.sessionWatchers.clear();
|
|
583
678
|
}
|
|
584
679
|
}
|
|
585
680
|
|
|
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
|
+
}
|