@foxden-app/foxclaw 0.4.12 → 0.4.14

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.
@@ -0,0 +1,682 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { isAuthCandidateName, parseChatGptAuthMetadata } from './mirror.js';
6
+ const ENVELOPE_MAGIC = 'foxclaw-auth-sync';
7
+ const NONCE_RETENTION_MS = 7 * 24 * 60 * 60_000;
8
+ const PULL_TIMEOUT_MS = 12_000;
9
+ const LEASE_TIMEOUT_MS = 8_000;
10
+ const LEASE_TTL_MS = 60_000;
11
+ const REMOTE_ACCESS_TOKEN_MIN_TTL_MS = 60_000;
12
+ export class CrossNodeAuthSync {
13
+ config;
14
+ logger;
15
+ transport;
16
+ callbacks;
17
+ nodeId = null;
18
+ key = null;
19
+ peers;
20
+ peerKeys;
21
+ pendingImports = [];
22
+ pendingPulls = new Map();
23
+ pendingLeases = new Map();
24
+ seenNonces = new Map();
25
+ timer = null;
26
+ activeRemoteLease = null;
27
+ activeLocalLease = null;
28
+ state = {
29
+ lastSentAt: null,
30
+ lastReceivedAt: null,
31
+ lastImportedAt: null,
32
+ lastImportCandidate: null,
33
+ lastPullAt: null,
34
+ lastPullCandidate: null,
35
+ lastError: null,
36
+ };
37
+ constructor(config, logger, transport, callbacks) {
38
+ this.config = config;
39
+ this.logger = logger;
40
+ this.transport = transport;
41
+ this.callbacks = callbacks;
42
+ this.peers = config.peers.map(normalizeConfiguredPeer).filter(Boolean);
43
+ this.peerKeys = new Set(this.peers.flatMap(expandPeerKeys));
44
+ }
45
+ async initialize() {
46
+ if (!this.config.enabled)
47
+ return;
48
+ if (!this.config.key?.trim()) {
49
+ throw new Error('AUTH_SYNC_KEY is required when AUTH_SYNC_ENABLED=true');
50
+ }
51
+ this.key = decodeSharedKey(this.config.key);
52
+ const stored = await readState(this.config.statePath);
53
+ this.nodeId = this.config.nodeId?.trim() || stored.nodeId || createNodeId();
54
+ this.seenNonces = pruneSeenNonces(new Map(Object.entries(stored.seenNonces ?? {})));
55
+ this.state = {
56
+ lastSentAt: stored.lastSentAt ?? null,
57
+ lastReceivedAt: stored.lastReceivedAt ?? null,
58
+ lastImportedAt: stored.lastImportedAt ?? null,
59
+ lastImportCandidate: stored.lastImportCandidate ?? null,
60
+ lastPullAt: stored.lastPullAt ?? null,
61
+ lastPullCandidate: stored.lastPullCandidate ?? null,
62
+ lastError: stored.lastError ?? null,
63
+ };
64
+ await fs.mkdir(this.config.tempDir, { recursive: true, mode: 0o700 });
65
+ await this.writeState();
66
+ }
67
+ start() {
68
+ if (!this.config.enabled || this.timer)
69
+ return;
70
+ this.timer = setInterval(() => {
71
+ this.expireLeases();
72
+ void this.processPendingImports().catch((error) => {
73
+ this.recordError(`pending import failed: ${formatError(error)}`);
74
+ });
75
+ }, 5_000);
76
+ this.timer.unref();
77
+ void this.publishDigest().catch((error) => {
78
+ this.recordError(`initial digest failed: ${formatError(error)}`);
79
+ });
80
+ }
81
+ stop() {
82
+ if (!this.timer)
83
+ return;
84
+ clearInterval(this.timer);
85
+ this.timer = null;
86
+ }
87
+ getStatus() {
88
+ return {
89
+ enabled: this.config.enabled,
90
+ nodeId: this.nodeId,
91
+ transport: this.config.transport,
92
+ peers: this.peers,
93
+ pendingImports: this.pendingImports.length,
94
+ lastSentAt: this.state.lastSentAt,
95
+ lastReceivedAt: this.state.lastReceivedAt,
96
+ lastImportedAt: this.state.lastImportedAt,
97
+ lastImportCandidate: this.state.lastImportCandidate,
98
+ lastPullAt: this.state.lastPullAt,
99
+ lastPullCandidate: this.state.lastPullCandidate,
100
+ lastError: this.state.lastError,
101
+ activeLeaseId: this.activeLocalLease?.leaseId ?? this.activeRemoteLease?.leaseId ?? null,
102
+ };
103
+ }
104
+ isIdle() {
105
+ return this.pendingImports.length === 0
106
+ && this.pendingPulls.size === 0
107
+ && this.pendingLeases.size === 0;
108
+ }
109
+ async publishCandidate(candidateName) {
110
+ if (!this.isReady())
111
+ return false;
112
+ const record = await this.callbacks.readLocalCandidate(candidateName);
113
+ if (!record)
114
+ return false;
115
+ await this.sendToAll({
116
+ kind: 'push.bundle',
117
+ ...bundleFromRecord(record),
118
+ });
119
+ return true;
120
+ }
121
+ async pushAll() {
122
+ if (!this.isReady())
123
+ return { sent: 0, skipped: 0 };
124
+ let sent = 0;
125
+ let skipped = 0;
126
+ for (const record of await this.callbacks.listLocalCandidates()) {
127
+ if (!isAuthCandidateName(record.candidateName)) {
128
+ skipped += 1;
129
+ continue;
130
+ }
131
+ await this.sendToAll({
132
+ kind: 'push.bundle',
133
+ ...bundleFromRecord(record),
134
+ });
135
+ sent += 1;
136
+ }
137
+ return { sent, skipped };
138
+ }
139
+ async publishDigest() {
140
+ if (!this.isReady())
141
+ return;
142
+ const records = (await this.callbacks.listLocalCandidates()).map(record => ({
143
+ candidateName: record.candidateName,
144
+ accountIdHash: hashAccountId(record.accountId),
145
+ lastRefreshMs: record.lastRefreshMs,
146
+ }));
147
+ await this.sendToAll({ kind: 'digest', records });
148
+ }
149
+ async requestRecovery(candidateName, current) {
150
+ if (!this.isReady() || !isAuthCandidateName(candidateName))
151
+ return false;
152
+ const requestId = crypto.randomUUID();
153
+ const result = await new Promise((resolve) => {
154
+ const timer = setTimeout(() => {
155
+ const pending = this.pendingPulls.get(requestId);
156
+ if (pending) {
157
+ pending.finished = true;
158
+ this.pendingPulls.delete(requestId);
159
+ resolve(false);
160
+ }
161
+ }, PULL_TIMEOUT_MS);
162
+ timer.unref();
163
+ this.pendingPulls.set(requestId, {
164
+ candidateName,
165
+ resolve,
166
+ timer,
167
+ finished: false,
168
+ });
169
+ void this.sendToAll({
170
+ kind: 'pull.request',
171
+ requestId,
172
+ candidateName,
173
+ accountId: current?.accountId ?? null,
174
+ lastRefreshMs: current?.lastRefreshMs ?? null,
175
+ }).catch((error) => {
176
+ clearTimeout(timer);
177
+ this.pendingPulls.delete(requestId);
178
+ this.recordError(`pull request failed: ${formatError(error)}`);
179
+ resolve(false);
180
+ });
181
+ });
182
+ this.state.lastPullAt = new Date().toISOString();
183
+ this.state.lastPullCandidate = candidateName;
184
+ await this.writeState();
185
+ return result;
186
+ }
187
+ async acquireRefreshLease(reason) {
188
+ if (!this.isReady() || this.peers.length === 0) {
189
+ const leaseId = crypto.randomUUID();
190
+ this.activeLocalLease = { leaseId, expiresAt: Date.now() + LEASE_TTL_MS };
191
+ return { ok: true, leaseId };
192
+ }
193
+ if (!this.callbacks.isIdle()) {
194
+ return { ok: false, leaseId: null, reason: 'local runtime is not idle' };
195
+ }
196
+ const leaseId = crypto.randomUUID();
197
+ const expiresAt = Date.now() + LEASE_TTL_MS;
198
+ const result = await new Promise((resolve) => {
199
+ const timer = setTimeout(() => {
200
+ this.pendingLeases.delete(leaseId);
201
+ resolve({
202
+ ok: false,
203
+ leaseId: null,
204
+ reason: `timed out waiting for ${this.peers.length} auth sync peer lease grant(s)`,
205
+ });
206
+ }, LEASE_TIMEOUT_MS);
207
+ timer.unref();
208
+ this.pendingLeases.set(leaseId, {
209
+ leaseId,
210
+ grants: new Set(),
211
+ denies: [],
212
+ resolve,
213
+ timer,
214
+ });
215
+ void this.sendToAll({ kind: 'lease.request', leaseId, reason, expiresAt }).catch((error) => {
216
+ clearTimeout(timer);
217
+ this.pendingLeases.delete(leaseId);
218
+ resolve({ ok: false, leaseId: null, reason: formatError(error) });
219
+ });
220
+ });
221
+ if (result.ok) {
222
+ this.activeLocalLease = { leaseId, expiresAt };
223
+ }
224
+ else {
225
+ await this.releaseRefreshLease(leaseId);
226
+ }
227
+ return result;
228
+ }
229
+ async releaseRefreshLease(leaseId) {
230
+ if (!leaseId)
231
+ return;
232
+ if (this.activeLocalLease?.leaseId === leaseId) {
233
+ this.activeLocalLease = null;
234
+ }
235
+ if (this.isReady()) {
236
+ await this.sendToAll({ kind: 'lease.release', leaseId }).catch((error) => {
237
+ this.recordError(`lease release failed: ${formatError(error)}`);
238
+ });
239
+ }
240
+ }
241
+ async testPeers() {
242
+ if (!this.isReady())
243
+ return { sent: 0 };
244
+ await this.sendToAll({ kind: 'test.ping', requestId: crypto.randomUUID() });
245
+ return { sent: this.peers.length };
246
+ }
247
+ async handleIncomingEnvelope(rawEnvelope, peer) {
248
+ if (!this.isReady() || !this.isAllowedPeer(peer)) {
249
+ return false;
250
+ }
251
+ const opened = this.openEnvelope(rawEnvelope);
252
+ if (opened.sender === this.nodeId) {
253
+ return false;
254
+ }
255
+ const nonceKey = `${opened.sender}:${opened.envelope.nonce}`;
256
+ if (this.seenNonces.has(nonceKey)) {
257
+ return true;
258
+ }
259
+ this.seenNonces.set(nonceKey, Date.now());
260
+ this.state.lastReceivedAt = new Date().toISOString();
261
+ await this.writeState();
262
+ await this.handleMessage(opened.message, opened.sender, peer);
263
+ return true;
264
+ }
265
+ async handleMessage(message, senderNodeId, peer) {
266
+ const sourceLabel = peer.username ? `@${peer.username}` : peer.userId;
267
+ switch (message.kind) {
268
+ case 'push.bundle':
269
+ this.enqueueImport(message, senderNodeId, sourceLabel, normalizePeerIdentity(peer));
270
+ return;
271
+ case 'pull.request':
272
+ await this.handlePullRequest(message, normalizePeerIdentity(peer));
273
+ return;
274
+ case 'pull.response':
275
+ await this.handlePullResponse(message, senderNodeId, sourceLabel, normalizePeerIdentity(peer));
276
+ return;
277
+ case 'digest':
278
+ await this.handleDigest(message, normalizePeerIdentity(peer));
279
+ return;
280
+ case 'test.ping':
281
+ await this.sendToPeer(normalizePeerIdentity(peer), {
282
+ kind: 'test.pong',
283
+ requestId: message.requestId,
284
+ nodeId: this.nodeId,
285
+ });
286
+ return;
287
+ case 'test.pong':
288
+ this.logger.info('auth.sync.test_pong', { peer: normalizePeerIdentity(peer), nodeId: message.nodeId });
289
+ return;
290
+ case 'lease.request':
291
+ await this.handleLeaseRequest(message, normalizePeerIdentity(peer));
292
+ return;
293
+ case 'lease.grant':
294
+ case 'lease.deny':
295
+ this.handleLeaseReply(message, normalizePeerIdentity(peer));
296
+ return;
297
+ case 'lease.release':
298
+ if (this.activeRemoteLease?.leaseId === message.leaseId) {
299
+ this.activeRemoteLease = null;
300
+ }
301
+ return;
302
+ default:
303
+ return;
304
+ }
305
+ }
306
+ async handlePullRequest(message, peer) {
307
+ if (!isAuthCandidateName(message.candidateName))
308
+ return;
309
+ const record = await this.callbacks.readLocalCandidate(message.candidateName);
310
+ if (!record) {
311
+ await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'candidate not found' });
312
+ return;
313
+ }
314
+ if (message.accountId && record.accountId !== message.accountId) {
315
+ await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'account mismatch' });
316
+ return;
317
+ }
318
+ if (message.lastRefreshMs !== null && record.lastRefreshMs <= message.lastRefreshMs) {
319
+ await this.sendToPeer(peer, { kind: 'pull.response', requestId: message.requestId, bundle: null, reason: 'not newer' });
320
+ return;
321
+ }
322
+ await this.sendToPeer(peer, {
323
+ kind: 'pull.response',
324
+ requestId: message.requestId,
325
+ bundle: bundleFromRecord(record),
326
+ });
327
+ }
328
+ async handlePullResponse(message, senderNodeId, sourceLabel, peer) {
329
+ const pending = this.pendingPulls.get(message.requestId);
330
+ if (!pending || pending.finished)
331
+ return;
332
+ if (!message.bundle || message.bundle.candidateName !== pending.candidateName) {
333
+ return;
334
+ }
335
+ const imported = await this.validateAndImport(message.bundle, senderNodeId, sourceLabel, peer);
336
+ if (!imported)
337
+ return;
338
+ pending.finished = true;
339
+ clearTimeout(pending.timer);
340
+ this.pendingPulls.delete(message.requestId);
341
+ pending.resolve(true);
342
+ }
343
+ async handleDigest(message, peer) {
344
+ const remote = new Map(message.records.map(record => [record.candidateName, record]));
345
+ for (const local of await this.callbacks.listLocalCandidates()) {
346
+ const remoteRecord = remote.get(local.candidateName);
347
+ const shouldSend = !remoteRecord
348
+ || (remoteRecord.accountIdHash === hashAccountId(local.accountId)
349
+ && local.lastRefreshMs > remoteRecord.lastRefreshMs);
350
+ if (shouldSend) {
351
+ await this.sendToPeer(peer, {
352
+ kind: 'push.bundle',
353
+ ...bundleFromRecord(local),
354
+ });
355
+ }
356
+ }
357
+ }
358
+ async handleLeaseRequest(message, peer) {
359
+ this.expireLeases();
360
+ if (!this.callbacks.isIdle()) {
361
+ await this.sendToPeer(peer, { kind: 'lease.deny', leaseId: message.leaseId, reason: 'runtime is not idle' });
362
+ return;
363
+ }
364
+ if (this.activeRemoteLease && this.activeRemoteLease.leaseId !== message.leaseId) {
365
+ await this.sendToPeer(peer, { kind: 'lease.deny', leaseId: message.leaseId, reason: 'another refresh lease is active' });
366
+ return;
367
+ }
368
+ this.activeRemoteLease = {
369
+ leaseId: message.leaseId,
370
+ peer,
371
+ expiresAt: Math.min(message.expiresAt, Date.now() + LEASE_TTL_MS),
372
+ };
373
+ await this.sendToPeer(peer, { kind: 'lease.grant', leaseId: message.leaseId, expiresAt: this.activeRemoteLease.expiresAt });
374
+ }
375
+ handleLeaseReply(message, peer) {
376
+ const pending = this.pendingLeases.get(message.leaseId);
377
+ if (!pending)
378
+ return;
379
+ if (message.kind === 'lease.deny') {
380
+ pending.denies.push(`${peer}: ${message.reason}`);
381
+ }
382
+ else {
383
+ pending.grants.add(peer);
384
+ }
385
+ if (pending.denies.length > 0) {
386
+ clearTimeout(pending.timer);
387
+ this.pendingLeases.delete(message.leaseId);
388
+ pending.resolve({ ok: false, leaseId: null, reason: pending.denies.join('; ') });
389
+ return;
390
+ }
391
+ if (this.peers.every(peerName => pending.grants.has(peerName))) {
392
+ clearTimeout(pending.timer);
393
+ this.pendingLeases.delete(message.leaseId);
394
+ pending.resolve({ ok: true, leaseId: message.leaseId });
395
+ }
396
+ }
397
+ enqueueImport(bundle, sourceNodeId, sourceLabel, fromPeer) {
398
+ this.pendingImports.push({
399
+ bundle,
400
+ sourceNodeId,
401
+ sourceLabel,
402
+ receivedAt: Date.now(),
403
+ fromPeer,
404
+ });
405
+ void this.processPendingImports().catch((error) => {
406
+ this.recordError(`remote import failed: ${formatError(error)}`);
407
+ });
408
+ }
409
+ async processPendingImports() {
410
+ if (!this.callbacks.isIdle())
411
+ return;
412
+ while (this.pendingImports.length > 0 && this.callbacks.isIdle()) {
413
+ const pending = this.pendingImports.shift();
414
+ await this.validateAndImport(pending.bundle, pending.sourceNodeId, pending.sourceLabel, pending.fromPeer);
415
+ }
416
+ }
417
+ async validateAndImport(bundle, sourceNodeId, sourceLabel, fromPeer) {
418
+ if (!isValidBundle(bundle)) {
419
+ this.recordError('remote bundle shape is invalid');
420
+ return false;
421
+ }
422
+ const metadata = parseChatGptAuthMetadata(bundle.rawAuth);
423
+ if (!metadata || metadata.accountId !== bundle.accountId || metadata.lastRefreshMs !== bundle.lastRefreshMs) {
424
+ this.recordError(`remote bundle metadata mismatch for ${bundle.candidateName}`);
425
+ return false;
426
+ }
427
+ if (sha256(bundle.rawAuth) !== bundle.authSha256) {
428
+ this.recordError(`remote bundle hash mismatch for ${bundle.candidateName}`);
429
+ return false;
430
+ }
431
+ const expiresAt = readAccessTokenExpiresAtMs(bundle.rawAuth);
432
+ if (expiresAt === null || expiresAt <= Date.now() + REMOTE_ACCESS_TOKEN_MIN_TTL_MS) {
433
+ this.recordError(`remote access token is expired or missing exp for ${bundle.candidateName}`);
434
+ return false;
435
+ }
436
+ const validation = await this.callbacks.validateCandidate(bundle.candidateName, bundle.rawAuth, bundle.accountId);
437
+ if (!validation.ok) {
438
+ this.recordError(`remote candidate validation failed for ${bundle.candidateName}: ${validation.reason ?? 'unknown'}`);
439
+ return false;
440
+ }
441
+ const result = await this.callbacks.importCandidate(bundle.candidateName, bundle.rawAuth, {
442
+ nodeId: sourceNodeId,
443
+ label: sourceLabel ?? fromPeer,
444
+ });
445
+ if (!result.ok) {
446
+ this.recordError(`remote candidate import failed for ${bundle.candidateName}: ${result.reason ?? 'unknown'}`);
447
+ return false;
448
+ }
449
+ if (result.imported) {
450
+ this.state.lastImportedAt = new Date().toISOString();
451
+ this.state.lastImportCandidate = bundle.candidateName;
452
+ this.state.lastError = null;
453
+ await this.writeState();
454
+ this.logger.info('auth.sync.imported', { candidateName: bundle.candidateName, sourceNodeId });
455
+ }
456
+ return result.imported;
457
+ }
458
+ async sendToAll(message) {
459
+ await Promise.all(this.peers.map(peer => this.sendToPeer(peer, message)));
460
+ }
461
+ async sendToPeer(peer, message) {
462
+ const envelope = this.sealEnvelope(message);
463
+ await this.transport.send(peer, envelope);
464
+ this.state.lastSentAt = new Date().toISOString();
465
+ await this.writeState();
466
+ }
467
+ sealEnvelope(message) {
468
+ if (!this.key || !this.nodeId) {
469
+ throw new Error('auth sync is not initialized');
470
+ }
471
+ const nonce = crypto.randomBytes(16).toString('base64url');
472
+ const iv = crypto.randomBytes(12);
473
+ const cluster = hashClusterId(this.config.clusterId);
474
+ const aad = envelopeAad(cluster, this.nodeId, nonce);
475
+ const cipher = crypto.createCipheriv('aes-256-gcm', deriveMessageKey(this.key, this.config.clusterId), iv);
476
+ cipher.setAAD(Buffer.from(aad, 'utf8'));
477
+ const ciphertext = Buffer.concat([
478
+ cipher.update(Buffer.from(JSON.stringify(message), 'utf8')),
479
+ cipher.final(),
480
+ ]);
481
+ const envelope = {
482
+ magic: ENVELOPE_MAGIC,
483
+ v: 1,
484
+ cluster,
485
+ sender: this.nodeId,
486
+ nonce,
487
+ iv: iv.toString('base64url'),
488
+ tag: cipher.getAuthTag().toString('base64url'),
489
+ ciphertext: ciphertext.toString('base64url'),
490
+ };
491
+ return `${JSON.stringify(envelope)}\n`;
492
+ }
493
+ openEnvelope(raw) {
494
+ if (!this.key) {
495
+ throw new Error('auth sync is not initialized');
496
+ }
497
+ const envelope = JSON.parse(raw);
498
+ if (envelope.magic !== ENVELOPE_MAGIC
499
+ || envelope.v !== 1
500
+ || typeof envelope.cluster !== 'string'
501
+ || typeof envelope.sender !== 'string'
502
+ || typeof envelope.nonce !== 'string'
503
+ || typeof envelope.iv !== 'string'
504
+ || typeof envelope.tag !== 'string'
505
+ || typeof envelope.ciphertext !== 'string') {
506
+ throw new Error('invalid auth sync envelope');
507
+ }
508
+ if (envelope.cluster !== hashClusterId(this.config.clusterId)) {
509
+ throw new Error('auth sync cluster mismatch');
510
+ }
511
+ const decipher = crypto.createDecipheriv('aes-256-gcm', deriveMessageKey(this.key, this.config.clusterId), Buffer.from(envelope.iv, 'base64url'));
512
+ decipher.setAAD(Buffer.from(envelopeAad(envelope.cluster, envelope.sender, envelope.nonce), 'utf8'));
513
+ decipher.setAuthTag(Buffer.from(envelope.tag, 'base64url'));
514
+ const plaintext = Buffer.concat([
515
+ decipher.update(Buffer.from(envelope.ciphertext, 'base64url')),
516
+ decipher.final(),
517
+ ]).toString('utf8');
518
+ const message = JSON.parse(plaintext);
519
+ return { envelope: envelope, sender: envelope.sender, message };
520
+ }
521
+ isReady() {
522
+ return this.config.enabled && this.key !== null && this.nodeId !== null && this.peers.length > 0;
523
+ }
524
+ isAllowedPeer(peer) {
525
+ const keys = [
526
+ ...expandPeerKeys(peer.userId),
527
+ ...(peer.username ? expandPeerKeys(`@${peer.username}`) : []),
528
+ ];
529
+ return keys.some(key => this.peerKeys.has(key));
530
+ }
531
+ expireLeases() {
532
+ const now = Date.now();
533
+ if (this.activeRemoteLease && this.activeRemoteLease.expiresAt <= now) {
534
+ this.activeRemoteLease = null;
535
+ }
536
+ if (this.activeLocalLease && this.activeLocalLease.expiresAt <= now) {
537
+ this.activeLocalLease = null;
538
+ }
539
+ }
540
+ recordError(message) {
541
+ this.state.lastError = message;
542
+ this.logger.warn('auth.sync.error', { error: message });
543
+ void this.writeState().catch((error) => {
544
+ this.logger.warn('auth.sync.state_write_failed', { error: formatError(error) });
545
+ });
546
+ }
547
+ async writeState() {
548
+ if (!this.config.enabled)
549
+ return;
550
+ this.seenNonces = pruneSeenNonces(this.seenNonces);
551
+ await fs.mkdir(path.dirname(this.config.statePath), { recursive: true, mode: 0o700 });
552
+ const temporary = `${this.config.statePath}.${process.pid}.${Date.now()}.tmp`;
553
+ const state = {
554
+ seenNonces: Object.fromEntries(this.seenNonces),
555
+ ...this.state,
556
+ };
557
+ if (this.nodeId) {
558
+ state.nodeId = this.nodeId;
559
+ }
560
+ await fs.writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
561
+ await fs.rename(temporary, this.config.statePath);
562
+ }
563
+ }
564
+ export function readAccessTokenExpiresAtMs(raw) {
565
+ try {
566
+ const parsed = JSON.parse(raw);
567
+ const token = typeof parsed.tokens?.access_token === 'string' ? parsed.tokens.access_token : null;
568
+ if (!token)
569
+ return null;
570
+ const payload = token.split('.')[1];
571
+ if (!payload)
572
+ return null;
573
+ const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
574
+ return typeof claims.exp === 'number' && Number.isFinite(claims.exp) ? claims.exp * 1000 : null;
575
+ }
576
+ catch {
577
+ return null;
578
+ }
579
+ }
580
+ function bundleFromRecord(record) {
581
+ return {
582
+ candidateName: record.candidateName,
583
+ accountId: record.accountId,
584
+ lastRefreshMs: record.lastRefreshMs,
585
+ rawAuth: record.raw,
586
+ authSha256: sha256(record.raw),
587
+ };
588
+ }
589
+ function isValidBundle(value) {
590
+ return typeof value.candidateName === 'string'
591
+ && isAuthCandidateName(value.candidateName)
592
+ && typeof value.accountId === 'string'
593
+ && typeof value.lastRefreshMs === 'number'
594
+ && Number.isFinite(value.lastRefreshMs)
595
+ && typeof value.rawAuth === 'string'
596
+ && typeof value.authSha256 === 'string';
597
+ }
598
+ function decodeSharedKey(raw) {
599
+ const value = raw.trim();
600
+ const candidates = [];
601
+ if (/^[A-Za-z0-9_-]+={0,2}$/.test(value)) {
602
+ try {
603
+ candidates.push(Buffer.from(value, 'base64url'));
604
+ }
605
+ catch {
606
+ // Ignore invalid base64url and try other key encodings.
607
+ }
608
+ }
609
+ if (/^[a-fA-F0-9]+$/.test(value) && value.length % 2 === 0) {
610
+ try {
611
+ candidates.push(Buffer.from(value, 'hex'));
612
+ }
613
+ catch {
614
+ // Ignore invalid hex and try the raw UTF-8 bytes.
615
+ }
616
+ }
617
+ candidates.push(Buffer.from(value, 'utf8'));
618
+ const key = candidates.find(candidate => candidate.length >= 32);
619
+ if (!key) {
620
+ throw new Error('AUTH_SYNC_KEY must decode to at least 32 bytes');
621
+ }
622
+ return key.subarray(0, 32);
623
+ }
624
+ function deriveMessageKey(key, clusterId) {
625
+ const derived = crypto.hkdfSync('sha256', key, Buffer.from('foxclaw-auth-sync-v1'), Buffer.from(clusterId), 32);
626
+ return Buffer.isBuffer(derived) ? derived : Buffer.from(derived);
627
+ }
628
+ function envelopeAad(cluster, sender, nonce) {
629
+ return `${ENVELOPE_MAGIC}\n1\n${cluster}\n${sender}\n${nonce}`;
630
+ }
631
+ function hashClusterId(clusterId) {
632
+ return crypto.createHash('sha256').update(clusterId).digest('hex').slice(0, 24);
633
+ }
634
+ function hashAccountId(accountId) {
635
+ return crypto.createHash('sha256').update(accountId).digest('hex').slice(0, 24);
636
+ }
637
+ function sha256(value) {
638
+ return crypto.createHash('sha256').update(value).digest('hex');
639
+ }
640
+ function normalizeConfiguredPeer(peer) {
641
+ const value = peer.trim();
642
+ if (!value)
643
+ return '';
644
+ return value.startsWith('@') ? value.toLowerCase() : value.toLowerCase();
645
+ }
646
+ function normalizePeerIdentity(peer) {
647
+ return peer.username ? `@${peer.username.toLowerCase()}` : peer.userId;
648
+ }
649
+ function expandPeerKeys(peer) {
650
+ const value = peer.toLowerCase();
651
+ if (!value)
652
+ return [];
653
+ if (value.startsWith('@')) {
654
+ return [value, value.slice(1)];
655
+ }
656
+ return [value, `@${value}`];
657
+ }
658
+ function createNodeId() {
659
+ const host = os.hostname().replace(/[^a-zA-Z0-9_.-]+/g, '-').slice(0, 48) || 'node';
660
+ return `${host}-${crypto.randomBytes(5).toString('hex')}`;
661
+ }
662
+ function pruneSeenNonces(nonces) {
663
+ const cutoff = Date.now() - NONCE_RETENTION_MS;
664
+ for (const [nonce, seenAt] of nonces) {
665
+ if (!Number.isFinite(seenAt) || seenAt < cutoff) {
666
+ nonces.delete(nonce);
667
+ }
668
+ }
669
+ return nonces;
670
+ }
671
+ async function readState(statePath) {
672
+ try {
673
+ const parsed = JSON.parse(await fs.readFile(statePath, 'utf8'));
674
+ return parsed && typeof parsed === 'object' ? parsed : {};
675
+ }
676
+ catch {
677
+ return {};
678
+ }
679
+ }
680
+ function formatError(error) {
681
+ return error instanceof Error ? error.message : String(error);
682
+ }