borgmcp 2.10.2 → 2.11.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.
Files changed (57) hide show
  1. package/dist/cli-help.js +3 -3
  2. package/dist/cli-help.js.map +1 -1
  3. package/dist/config.d.ts +34 -9
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +487 -105
  6. package/dist/config.js.map +1 -1
  7. package/dist/enrollment-lock.d.ts +10 -0
  8. package/dist/enrollment-lock.d.ts.map +1 -0
  9. package/dist/enrollment-lock.js +49 -0
  10. package/dist/enrollment-lock.js.map +1 -0
  11. package/dist/enrollment-types.d.ts +47 -0
  12. package/dist/enrollment-types.d.ts.map +1 -0
  13. package/dist/enrollment-types.js +2 -0
  14. package/dist/enrollment-types.js.map +1 -0
  15. package/dist/index.js +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/invitation-artifact.d.ts +3 -1
  18. package/dist/invitation-artifact.d.ts.map +1 -1
  19. package/dist/invitation-artifact.js +3 -1
  20. package/dist/invitation-artifact.js.map +1 -1
  21. package/dist/recover-enrollment-cmd.d.ts.map +1 -1
  22. package/dist/recover-enrollment-cmd.js +34 -15
  23. package/dist/recover-enrollment-cmd.js.map +1 -1
  24. package/dist/remote-client.d.ts +3 -2
  25. package/dist/remote-client.d.ts.map +1 -1
  26. package/dist/remote-client.js +32 -11
  27. package/dist/remote-client.js.map +1 -1
  28. package/dist/server-errors.d.ts +4 -0
  29. package/dist/server-errors.d.ts.map +1 -1
  30. package/dist/server-errors.js +8 -0
  31. package/dist/server-errors.js.map +1 -1
  32. package/dist/server-handshake.d.ts +11 -3
  33. package/dist/server-handshake.d.ts.map +1 -1
  34. package/dist/server-handshake.js +103 -45
  35. package/dist/server-handshake.js.map +1 -1
  36. package/dist/server-trust.d.ts +14 -1
  37. package/dist/server-trust.d.ts.map +1 -1
  38. package/dist/server-trust.js +363 -66
  39. package/dist/server-trust.js.map +1 -1
  40. package/dist/tool-manifest.d.ts.map +1 -1
  41. package/dist/tool-manifest.js +4 -10
  42. package/dist/tool-manifest.js.map +1 -1
  43. package/docs/EXTRACTION_PROVENANCE.md +3 -3
  44. package/docs/RELEASING.md +7 -2
  45. package/package.json +1 -1
  46. package/src/cli-help.ts +3 -3
  47. package/src/config.ts +544 -101
  48. package/src/enrollment-lock.ts +53 -0
  49. package/src/enrollment-types.ts +51 -0
  50. package/src/index.ts +1 -1
  51. package/src/invitation-artifact.ts +7 -1
  52. package/src/recover-enrollment-cmd.ts +39 -15
  53. package/src/remote-client.ts +36 -10
  54. package/src/server-errors.ts +7 -0
  55. package/src/server-handshake.ts +130 -52
  56. package/src/server-trust.ts +392 -66
  57. package/src/tool-manifest.ts +4 -10
@@ -1,7 +1,7 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { createHash, timingSafeEqual, X509Certificate } from 'node:crypto';
3
3
  import { constants } from 'node:fs';
4
- import { access, mkdir, open, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { access, lstat, mkdir, open, rename, rm, unlink, writeFile } from 'node:fs/promises';
5
5
  import { request as httpsRequest } from 'node:https';
6
6
  import { connect as tlsConnect } from 'node:tls';
7
7
  import { homedir } from 'node:os';
@@ -15,6 +15,18 @@ import {
15
15
  InvitationArtifactTransportError,
16
16
  InvitationArtifactTrustError,
17
17
  } from './invitation-artifact.js';
18
+ import { atomicWrite0600 } from './seat-store.js';
19
+ import { withEnrollmentOriginLock } from './enrollment-lock.js';
20
+ import {
21
+ finalizeAcceptedEnrollment,
22
+ getAcceptedEnrollmentMarker,
23
+ restoreAcceptedEnrollmentAccounts,
24
+ } from './config.js';
25
+ import type {
26
+ AcceptedEnrollmentMarker,
27
+ EnrollmentArtifactBinding,
28
+ EnrollmentTrustPointer,
29
+ } from './enrollment-types.js';
18
30
 
19
31
  // CR5 TLS LATTICE: OpenSSL/Node TLS certificate-verification error codes. A raw
20
32
  // CA / cert-chain / SAN failure from the pinned transport is a potential MITM and
@@ -57,7 +69,11 @@ export interface BorgServerTrust {
57
69
  }
58
70
 
59
71
  export interface StagedBorgServerTrust extends BorgServerTrust {
60
- commitTrust: (activate: () => Promise<void>) => Promise<void>;
72
+ generationId: string;
73
+ commitTrust: (activate: (context: {
74
+ generationId: string;
75
+ previousPointer: EnrollmentTrustPointer | null;
76
+ }) => Promise<void>) => Promise<void>;
61
77
  discardTrust: () => Promise<void>;
62
78
  }
63
79
 
@@ -66,6 +82,24 @@ interface ServerTrustConfig {
66
82
  }
67
83
 
68
84
  const trustCache = new Map<string, Promise<BorgServerTrust>>();
85
+ type EnrollmentTrustFaultPoint =
86
+ | 'after-account-activation'
87
+ | 'after-pointer-publish'
88
+ | 'before-marker-finalize'
89
+ | 'during-pointer-rollback';
90
+ let enrollmentTrustFaultsForTest = new Set<EnrollmentTrustFaultPoint>();
91
+
92
+ function injectEnrollmentTrustFault(point: EnrollmentTrustFaultPoint): void {
93
+ if (enrollmentTrustFaultsForTest.has(point)) throw new Error(`injected ${point} failure`);
94
+ }
95
+
96
+ function invalidateOriginTrustCache(origin: string): void {
97
+ for (const key of trustCache.keys()) {
98
+ if (key === origin || key.startsWith(`${origin}\0`) || key.endsWith(`\0${origin}`)) {
99
+ trustCache.delete(key);
100
+ }
101
+ }
102
+ }
69
103
 
70
104
  function serverDataDirectory(): string {
71
105
  return resolve(process.env.BORG_SERVER_DATA_DIR ?? join(homedir(), '.borg', 'server'));
@@ -76,6 +110,24 @@ function remoteTrustDirectory(origin: string): string {
76
110
  return join(homedir(), '.borg', 'server-trust', key);
77
111
  }
78
112
 
113
+ function trustGenerationsDirectory(origin: string): string {
114
+ return join(remoteTrustDirectory(origin), 'generations');
115
+ }
116
+
117
+ function trustGenerationDirectory(origin: string, generationId: string): string {
118
+ return join(trustGenerationsDirectory(origin), generationId);
119
+ }
120
+
121
+ function trustPointerPath(origin: string): string {
122
+ return join(remoteTrustDirectory(origin), 'current.json');
123
+ }
124
+
125
+ function trustGenerationId(origin: string, certificate: string, identity: string): string {
126
+ return createHash('sha256')
127
+ .update(origin).update('\0').update(identity).update('\0').update(certificate)
128
+ .digest('hex');
129
+ }
130
+
79
131
  async function trustFilesExist(directory: string): Promise<boolean> {
80
132
  try {
81
133
  await Promise.all([access(join(directory, 'ca.crt')), access(join(directory, 'server.json'))]);
@@ -85,6 +137,11 @@ async function trustFilesExist(directory: string): Promise<boolean> {
85
137
  }
86
138
  }
87
139
 
140
+ async function remoteTrustStateExists(origin: string): Promise<boolean> {
141
+ try { await access(trustPointerPath(origin)); return true; } catch { /* continue */ }
142
+ return trustFilesExist(remoteTrustDirectory(origin));
143
+ }
144
+
88
145
  async function readTrustFile(path: string): Promise<string> {
89
146
  let handle;
90
147
  try {
@@ -109,6 +166,16 @@ async function readTrustFile(path: string): Promise<string> {
109
166
  }
110
167
  }
111
168
 
169
+ async function assertPrivateDirectory(path: string): Promise<void> {
170
+ const metadata = await lstat(path);
171
+ if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
172
+ throw new Error('Borg server trust directories must be private real directories');
173
+ }
174
+ if (typeof process.getuid === 'function' && metadata.uid !== process.getuid()) {
175
+ throw new Error('Borg server trust directories must be owned by the current user');
176
+ }
177
+ }
178
+
112
179
  async function syncPath(path: string): Promise<void> {
113
180
  const handle = await open(path, 'r');
114
181
  try {
@@ -153,6 +220,119 @@ function verifyCaIdentity(certificate: string, expected: string): string {
153
220
  return `spki-sha256:${actual.toString('hex')}`;
154
221
  }
155
222
 
223
+ async function readGeneration(
224
+ origin: string,
225
+ generationId: string,
226
+ ): Promise<{ certificate: string; identity: string }> {
227
+ if (!/^[a-f0-9]{64}$/.test(generationId)) throw new Error('Borg server trust pointer is invalid');
228
+ const directory = trustGenerationDirectory(origin, generationId);
229
+ const [certificate, configText] = await Promise.all([
230
+ readTrustFile(join(directory, 'ca.crt')),
231
+ readTrustFile(join(directory, 'server.json')),
232
+ ]);
233
+ const config = decodeTrustConfig(configText);
234
+ const identity = verifyCaIdentity(certificate, config.ca_spki_sha256);
235
+ if (trustGenerationId(origin, certificate, identity) !== generationId) {
236
+ throw new Error('Borg server trust generation digest is invalid');
237
+ }
238
+ return { certificate, identity };
239
+ }
240
+
241
+ async function readTrustPointer(origin: string): Promise<EnrollmentTrustPointer | null> {
242
+ try { await assertPrivateDirectory(remoteTrustDirectory(origin)); } catch (error) {
243
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
244
+ throw error;
245
+ }
246
+ let raw: string;
247
+ try {
248
+ raw = await readTrustFile(trustPointerPath(origin));
249
+ } catch (error) {
250
+ if (error instanceof Error && /were not found/.test(error.message)) return null;
251
+ throw error;
252
+ }
253
+ let pointer: Partial<EnrollmentTrustPointer>;
254
+ try { pointer = JSON.parse(raw) as Partial<EnrollmentTrustPointer>; } catch {
255
+ throw new Error('Borg server trust pointer is invalid');
256
+ }
257
+ if (
258
+ pointer.version !== 1 || pointer.origin !== origin ||
259
+ typeof pointer.generationId !== 'string' || !/^[a-f0-9]{64}$/.test(pointer.generationId) ||
260
+ typeof pointer.trustIdentity !== 'string'
261
+ ) throw new Error('Borg server trust pointer is invalid');
262
+ return pointer as EnrollmentTrustPointer;
263
+ }
264
+
265
+ async function publishTrustPointer(pointer: EnrollmentTrustPointer): Promise<void> {
266
+ const root = remoteTrustDirectory(pointer.origin);
267
+ await mkdir(root, { recursive: true, mode: 0o700 });
268
+ await assertPrivateDirectory(root);
269
+ await atomicWrite0600(trustPointerPath(pointer.origin), `${JSON.stringify(pointer)}\n`);
270
+ invalidateOriginTrustCache(pointer.origin);
271
+ }
272
+
273
+ async function removeTrustPointer(origin: string): Promise<void> {
274
+ try { await unlink(trustPointerPath(origin)); } catch (error) {
275
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
276
+ }
277
+ await syncPath(remoteTrustDirectory(origin));
278
+ invalidateOriginTrustCache(origin);
279
+ }
280
+
281
+ async function publishGeneration(
282
+ origin: string,
283
+ certificate: string,
284
+ identity: string,
285
+ ): Promise<string> {
286
+ const generationId = trustGenerationId(origin, certificate, identity);
287
+ const generations = trustGenerationsDirectory(origin);
288
+ const target = trustGenerationDirectory(origin, generationId);
289
+ await mkdir(generations, { recursive: true, mode: 0o700 });
290
+ await assertPrivateDirectory(resolve(remoteTrustDirectory(origin), '..'));
291
+ await assertPrivateDirectory(remoteTrustDirectory(origin));
292
+ await assertPrivateDirectory(generations);
293
+ if (await trustFilesExist(target)) {
294
+ const verified = await readGeneration(origin, generationId);
295
+ if (verified.identity !== identity) throw new Error('Borg server trust generation identity changed');
296
+ return generationId;
297
+ }
298
+ const temporary = `${target}.tmp-${process.pid}-${Date.now()}`;
299
+ try {
300
+ await mkdir(temporary, { mode: 0o700 });
301
+ const certificatePath = join(temporary, 'ca.crt');
302
+ const configPath = join(temporary, 'server.json');
303
+ await writeFile(certificatePath, certificate, { mode: 0o600, flag: 'wx' });
304
+ await syncPath(certificatePath);
305
+ await writeFile(configPath, JSON.stringify({
306
+ ca_spki_sha256: identity.replace(/^spki-sha256:/, ''),
307
+ }), { mode: 0o600, flag: 'wx' });
308
+ await syncPath(configPath);
309
+ await syncPath(temporary);
310
+ await rename(temporary, target);
311
+ await syncPath(generations);
312
+ const verified = await readGeneration(origin, generationId);
313
+ if (verified.identity !== identity) throw new Error('Borg server trust generation identity changed');
314
+ return generationId;
315
+ } catch (error) {
316
+ await rm(temporary, { recursive: true, force: true }).catch(() => undefined);
317
+ throw error;
318
+ }
319
+ }
320
+
321
+ async function migrateLegacyRemoteTrust(origin: string): Promise<EnrollmentTrustPointer | null> {
322
+ const directory = remoteTrustDirectory(origin);
323
+ if (!await trustFilesExist(directory)) return null;
324
+ const [certificate, configText] = await Promise.all([
325
+ readTrustFile(join(directory, 'ca.crt')),
326
+ readTrustFile(join(directory, 'server.json')),
327
+ ]);
328
+ const config = decodeTrustConfig(configText);
329
+ const identity = verifyCaIdentity(certificate, config.ca_spki_sha256);
330
+ const generationId = await publishGeneration(origin, certificate, identity);
331
+ const pointer: EnrollmentTrustPointer = { version: 1, origin, generationId, trustIdentity: identity };
332
+ await publishTrustPointer(pointer);
333
+ return pointer;
334
+ }
335
+
156
336
  function responseHeaders(rawHeaders: string[]): Headers {
157
337
  const headers = new Headers();
158
338
  for (let index = 0; index < rawHeaders.length; index += 2) {
@@ -214,6 +394,10 @@ export function createPinnedServerFetch(origin: string, caCertificate: string):
214
394
  headers: Object.fromEntries(headers.entries()),
215
395
  ca: caCertificate,
216
396
  rejectUnauthorized: true,
397
+ // The pinned CA is the authority boundary. OpenSSL still validates
398
+ // the complete chain; only the leaf's address SAN check is skipped
399
+ // so a DHCP change does not require reissuing the leaf certificate.
400
+ checkServerIdentity: () => undefined,
217
401
  minVersion: 'TLSv1.3',
218
402
  }, (incoming) => {
219
403
  const status = incoming.statusCode ?? 500;
@@ -266,32 +450,48 @@ export async function loadBorgServerTrust(
266
450
  origin: string,
267
451
  dataDirectory?: string,
268
452
  ): Promise<BorgServerTrust> {
269
- const requestedDirectory = dataDirectory ?? serverDataDirectory();
270
- const key = `${requestedDirectory}\0${origin}`;
271
- let pending = trustCache.get(key);
272
- if (!pending) {
273
- pending = (async () => {
274
- const remoteDirectory = remoteTrustDirectory(origin);
275
- const directory = dataDirectory === undefined && await trustFilesExist(remoteDirectory)
276
- ? remoteDirectory
277
- : dataDirectory === undefined && !(await trustFilesExist(requestedDirectory))
278
- ? remoteDirectory
279
- : requestedDirectory;
280
- const [certificate, configText] = await Promise.all([
281
- readTrustFile(join(directory, 'ca.crt')),
282
- readTrustFile(join(directory, 'server.json')),
283
- ]);
284
- const config = decodeTrustConfig(configText);
285
- const identity = verifyCaIdentity(certificate, config.ca_spki_sha256);
286
- return {
287
- identity,
288
- fetchImpl: createPinnedServerFetch(origin, certificate),
289
- };
290
- })();
291
- trustCache.set(key, pending);
292
- pending.catch(() => trustCache.delete(key));
453
+ const useRemoteState = dataDirectory === undefined && await remoteTrustStateExists(origin);
454
+ if (dataDirectory !== undefined || !useRemoteState && await trustFilesExist(serverDataDirectory())) {
455
+ const directory = dataDirectory ?? serverDataDirectory();
456
+ const key = `legacy\0${directory}\0${origin}`;
457
+ let pending = trustCache.get(key);
458
+ if (!pending) {
459
+ pending = (async () => {
460
+ const [certificate, configText] = await Promise.all([
461
+ readTrustFile(join(directory, 'ca.crt')),
462
+ readTrustFile(join(directory, 'server.json')),
463
+ ]);
464
+ const config = decodeTrustConfig(configText);
465
+ const identity = verifyCaIdentity(certificate, config.ca_spki_sha256);
466
+ return { identity, fetchImpl: createPinnedServerFetch(origin, certificate) };
467
+ })();
468
+ trustCache.set(key, pending);
469
+ pending.catch(() => trustCache.delete(key));
470
+ }
471
+ return pending;
293
472
  }
294
- return pending;
473
+ return withEnrollmentOriginLock(origin, async () => {
474
+ await recoverAcceptedEnrollment(origin);
475
+ const pointer = await readTrustPointer(origin) ?? await migrateLegacyRemoteTrust(origin);
476
+ if (!pointer) throw new Error('Borg server trust files were not found');
477
+ const key = `${origin}\0${pointer.generationId}\0${pointer.trustIdentity}`;
478
+ let pending = trustCache.get(key);
479
+ if (!pending) {
480
+ pending = (async () => {
481
+ const generation = await readGeneration(origin, pointer.generationId);
482
+ if (generation.identity !== pointer.trustIdentity) {
483
+ throw new Error('Borg server trust pointer identity does not match its generation');
484
+ }
485
+ return {
486
+ identity: generation.identity,
487
+ fetchImpl: createPinnedServerFetch(origin, generation.certificate),
488
+ };
489
+ })();
490
+ trustCache.set(key, pending);
491
+ pending.catch(() => trustCache.delete(key));
492
+ }
493
+ return pending;
494
+ });
295
495
  }
296
496
 
297
497
  function pemCertificate(raw: Buffer): string {
@@ -369,63 +569,189 @@ export async function stageBorgServerTrust(
369
569
  certificate: string,
370
570
  identity: string,
371
571
  ): Promise<StagedBorgServerTrust> {
372
- const directory = remoteTrustDirectory(origin);
373
- const parent = resolve(directory, '..');
374
- await mkdir(parent, { recursive: true, mode: 0o700 });
375
- const suffix = `${process.pid}-${Date.now()}`;
376
- const temporaryDirectory = `${directory}.tmp-${suffix}`;
377
- const temporaryCertificate = join(temporaryDirectory, 'ca.crt');
378
- const temporaryConfig = join(temporaryDirectory, 'server.json');
572
+ let generationId: string;
379
573
  try {
380
- await mkdir(temporaryDirectory, { recursive: false, mode: 0o700 });
381
- await Promise.all([
382
- writeFile(temporaryCertificate, certificate, { mode: 0o600, flag: 'wx' }),
383
- writeFile(temporaryConfig, JSON.stringify({ ca_spki_sha256: identity.replace(/^spki-sha256:/, '') }), { mode: 0o600, flag: 'wx' }),
384
- ]);
385
- await Promise.all([syncPath(temporaryCertificate), syncPath(temporaryConfig), syncPath(temporaryDirectory)]);
574
+ generationId = await withEnrollmentOriginLock(origin, () =>
575
+ publishGeneration(origin, certificate, identity));
386
576
  } catch {
387
- await rm(temporaryDirectory, { recursive: true, force: true });
388
577
  throw new InvitationArtifactStorageError();
389
578
  }
390
- let committed = false;
579
+ return stagedTrustFromGeneration(origin, generationId, identity, certificate);
580
+ }
581
+
582
+ function stagedTrustFromGeneration(
583
+ origin: string,
584
+ generationId: string,
585
+ identity: string,
586
+ certificate: string,
587
+ ): StagedBorgServerTrust {
588
+ let finalized = false;
391
589
  return {
392
590
  identity,
591
+ generationId,
393
592
  fetchImpl: createPinnedServerFetch(origin, certificate),
394
593
  commitTrust: async (activate) => {
395
- if (committed) return;
396
- const backupDirectory = `${directory}.backup-${suffix}`;
397
- let backedUp = false;
398
- try {
399
- await rename(directory, backupDirectory).then(() => { backedUp = true; }).catch((error: unknown) => {
400
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
401
- });
402
- await rename(temporaryDirectory, directory);
403
- await syncPath(resolve(directory, '..'));
404
- await activate();
405
- committed = true;
406
- await rm(backupDirectory, { recursive: true, force: true }).catch(() => undefined);
407
- } catch (error) {
408
- await rm(directory, { recursive: true, force: true });
409
- await rm(temporaryDirectory, { recursive: true, force: true });
410
- if (backedUp) {
411
- await rename(backupDirectory, directory);
412
- await syncPath(resolve(directory, '..'));
594
+ if (finalized) return;
595
+ await withEnrollmentOriginLock(origin, async () => {
596
+ const previousPointer = await readTrustPointer(origin) ?? await migrateLegacyRemoteTrust(origin);
597
+ let activated = false;
598
+ try {
599
+ await activate({ generationId, previousPointer });
600
+ const accepted = await getAcceptedEnrollmentMarker(origin);
601
+ if (
602
+ !accepted || accepted.generationId !== generationId ||
603
+ accepted.trustIdentity !== identity
604
+ ) throw new Error('Borg enrollment activation did not publish its accepted marker');
605
+ activated = true;
606
+ injectEnrollmentTrustFault('after-account-activation');
607
+ await publishTrustPointer({ version: 1, origin, generationId, trustIdentity: identity });
608
+ injectEnrollmentTrustFault('after-pointer-publish');
609
+ const verified = await readGeneration(origin, generationId);
610
+ if (verified.identity !== identity) throw new Error('published Borg trust generation changed');
611
+ injectEnrollmentTrustFault('before-marker-finalize');
612
+ await finalizeAcceptedEnrollment(origin, identity, generationId);
613
+ finalized = true;
614
+ } catch (error) {
615
+ let marker;
616
+ try { marker = await getAcceptedEnrollmentMarker(origin); } catch {
617
+ throw new InvitationArtifactRecoveryError();
618
+ }
619
+ // Activation is one atomic account-map replacement, but a durable
620
+ // backend may report an error after rename and before/while syncing
621
+ // the parent directory. The accepted marker, not the callback's
622
+ // return value, is therefore the authority for whether rollback is
623
+ // required.
624
+ if (!marker && !activated) throw error;
625
+ if (!marker) throw new InvitationArtifactRecoveryError();
626
+ try {
627
+ await restoreEnrollmentPointer(marker);
628
+ await restoreAcceptedEnrollmentAccounts(marker);
629
+ } catch {
630
+ throw new InvitationArtifactRecoveryError();
631
+ }
632
+ throw new InvitationArtifactRecoveryError();
413
633
  }
414
- throw new InvitationArtifactRecoveryError();
415
- }
634
+ });
416
635
  },
417
636
  discardTrust: async () => {
418
- if (committed) return;
419
- await rm(temporaryDirectory, { recursive: true, force: true });
637
+ if (finalized) return;
638
+ await withEnrollmentOriginLock(origin, async () => {
639
+ const pointer = await readTrustPointer(origin);
640
+ if (pointer?.generationId === generationId) return;
641
+ await rm(trustGenerationDirectory(origin, generationId), { recursive: true, force: true });
642
+ await syncPath(trustGenerationsDirectory(origin)).catch(() => undefined);
643
+ });
420
644
  },
421
645
  };
422
646
  }
423
647
 
648
+ async function restoreEnrollmentPointer(marker: {
649
+ origin: string;
650
+ previousPointer: EnrollmentTrustPointer | null;
651
+ }): Promise<void> {
652
+ injectEnrollmentTrustFault('during-pointer-rollback');
653
+ if (marker.previousPointer === null) {
654
+ await removeTrustPointer(marker.origin);
655
+ if (await readTrustPointer(marker.origin) !== null) throw new Error('Borg trust pointer rollback failed');
656
+ return;
657
+ }
658
+ const previous = await readGeneration(marker.origin, marker.previousPointer.generationId);
659
+ if (previous.identity !== marker.previousPointer.trustIdentity) {
660
+ throw new Error('Borg previous trust generation is invalid');
661
+ }
662
+ await publishTrustPointer(marker.previousPointer);
663
+ const restored = await readTrustPointer(marker.origin);
664
+ if (restored?.generationId !== marker.previousPointer.generationId) {
665
+ throw new Error('Borg trust pointer rollback failed');
666
+ }
667
+ }
668
+
669
+ async function recoverAcceptedEnrollment(origin: string): Promise<void> {
670
+ const marker = await getAcceptedEnrollmentMarker(origin);
671
+ if (!marker) return;
672
+ try {
673
+ const generation = await readGeneration(origin, marker.generationId);
674
+ if (generation.identity !== marker.trustIdentity) throw new Error('Borg enrollment generation identity changed');
675
+ await publishTrustPointer({
676
+ version: 1,
677
+ origin,
678
+ generationId: marker.generationId,
679
+ trustIdentity: marker.trustIdentity,
680
+ });
681
+ await finalizeAcceptedEnrollment(origin, marker.trustIdentity, marker.generationId);
682
+ } catch {
683
+ try {
684
+ await restoreEnrollmentPointer(marker);
685
+ await restoreAcceptedEnrollmentAccounts(marker);
686
+ } catch {
687
+ throw new InvitationArtifactRecoveryError();
688
+ }
689
+ throw new InvitationArtifactRecoveryError();
690
+ }
691
+ }
692
+
693
+ /** Verify the exact persisted artifact binding before any resumed network I/O. */
694
+ export async function loadStagedBorgServerTrust(
695
+ origin: string,
696
+ binding: EnrollmentArtifactBinding,
697
+ ): Promise<StagedBorgServerTrust> {
698
+ return withEnrollmentOriginLock(origin, async () => {
699
+ if (
700
+ binding.endpoint !== origin || binding.trustIdentity !== `spki-sha256:${binding.caSpkiSha256}`
701
+ ) throw new InvitationArtifactTrustError();
702
+ let generation: { certificate: string; identity: string };
703
+ try { generation = await readGeneration(origin, binding.stagedGenerationId); } catch {
704
+ throw new InvitationArtifactRecoveryError();
705
+ }
706
+ if (generation.identity !== binding.trustIdentity) throw new InvitationArtifactTrustError();
707
+ return stagedTrustFromGeneration(
708
+ origin,
709
+ binding.stagedGenerationId,
710
+ generation.identity,
711
+ generation.certificate,
712
+ );
713
+ });
714
+ }
715
+
716
+ /** Explicit operator recovery restores the exact accepted journal that was reviewed. */
717
+ export async function restoreBorgServerEnrollment(expected: AcceptedEnrollmentMarker): Promise<boolean> {
718
+ return withEnrollmentOriginLock(expected.origin, async () => {
719
+ const marker = await getAcceptedEnrollmentMarker(expected.origin);
720
+ if (!marker || JSON.stringify(marker) !== JSON.stringify(expected)) return false;
721
+ await restoreEnrollmentPointer(expected);
722
+ await restoreAcceptedEnrollmentAccounts(expected);
723
+ return true;
724
+ });
725
+ }
726
+
727
+ export async function clearStagedBorgServerTrust(
728
+ origin: string,
729
+ generationId: string | undefined,
730
+ ): Promise<void> {
731
+ if (!generationId) return;
732
+ await withEnrollmentOriginLock(origin, async () => {
733
+ const pointer = await readTrustPointer(origin);
734
+ if (pointer?.generationId === generationId) {
735
+ throw new InvitationArtifactRecoveryError();
736
+ }
737
+ await rm(trustGenerationDirectory(origin, generationId), { recursive: true, force: true });
738
+ await syncPath(trustGenerationsDirectory(origin)).catch(() => undefined);
739
+ });
740
+ }
741
+
424
742
  export function __clearServerTrustCacheForTest(): void {
425
743
  trustCache.clear();
426
744
  }
427
745
 
746
+ export function __setEnrollmentTrustFaultForTest(
747
+ points: EnrollmentTrustFaultPoint | EnrollmentTrustFaultPoint[] | null,
748
+ ): void {
749
+ enrollmentTrustFaultsForTest = new Set(points === null ? [] : Array.isArray(points) ? points : [points]);
750
+ }
751
+
428
752
  export async function clearBorgServerTrust(origin: string): Promise<void> {
429
- await rm(remoteTrustDirectory(origin), { recursive: true, force: true });
430
- trustCache.delete(`${serverDataDirectory()}\0${origin}`);
753
+ await withEnrollmentOriginLock(origin, async () => {
754
+ await rm(remoteTrustDirectory(origin), { recursive: true, force: true });
755
+ invalidateOriginTrustCache(origin);
756
+ });
431
757
  }
@@ -348,9 +348,9 @@ export const TOOL_MANIFEST: ToolManifestEntry[] = [
348
348
  properties: {
349
349
  name: {
350
350
  type: 'string',
351
- description: 'Cube name (lowercase letters, digits, hyphens; max 64 chars).',
352
- pattern: '^[a-z0-9-]+$',
353
- maxLength: 64,
351
+ description: 'Cube name (starts with a letter or digit; letters, digits, spaces, dots, underscores, or hyphens; max 120 UTF-8 bytes).',
352
+ pattern: '^[A-Za-z0-9][A-Za-z0-9 ._-]*$',
353
+ maxLength: 120,
354
354
  },
355
355
  cube_directive: { type: 'string', description: 'Project-specific Markdown shown to every drone when it refreshes cube context.' },
356
356
  template: {
@@ -363,17 +363,11 @@ export const TOOL_MANIFEST: ToolManifestEntry[] = [
363
363
  },
364
364
  {
365
365
  name: 'borg_update-cube',
366
- description: 'Update a cube\'s name, cube_directive, and/or message_taxonomy. Pass only what changes.',
366
+ description: 'Update a cube\'s cube_directive and/or message_taxonomy. Pass only what changes.',
367
367
  inputSchema: {
368
368
  type: 'object',
369
369
  properties: {
370
370
  cube_id: { type: 'string', description: 'UUID of the cube to update.' },
371
- name: {
372
- type: 'string',
373
- description: 'New name (optional). Lowercase letters, digits, hyphens; max 64 chars.',
374
- pattern: '^[a-z0-9-]+$',
375
- maxLength: 64,
376
- },
377
371
  cube_directive: { type: 'string', description: 'New cube directive markdown (optional).' },
378
372
  message_taxonomy: {
379
373
  type: 'array',