borgmcp 2.10.0 → 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 (67) hide show
  1. package/dist/assimilate-cmd.d.ts.map +1 -1
  2. package/dist/assimilate-cmd.js +10 -3
  3. package/dist/assimilate-cmd.js.map +1 -1
  4. package/dist/cli-help.d.ts.map +1 -1
  5. package/dist/cli-help.js +5 -3
  6. package/dist/cli-help.js.map +1 -1
  7. package/dist/config.d.ts +34 -9
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +487 -105
  10. package/dist/config.js.map +1 -1
  11. package/dist/enrollment-lock.d.ts +10 -0
  12. package/dist/enrollment-lock.d.ts.map +1 -0
  13. package/dist/enrollment-lock.js +49 -0
  14. package/dist/enrollment-lock.js.map +1 -0
  15. package/dist/enrollment-types.d.ts +47 -0
  16. package/dist/enrollment-types.d.ts.map +1 -0
  17. package/dist/enrollment-types.js +2 -0
  18. package/dist/enrollment-types.js.map +1 -0
  19. package/dist/index.js +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/invitation-artifact.d.ts +3 -1
  22. package/dist/invitation-artifact.d.ts.map +1 -1
  23. package/dist/invitation-artifact.js +3 -1
  24. package/dist/invitation-artifact.js.map +1 -1
  25. package/dist/recover-enrollment-cmd.d.ts.map +1 -1
  26. package/dist/recover-enrollment-cmd.js +34 -15
  27. package/dist/recover-enrollment-cmd.js.map +1 -1
  28. package/dist/remote-client.d.ts +3 -2
  29. package/dist/remote-client.d.ts.map +1 -1
  30. package/dist/remote-client.js +32 -11
  31. package/dist/remote-client.js.map +1 -1
  32. package/dist/server-errors.d.ts +4 -0
  33. package/dist/server-errors.d.ts.map +1 -1
  34. package/dist/server-errors.js +8 -0
  35. package/dist/server-errors.js.map +1 -1
  36. package/dist/server-facade.d.ts +1 -1
  37. package/dist/server-facade.d.ts.map +1 -1
  38. package/dist/server-facade.js +13 -2
  39. package/dist/server-facade.js.map +1 -1
  40. package/dist/server-handshake.d.ts +11 -3
  41. package/dist/server-handshake.d.ts.map +1 -1
  42. package/dist/server-handshake.js +103 -45
  43. package/dist/server-handshake.js.map +1 -1
  44. package/dist/server-trust.d.ts +14 -1
  45. package/dist/server-trust.d.ts.map +1 -1
  46. package/dist/server-trust.js +363 -66
  47. package/dist/server-trust.js.map +1 -1
  48. package/dist/tool-manifest.d.ts.map +1 -1
  49. package/dist/tool-manifest.js +4 -10
  50. package/dist/tool-manifest.js.map +1 -1
  51. package/docs/EXTRACTION_PROVENANCE.md +3 -3
  52. package/docs/RELEASING.md +40 -1
  53. package/package.json +1 -1
  54. package/src/assimilate-cmd.ts +14 -4
  55. package/src/cli-help.ts +5 -3
  56. package/src/config.ts +544 -101
  57. package/src/enrollment-lock.ts +53 -0
  58. package/src/enrollment-types.ts +51 -0
  59. package/src/index.ts +1 -1
  60. package/src/invitation-artifact.ts +7 -1
  61. package/src/recover-enrollment-cmd.ts +39 -15
  62. package/src/remote-client.ts +36 -10
  63. package/src/server-errors.ts +7 -0
  64. package/src/server-facade.ts +13 -2
  65. package/src/server-handshake.ts +130 -52
  66. package/src/server-trust.ts +392 -66
  67. package/src/tool-manifest.ts +4 -10
package/dist/config.js CHANGED
@@ -10,8 +10,12 @@ import { createHash, randomBytes, randomUUID } from 'crypto';
10
10
  import { withStoreLock } from './seat-store.js';
11
11
  import { makeFileBackend, } from './token-store.js';
12
12
  import { BORG_USER_ROOT, SERVER_CREDENTIALS_FILE } from './credential-paths.js';
13
+ import { withEnrollmentOriginLock } from './enrollment-lock.js';
14
+ import { InvitationArtifactRecoveryError, MISKEYED_RECOVERY_ERROR, } from './invitation-artifact.js';
13
15
  const SERVER_CREDENTIAL_RECORD_VERSION = 2;
14
- const SERVER_PENDING_ENROLLMENT_RECORD_VERSION = 1;
16
+ const SERVER_PENDING_ENROLLMENT_RECORD_VERSION = 3;
17
+ const LEGACY_SERVER_PENDING_ENROLLMENT_RECORD_VERSION = 1;
18
+ const SERVER_ACCEPTED_ENROLLMENT_MARKER_VERSION = 1;
15
19
  const SERVER_CUBE_RETRY_RECORD_VERSION = 2;
16
20
  // The 0600 credential store (Queen rescope: replaces the OS keychain). A single
17
21
  // file holds every parent credential/enrollment record; a single flock
@@ -59,6 +63,51 @@ function serverPendingEnrollmentAccount(origin, trustIdentity) {
59
63
  .digest('hex');
60
64
  return `borg-server-enrollment-pending:${binding}`;
61
65
  }
66
+ function serverAcceptedEnrollmentAccount(origin) {
67
+ const binding = createHash('sha256').update(origin).digest('hex');
68
+ return `borg-server-enrollment-accepted:${binding}`;
69
+ }
70
+ function serverEnrollmentRollbackAccount(origin, retryKey) {
71
+ const binding = createHash('sha256').update(origin).update('\0').update(retryKey).digest('hex');
72
+ return `borg-server-enrollment-rollback:${binding}`;
73
+ }
74
+ function digestAccountSnapshot(accounts, origin) {
75
+ const selected = Object.entries(accounts)
76
+ .filter(([, value]) => {
77
+ try {
78
+ const parsed = JSON.parse(value);
79
+ return parsed.version === SERVER_CREDENTIAL_RECORD_VERSION && parsed.origin === origin;
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ })
85
+ .sort(([left], [right]) => left.localeCompare(right));
86
+ return createHash('sha256').update(JSON.stringify(selected)).digest('hex');
87
+ }
88
+ function validateArtifactBinding(binding) {
89
+ if (binding === undefined)
90
+ return;
91
+ validateServerCredentialBinding(binding.endpoint, binding.trustIdentity);
92
+ if (!Number.isSafeInteger(binding.artifactFormatVersion) || binding.artifactFormatVersion < 1 ||
93
+ !/^[a-f0-9]{64}$/.test(binding.artifactDigest) ||
94
+ !/^[a-f0-9]{64}$/.test(binding.caSpkiSha256) ||
95
+ !/^[a-f0-9]{64}$/.test(binding.stagedGenerationId) ||
96
+ binding.endpoint.length < 1 ||
97
+ binding.expectedAuthority !== 'owner' && binding.expectedAuthority !== 'client')
98
+ throw new Error('invalid Borg enrollment artifact binding');
99
+ }
100
+ function validateReplacementCapability(capability) {
101
+ if (capability === undefined)
102
+ return;
103
+ validateUuid(capability.token, 'replacement capability');
104
+ validateServerCredentialBinding(capability.endpoint, capability.trustIdentity);
105
+ if (!/^[a-f0-9]{64}$/.test(capability.priorAccountsDigest) ||
106
+ !/^[a-f0-9]{64}$/.test(capability.caSpkiSha256) ||
107
+ !/^[a-f0-9]{64}$/.test(capability.artifactDigest))
108
+ throw new Error('invalid Borg enrollment replacement capability');
109
+ validateUuid(capability.retryKey, 'enrollment retry key');
110
+ }
62
111
  function validateUuid(value, label) {
63
112
  if (!UUID_RE.test(value))
64
113
  throw new Error(`invalid Borg server ${label}`);
@@ -223,6 +272,102 @@ function encodeServerCredentialRecord(record, serverCapabilities) {
223
272
  serverCapabilities,
224
273
  });
225
274
  }
275
+ function decodeActiveServerCredentialRecord(stored, origin, trustIdentity) {
276
+ const record = JSON.parse(stored);
277
+ if (record.version !== SERVER_CREDENTIAL_RECORD_VERSION ||
278
+ record.origin !== origin ||
279
+ record.trustIdentity !== trustIdentity ||
280
+ typeof record.credential !== 'string' ||
281
+ !/^[A-Za-z0-9_-]{43}$/.test(record.credential) ||
282
+ (record.clientId !== null &&
283
+ (typeof record.clientId !== 'string' || !UUID_RE.test(record.clientId))) ||
284
+ !Array.isArray(record.serverCapabilities))
285
+ throw new Error('invalid Borg server credential record');
286
+ const serverCapabilities = validateServerCapabilities(record.serverCapabilities);
287
+ return {
288
+ origin,
289
+ trustIdentity,
290
+ credential: record.credential,
291
+ clientId: record.clientId,
292
+ serverCapabilities,
293
+ };
294
+ }
295
+ function decodeAcceptedEnrollmentMarker(value) {
296
+ const marker = JSON.parse(value);
297
+ if (marker.version !== SERVER_ACCEPTED_ENROLLMENT_MARKER_VERSION ||
298
+ marker.state !== 'accepted' ||
299
+ typeof marker.origin !== 'string' ||
300
+ typeof marker.trustIdentity !== 'string' ||
301
+ typeof marker.generationId !== 'string' || !/^[a-f0-9]{64}$/.test(marker.generationId) ||
302
+ (marker.previousPointer !== null && typeof marker.previousPointer !== 'object') ||
303
+ typeof marker.activeDigest !== 'string' || !/^[a-f0-9]{64}$/.test(marker.activeDigest) ||
304
+ typeof marker.rollbackAccount !== 'string' ||
305
+ typeof marker.rollbackDigest !== 'string' || !/^[a-f0-9]{64}$/.test(marker.rollbackDigest))
306
+ throw new Error('invalid Borg enrollment recovery marker');
307
+ validateServerCredentialBinding(marker.origin, marker.trustIdentity);
308
+ if (marker.previousPointer !== null) {
309
+ const pointer = marker.previousPointer;
310
+ if (pointer.version !== 1 || pointer.origin !== marker.origin ||
311
+ typeof pointer.generationId !== 'string' || !/^[a-f0-9]{64}$/.test(pointer.generationId) ||
312
+ typeof pointer.trustIdentity !== 'string')
313
+ throw new Error('invalid Borg enrollment recovery marker');
314
+ validateServerCredentialBinding(pointer.origin, pointer.trustIdentity);
315
+ }
316
+ return marker;
317
+ }
318
+ function decodeRollbackRecord(value, marker) {
319
+ if (createHash('sha256').update(value).digest('hex') !== marker.rollbackDigest) {
320
+ throw new Error('Borg enrollment rollback snapshot digest is invalid');
321
+ }
322
+ const record = JSON.parse(value);
323
+ if (record.version !== 1 || record.state !== 'rollback-snapshot' || record.origin !== marker.origin ||
324
+ typeof record.snapshot !== 'object' || record.snapshot === null ||
325
+ typeof record.snapshot.pendingAccount !== 'string' ||
326
+ typeof record.snapshot.pendingValue !== 'string' ||
327
+ typeof record.snapshot.activeAccounts !== 'object' || record.snapshot.activeAccounts === null)
328
+ throw new Error('invalid Borg enrollment rollback snapshot');
329
+ for (const [account, stored] of Object.entries(record.snapshot.activeAccounts)) {
330
+ if (typeof account !== 'string' || typeof stored !== 'string') {
331
+ throw new Error('invalid Borg enrollment rollback snapshot');
332
+ }
333
+ let active;
334
+ try {
335
+ active = JSON.parse(stored);
336
+ }
337
+ catch {
338
+ throw new Error('invalid Borg enrollment rollback snapshot');
339
+ }
340
+ if (active.version !== SERVER_CREDENTIAL_RECORD_VERSION || active.origin !== marker.origin ||
341
+ typeof active.trustIdentity !== 'string' ||
342
+ account !== serverCredentialAccount(marker.origin, active.trustIdentity))
343
+ throw new Error('invalid Borg enrollment rollback snapshot');
344
+ }
345
+ let pendingRaw;
346
+ try {
347
+ pendingRaw = JSON.parse(record.snapshot.pendingValue);
348
+ }
349
+ catch {
350
+ throw new Error('invalid Borg enrollment rollback snapshot');
351
+ }
352
+ if (typeof pendingRaw.origin !== 'string' || typeof pendingRaw.trustIdentity !== 'string') {
353
+ throw new Error('invalid Borg enrollment rollback snapshot');
354
+ }
355
+ const pending = decodePendingServerEnrollment(record.snapshot.pendingValue, pendingRaw.origin, pendingRaw.trustIdentity);
356
+ if (pending.origin !== marker.origin || pending.trustIdentity !== marker.trustIdentity ||
357
+ record.snapshot.pendingAccount !== serverPendingEnrollmentAccount(marker.origin, marker.trustIdentity) ||
358
+ marker.rollbackAccount !== serverEnrollmentRollbackAccount(marker.origin, pending.retryKey))
359
+ throw new Error('invalid Borg enrollment rollback snapshot');
360
+ return record;
361
+ }
362
+ async function markerForOriginUnlocked(backend, origin) {
363
+ const stored = await backend.get(serverAcceptedEnrollmentAccount(origin));
364
+ if (!stored)
365
+ return null;
366
+ return decodeAcceptedEnrollmentMarker(stored);
367
+ }
368
+ function processSharedEnrollmentLock() {
369
+ return !testBackendInjected;
370
+ }
226
371
  /**
227
372
  * Persist one self-hosted server credential in the dedicated 0600 credential store.
228
373
  *
@@ -233,46 +378,28 @@ function encodeServerCredentialRecord(record, serverCapabilities) {
233
378
  * not credential sources. CR3b: the load→set→rename runs inside ONE hold of the
234
379
  * single store lock so a concurrent writer cannot lose an unrelated account.
235
380
  */
236
- export async function storeServerCredential(record, options = {}) {
381
+ export async function storeServerCredential(record) {
237
382
  const backend = await getServerCredentialBackend();
238
- await withCredentialStoreLock(() => writeServerCredentialRecord(backend, record, options.allowReplacement === true));
383
+ await withEnrollmentOriginLock(record.origin, () => withCredentialStoreLock(() => writeServerCredentialRecord(backend, record, false)), {
384
+ processShared: processSharedEnrollmentLock(),
385
+ });
239
386
  }
240
387
  /** Read an authority-bound active client record, failing closed on corruption. */
241
388
  export async function getServerCredentialRecord(origin, trustIdentity) {
242
389
  const backend = await getServerCredentialBackend();
243
- const stored = await backend.get(serverCredentialAccount(origin, trustIdentity));
244
- if (!stored)
245
- return null;
246
- try {
247
- const record = JSON.parse(stored);
248
- if (record.version !== SERVER_CREDENTIAL_RECORD_VERSION ||
249
- record.origin !== origin ||
250
- record.trustIdentity !== trustIdentity ||
251
- typeof record.credential !== 'string' ||
252
- !/^[A-Za-z0-9_-]{43}$/.test(record.credential) ||
253
- (record.clientId !== null &&
254
- (typeof record.clientId !== 'string' || !UUID_RE.test(record.clientId))) ||
255
- !Array.isArray(record.serverCapabilities)) {
390
+ return withEnrollmentOriginLock(origin, () => withCredentialStoreLock(async () => {
391
+ if (await markerForOriginUnlocked(backend, origin))
392
+ throw new InvitationArtifactRecoveryError();
393
+ const stored = await backend.get(serverCredentialAccount(origin, trustIdentity));
394
+ if (!stored)
256
395
  return null;
257
- }
258
- let serverCapabilities;
259
396
  try {
260
- serverCapabilities = validateServerCapabilities(record.serverCapabilities);
397
+ return decodeActiveServerCredentialRecord(stored, origin, trustIdentity);
261
398
  }
262
399
  catch {
263
400
  return null;
264
401
  }
265
- return {
266
- origin,
267
- trustIdentity,
268
- credential: record.credential,
269
- clientId: record.clientId,
270
- serverCapabilities,
271
- };
272
- }
273
- catch {
274
- return null;
275
- }
402
+ }), { processShared: processSharedEnrollmentLock() });
276
403
  }
277
404
  /** Read only the bearer for existing call sites that do not need capability metadata. */
278
405
  export async function getServerCredential(origin, trustIdentity) {
@@ -282,32 +409,46 @@ export async function hasServerCredentialForOrigin(origin) {
282
409
  const backend = await getServerCredentialBackend();
283
410
  if (!backend.entries)
284
411
  return false;
285
- const accounts = await backend.entries();
286
- return Object.values(accounts).some((value) => {
287
- try {
288
- const record = JSON.parse(value);
289
- return record.version === SERVER_CREDENTIAL_RECORD_VERSION && record.origin === origin;
290
- }
291
- catch {
292
- return false;
293
- }
294
- });
412
+ return withEnrollmentOriginLock(origin, () => withCredentialStoreLock(async () => {
413
+ if (await markerForOriginUnlocked(backend, origin))
414
+ throw new InvitationArtifactRecoveryError();
415
+ const accounts = await backend.entries();
416
+ return Object.values(accounts).some((value) => {
417
+ try {
418
+ const record = JSON.parse(value);
419
+ return record.version === SERVER_CREDENTIAL_RECORD_VERSION && record.origin === origin;
420
+ }
421
+ catch {
422
+ return false;
423
+ }
424
+ });
425
+ }), { processShared: processSharedEnrollmentLock() });
295
426
  }
296
427
  function decodePendingServerEnrollment(stored, origin, trustIdentity) {
297
428
  const record = JSON.parse(stored);
298
- if (record.version !== SERVER_PENDING_ENROLLMENT_RECORD_VERSION ||
429
+ if (record.version !== SERVER_PENDING_ENROLLMENT_RECORD_VERSION &&
430
+ record.version !== LEGACY_SERVER_PENDING_ENROLLMENT_RECORD_VERSION ||
299
431
  record.state !== 'pending' ||
300
432
  record.origin !== origin ||
301
433
  record.trustIdentity !== trustIdentity ||
302
434
  typeof record.invitation !== 'string' ||
303
435
  typeof record.retryKey !== 'string' || !UUID_RE.test(record.retryKey) ||
304
436
  typeof record.credential !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(record.credential) ||
305
- (record.clientName !== undefined && typeof record.clientName !== 'string')
306
- || (record.replacementCapability !== undefined && !UUID_RE.test(record.replacementCapability))) {
437
+ (record.clientName !== undefined && typeof record.clientName !== 'string')) {
307
438
  throw new Error('invalid');
308
439
  }
309
440
  validateInvitation(record.invitation);
310
441
  validateClientName(record.clientName);
442
+ validateArtifactBinding(record.artifactBinding);
443
+ validateReplacementCapability(record.replacementCapability);
444
+ if (record.artifactBinding && (record.artifactBinding.endpoint !== origin ||
445
+ record.artifactBinding.trustIdentity !== trustIdentity ||
446
+ record.replacementCapability && (record.replacementCapability.endpoint !== origin ||
447
+ record.replacementCapability.trustIdentity !== trustIdentity ||
448
+ record.replacementCapability.retryKey !== record.retryKey ||
449
+ record.replacementCapability.artifactDigest !== record.artifactBinding.artifactDigest ||
450
+ record.replacementCapability.caSpkiSha256 !== record.artifactBinding.caSpkiSha256)))
451
+ throw new Error('invalid');
311
452
  return {
312
453
  origin,
313
454
  trustIdentity,
@@ -315,6 +456,7 @@ function decodePendingServerEnrollment(stored, origin, trustIdentity) {
315
456
  retryKey: record.retryKey,
316
457
  credential: record.credential,
317
458
  ...(record.clientName === undefined ? {} : { clientName: record.clientName }),
459
+ ...(record.artifactBinding === undefined ? {} : { artifactBinding: record.artifactBinding }),
318
460
  ...(record.replacementCapability === undefined ? {} : { replacementCapability: record.replacementCapability }),
319
461
  };
320
462
  }
@@ -323,7 +465,9 @@ export async function getPendingServerEnrollment(origin, trustIdentity) {
323
465
  validateServerCredentialBinding(origin, trustIdentity);
324
466
  const backend = await getServerCredentialBackend();
325
467
  const account = serverPendingEnrollmentAccount(origin, trustIdentity);
326
- return withCredentialStoreLock(async () => {
468
+ return withEnrollmentOriginLock(origin, () => withCredentialStoreLock(async () => {
469
+ if (await markerForOriginUnlocked(backend, origin))
470
+ throw new InvitationArtifactRecoveryError();
327
471
  const stored = await backend.get(account);
328
472
  if (!stored)
329
473
  return null;
@@ -333,7 +477,7 @@ export async function getPendingServerEnrollment(origin, trustIdentity) {
333
477
  catch {
334
478
  throw new Error('pending Borg server enrollment is corrupt');
335
479
  }
336
- });
480
+ }), { processShared: processSharedEnrollmentLock() });
337
481
  }
338
482
  /** Find the sole pending enrollment so artifact-only retries need no invitation. */
339
483
  export async function findPendingServerEnrollment() {
@@ -342,16 +486,33 @@ export async function findPendingServerEnrollment() {
342
486
  return null;
343
487
  return withCredentialStoreLock(async () => {
344
488
  const matches = [];
345
- for (const value of Object.values(await backend.entries())) {
489
+ for (const [account, value] of Object.entries(await backend.entries())) {
346
490
  try {
347
491
  const raw = JSON.parse(value);
348
- if (raw.version !== SERVER_PENDING_ENROLLMENT_RECORD_VERSION || raw.state !== 'pending')
492
+ if (raw.version === SERVER_ACCEPTED_ENROLLMENT_MARKER_VERSION && raw.state === 'accepted') {
493
+ const marker = decodeAcceptedEnrollmentMarker(value);
494
+ if (account !== serverAcceptedEnrollmentAccount(marker.origin)) {
495
+ throw new InvitationArtifactRecoveryError(MISKEYED_RECOVERY_ERROR);
496
+ }
497
+ throw new InvitationArtifactRecoveryError();
498
+ }
499
+ if (raw.version !== SERVER_PENDING_ENROLLMENT_RECORD_VERSION &&
500
+ raw.version !== LEGACY_SERVER_PENDING_ENROLLMENT_RECORD_VERSION ||
501
+ raw.state !== 'pending')
349
502
  continue;
350
503
  if (typeof raw.origin !== 'string' || typeof raw.trustIdentity !== 'string')
351
504
  continue;
505
+ if (account !== serverPendingEnrollmentAccount(raw.origin, raw.trustIdentity)) {
506
+ throw new InvitationArtifactRecoveryError(MISKEYED_RECOVERY_ERROR);
507
+ }
352
508
  matches.push(decodePendingServerEnrollment(value, raw.origin, raw.trustIdentity));
353
509
  }
354
- catch {
510
+ catch (error) {
511
+ if (error instanceof InvitationArtifactRecoveryError)
512
+ throw error;
513
+ if (account.startsWith('borg-server-enrollment-pending:') ||
514
+ account.startsWith('borg-server-enrollment-accepted:'))
515
+ throw new InvitationArtifactRecoveryError();
355
516
  // Ignore unrelated or corrupt records; the keyed resume path remains fail-closed.
356
517
  }
357
518
  }
@@ -366,21 +527,37 @@ export async function findPendingServerEnrollment() {
366
527
  * this makes response-loss retries exact without minting a second bearer.
367
528
  */
368
529
  export async function getOrCreatePendingServerEnrollment(input) {
530
+ return getOrCreatePendingServerEnrollmentInternal(input, false);
531
+ }
532
+ /**
533
+ * Mint replacement consent only for the fresh interactive confirmation path.
534
+ * Resume can load and consume the resulting capability, but has no mint API.
535
+ */
536
+ export async function createPendingServerEnrollmentWithReplacementConsent(input) {
537
+ return getOrCreatePendingServerEnrollmentInternal(input, true);
538
+ }
539
+ async function getOrCreatePendingServerEnrollmentInternal(input, mintReplacementConsent) {
369
540
  validateServerCredentialBinding(input.origin, input.trustIdentity);
370
541
  validateInvitation(input.invitation);
371
542
  validateClientName(input.clientName);
372
- if (input.replacementCapability !== undefined)
373
- validateUuid(input.replacementCapability, 'replacement capability');
543
+ validateArtifactBinding(input.artifactBinding);
544
+ if (input.artifactBinding !== undefined &&
545
+ createHash('sha256').update(input.invitation).digest('hex') !== input.artifactBinding.artifactDigest)
546
+ throw new Error('enrollment invitation does not match its verified artifact binding');
547
+ if (mintReplacementConsent && input.artifactBinding === undefined) {
548
+ throw new Error('replacement consent requires a verified invitation artifact binding');
549
+ }
374
550
  const backend = await getServerCredentialBackend();
375
551
  const account = serverPendingEnrollmentAccount(input.origin, input.trustIdentity);
376
- return withCredentialStoreLock(async () => {
552
+ return withEnrollmentOriginLock(input.origin, () => withCredentialStoreLock(async () => {
377
553
  const stored = await backend.get(account);
378
554
  if (stored) {
379
555
  try {
380
556
  const record = decodePendingServerEnrollment(stored, input.origin, input.trustIdentity);
381
557
  if (record.invitation !== input.invitation ||
382
558
  record.clientName !== input.clientName
383
- || record.replacementCapability !== input.replacementCapability) {
559
+ || JSON.stringify(record.artifactBinding) !== JSON.stringify(input.artifactBinding)
560
+ || mintReplacementConsent && record.replacementCapability === undefined) {
384
561
  throw new Error('mismatch');
385
562
  }
386
563
  return record;
@@ -389,14 +566,26 @@ export async function getOrCreatePendingServerEnrollment(input) {
389
566
  throw new Error('pending Borg server enrollment does not match this request');
390
567
  }
391
568
  }
569
+ const retryKey = randomUUID();
570
+ const accounts = backend.entries ? await backend.entries() : {};
571
+ const replacementCapability = mintReplacementConsent ? {
572
+ token: randomUUID(),
573
+ priorAccountsDigest: digestAccountSnapshot(accounts, input.origin),
574
+ endpoint: input.origin,
575
+ caSpkiSha256: input.artifactBinding.caSpkiSha256,
576
+ trustIdentity: input.trustIdentity,
577
+ retryKey,
578
+ artifactDigest: input.artifactBinding.artifactDigest,
579
+ } : undefined;
392
580
  const record = {
393
581
  origin: input.origin,
394
582
  trustIdentity: input.trustIdentity,
395
583
  invitation: input.invitation,
396
- retryKey: randomUUID(),
584
+ retryKey,
397
585
  credential: randomBytes(32).toString('base64url'),
398
586
  ...(input.clientName === undefined ? {} : { clientName: input.clientName }),
399
- ...(input.replacementCapability === undefined ? {} : { replacementCapability: input.replacementCapability }),
587
+ ...(input.artifactBinding === undefined ? {} : { artifactBinding: input.artifactBinding }),
588
+ ...(replacementCapability === undefined ? {} : { replacementCapability }),
400
589
  };
401
590
  validateEnrollmentCredential(record.credential);
402
591
  await backend.set(account, JSON.stringify({
@@ -405,7 +594,7 @@ export async function getOrCreatePendingServerEnrollment(input) {
405
594
  ...record,
406
595
  }));
407
596
  return record;
408
- });
597
+ }), { processShared: processSharedEnrollmentLock() });
409
598
  }
410
599
  /** Activate the exact pending tuple only after a verified server response. */
411
600
  export async function activatePendingServerEnrollment(input) {
@@ -416,34 +605,53 @@ export async function activatePendingServerEnrollment(input) {
416
605
  const serverCapabilities = validateServerCapabilities(input.serverCapabilities);
417
606
  const backend = await getServerCredentialBackend();
418
607
  const pendingAccount = serverPendingEnrollmentAccount(input.origin, input.trustIdentity);
419
- await withCredentialStoreLock(async () => {
608
+ await withEnrollmentOriginLock(input.origin, () => withCredentialStoreLock(async () => {
609
+ if (!backend.entries || !backend.replaceAccounts) {
610
+ throw new Error('Borg credential store does not support atomic enrollment activation');
611
+ }
420
612
  const stored = await backend.get(pendingAccount);
421
613
  if (!stored)
422
614
  throw new Error('pending Borg server enrollment is missing');
423
615
  try {
424
616
  const pending = decodePendingServerEnrollment(stored, input.origin, input.trustIdentity);
425
- if (pending.retryKey !== input.retryKey || pending.credential !== input.credential ||
426
- pending.replacementCapability !== input.replacementCapability) {
617
+ if (pending.retryKey !== input.retryKey || pending.credential !== input.credential) {
427
618
  throw new Error('mismatch');
428
619
  }
429
620
  }
430
621
  catch {
431
622
  throw new Error('pending Borg server enrollment does not match the verified response');
432
623
  }
433
- if (!input.allowReplacement) {
434
- const existing = backend.entries
435
- ? Object.values(await backend.entries()).some((value) => {
436
- try {
437
- const record = JSON.parse(value);
438
- return record.version === SERVER_CREDENTIAL_RECORD_VERSION && record.origin === input.origin;
439
- }
440
- catch {
441
- return false;
442
- }
443
- })
444
- : await backend.get(serverCredentialAccount(input.origin, input.trustIdentity)) !== null;
445
- if (existing)
446
- throw new Error('local Borg server enrollment already exists');
624
+ const pending = decodePendingServerEnrollment(stored, input.origin, input.trustIdentity);
625
+ const accounts = await backend.entries();
626
+ const activeAccounts = Object.fromEntries(Object.entries(accounts).filter(([, value]) => {
627
+ try {
628
+ const record = JSON.parse(value);
629
+ return record.version === SERVER_CREDENTIAL_RECORD_VERSION && record.origin === input.origin;
630
+ }
631
+ catch {
632
+ return false;
633
+ }
634
+ }));
635
+ if (Object.keys(activeAccounts).length > 0) {
636
+ const capability = pending.replacementCapability;
637
+ if (!capability || capability.token !== input.replacementCapabilityToken) {
638
+ throw new Error('local Borg server enrollment already exists and replacement consent is missing');
639
+ }
640
+ validateReplacementCapability(capability);
641
+ if (capability.priorAccountsDigest !== digestAccountSnapshot(accounts, input.origin) ||
642
+ capability.endpoint !== input.origin ||
643
+ capability.trustIdentity !== input.trustIdentity ||
644
+ capability.retryKey !== input.retryKey ||
645
+ capability.artifactDigest !== pending.artifactBinding?.artifactDigest ||
646
+ capability.caSpkiSha256 !== pending.artifactBinding?.caSpkiSha256)
647
+ throw new Error('Borg enrollment replacement consent does not match this transaction');
648
+ }
649
+ else if (input.replacementCapabilityToken !== undefined) {
650
+ throw new Error('Borg enrollment replacement consent does not match this transaction');
651
+ }
652
+ const generationId = input.generationId ?? pending.artifactBinding?.stagedGenerationId;
653
+ if (pending.artifactBinding && generationId !== pending.artifactBinding.stagedGenerationId) {
654
+ throw new Error('pending Borg server enrollment generation does not match the verified response');
447
655
  }
448
656
  const activeRecord = {
449
657
  origin: input.origin,
@@ -452,31 +660,180 @@ export async function activatePendingServerEnrollment(input) {
452
660
  clientId: input.clientId,
453
661
  serverCapabilities,
454
662
  };
455
- // Replace the active record and retire the pending tuple in one account-map
456
- // write. A failed map write therefore preserves the old active + pending state.
457
- if (backend.entries && backend.replaceAccounts) {
458
- const accounts = await backend.entries();
459
- const target = serverCredentialAccount(input.origin, input.trustIdentity);
460
- const next = { ...accounts };
461
- for (const [account, value] of Object.entries(accounts)) {
462
- try {
463
- const parsed = JSON.parse(value);
464
- if (parsed.version === SERVER_CREDENTIAL_RECORD_VERSION && parsed.origin === input.origin) {
465
- delete next[account];
663
+ const target = serverCredentialAccount(input.origin, input.trustIdentity);
664
+ const next = { ...accounts };
665
+ for (const [account, value] of Object.entries(accounts)) {
666
+ try {
667
+ const parsed = JSON.parse(value);
668
+ if (parsed.version === SERVER_CREDENTIAL_RECORD_VERSION && parsed.origin === input.origin) {
669
+ delete next[account];
670
+ }
671
+ }
672
+ catch {
673
+ // Preserve unrelated malformed records; the backend's normal validation remains fail-closed.
674
+ }
675
+ }
676
+ const activeValue = encodeServerCredentialRecord(activeRecord, serverCapabilities);
677
+ next[target] = activeValue;
678
+ delete next[pendingAccount];
679
+ if (generationId !== undefined) {
680
+ const rollbackAccount = serverEnrollmentRollbackAccount(input.origin, input.retryKey);
681
+ const rollbackRecord = {
682
+ version: 1,
683
+ state: 'rollback-snapshot',
684
+ origin: input.origin,
685
+ snapshot: { activeAccounts, pendingAccount, pendingValue: stored },
686
+ };
687
+ const rollbackValue = JSON.stringify(rollbackRecord);
688
+ const marker = {
689
+ version: SERVER_ACCEPTED_ENROLLMENT_MARKER_VERSION,
690
+ state: 'accepted',
691
+ origin: input.origin,
692
+ trustIdentity: input.trustIdentity,
693
+ generationId,
694
+ previousPointer: input.previousPointer ?? null,
695
+ activeDigest: createHash('sha256').update(activeValue).digest('hex'),
696
+ rollbackAccount,
697
+ rollbackDigest: createHash('sha256').update(rollbackValue).digest('hex'),
698
+ };
699
+ next[rollbackAccount] = rollbackValue;
700
+ next[serverAcceptedEnrollmentAccount(input.origin)] = JSON.stringify(marker);
701
+ }
702
+ await backend.replaceAccounts(next);
703
+ }), { processShared: processSharedEnrollmentLock() });
704
+ }
705
+ export async function getAcceptedEnrollmentMarker(origin) {
706
+ const backend = await getServerCredentialBackend();
707
+ return withEnrollmentOriginLock(origin, () => withCredentialStoreLock(() => markerForOriginUnlocked(backend, origin)), { processShared: processSharedEnrollmentLock() });
708
+ }
709
+ /** Remove the gate only after the pointer and active account have been verified together. */
710
+ export async function finalizeAcceptedEnrollment(origin, trustIdentity, generationId) {
711
+ const backend = await getServerCredentialBackend();
712
+ await withEnrollmentOriginLock(origin, () => withCredentialStoreLock(async () => {
713
+ if (!backend.entries || !backend.replaceAccounts) {
714
+ throw new Error('Borg credential store does not support atomic enrollment finalization');
715
+ }
716
+ const accounts = await backend.entries();
717
+ const markerValue = accounts[serverAcceptedEnrollmentAccount(origin)];
718
+ if (!markerValue)
719
+ return;
720
+ const marker = decodeAcceptedEnrollmentMarker(markerValue);
721
+ if (marker.trustIdentity !== trustIdentity || marker.generationId !== generationId) {
722
+ throw new Error('Borg enrollment recovery marker does not match committed trust');
723
+ }
724
+ const active = accounts[serverCredentialAccount(origin, trustIdentity)];
725
+ if (!active)
726
+ throw new Error('Borg enrollment recovery marker has no matching active credential');
727
+ if (createHash('sha256').update(active).digest('hex') !== marker.activeDigest) {
728
+ throw new Error('Borg enrollment recovery marker active credential does not match the committed transaction');
729
+ }
730
+ try {
731
+ decodeActiveServerCredentialRecord(active, origin, trustIdentity);
732
+ }
733
+ catch {
734
+ throw new Error('Borg enrollment recovery marker active credential is invalid');
735
+ }
736
+ const next = { ...accounts };
737
+ delete next[serverAcceptedEnrollmentAccount(origin)];
738
+ delete next[marker.rollbackAccount];
739
+ await backend.replaceAccounts(next);
740
+ }), { processShared: processSharedEnrollmentLock() });
741
+ }
742
+ /** Restore the complete pre-commit account snapshot after pointer restoration. */
743
+ export async function restoreAcceptedEnrollmentAccounts(marker) {
744
+ const backend = await getServerCredentialBackend();
745
+ await withEnrollmentOriginLock(marker.origin, () => withCredentialStoreLock(async () => {
746
+ if (!backend.entries || !backend.replaceAccounts) {
747
+ throw new Error('Borg credential store does not support atomic enrollment rollback');
748
+ }
749
+ const accounts = await backend.entries();
750
+ const current = accounts[serverAcceptedEnrollmentAccount(marker.origin)];
751
+ if (!current || JSON.stringify(decodeAcceptedEnrollmentMarker(current)) !== JSON.stringify(marker)) {
752
+ throw new Error('Borg enrollment recovery marker changed during rollback');
753
+ }
754
+ const rollbackValue = accounts[marker.rollbackAccount];
755
+ if (!rollbackValue)
756
+ throw new Error('Borg enrollment rollback snapshot is missing');
757
+ const rollback = decodeRollbackRecord(rollbackValue, marker).snapshot;
758
+ const next = { ...accounts };
759
+ for (const [account, value] of Object.entries(accounts)) {
760
+ try {
761
+ const parsed = JSON.parse(value);
762
+ if (parsed.version === SERVER_CREDENTIAL_RECORD_VERSION && parsed.origin === marker.origin) {
763
+ delete next[account];
764
+ }
765
+ }
766
+ catch {
767
+ // Preserve unrelated malformed entries for the backend's fail-closed reader.
768
+ }
769
+ }
770
+ Object.assign(next, rollback.activeAccounts);
771
+ next[rollback.pendingAccount] = rollback.pendingValue;
772
+ delete next[serverAcceptedEnrollmentAccount(marker.origin)];
773
+ delete next[marker.rollbackAccount];
774
+ await backend.replaceAccounts(next);
775
+ }), { processShared: processSharedEnrollmentLock() });
776
+ }
777
+ export async function findEnrollmentRecoveryTransaction(selectedOrigin) {
778
+ if (selectedOrigin !== undefined) {
779
+ let parsed;
780
+ try {
781
+ parsed = new URL(selectedOrigin);
782
+ }
783
+ catch {
784
+ throw new Error('invalid Borg server credential origin');
785
+ }
786
+ if (parsed.origin !== selectedOrigin || parsed.protocol !== 'https:') {
787
+ throw new Error('Borg server credentials require a canonical HTTPS origin');
788
+ }
789
+ }
790
+ const backend = await getServerCredentialBackend();
791
+ if (!backend.entries)
792
+ return null;
793
+ return withCredentialStoreLock(async () => {
794
+ const accepted = [];
795
+ const pending = [];
796
+ for (const [account, value] of Object.entries(await backend.entries())) {
797
+ try {
798
+ const raw = JSON.parse(value);
799
+ if (raw.version === SERVER_ACCEPTED_ENROLLMENT_MARKER_VERSION && raw.state === 'accepted') {
800
+ const marker = decodeAcceptedEnrollmentMarker(value);
801
+ if (account !== serverAcceptedEnrollmentAccount(marker.origin)) {
802
+ throw new InvitationArtifactRecoveryError(MISKEYED_RECOVERY_ERROR);
466
803
  }
804
+ if (selectedOrigin === undefined || marker.origin === selectedOrigin)
805
+ accepted.push(marker);
467
806
  }
468
- catch {
469
- // Preserve unrelated malformed records; the backend's normal validation remains fail-closed.
807
+ else if ((raw.version === SERVER_PENDING_ENROLLMENT_RECORD_VERSION ||
808
+ raw.version === LEGACY_SERVER_PENDING_ENROLLMENT_RECORD_VERSION) &&
809
+ raw.state === 'pending' &&
810
+ typeof raw.origin === 'string' && typeof raw.trustIdentity === 'string') {
811
+ const record = decodePendingServerEnrollment(value, raw.origin, raw.trustIdentity);
812
+ if (account !== serverPendingEnrollmentAccount(raw.origin, raw.trustIdentity)) {
813
+ throw new InvitationArtifactRecoveryError(MISKEYED_RECOVERY_ERROR);
814
+ }
815
+ if (selectedOrigin === undefined || record.origin === selectedOrigin)
816
+ pending.push(record);
470
817
  }
471
818
  }
472
- next[target] = encodeServerCredentialRecord(activeRecord, serverCapabilities);
473
- delete next[pendingAccount];
474
- await backend.replaceAccounts(next);
819
+ catch (error) {
820
+ if (error instanceof InvitationArtifactRecoveryError)
821
+ throw error;
822
+ if (account.startsWith('borg-server-enrollment-accepted:') ||
823
+ account.startsWith('borg-server-enrollment-pending:') ||
824
+ account.startsWith('borg-server-enrollment-rollback:'))
825
+ throw new InvitationArtifactRecoveryError();
826
+ // Unrelated corrupt entries are preserved for their owning reader.
827
+ }
475
828
  }
476
- else {
477
- await writeServerCredentialRecord(backend, activeRecord, input.allowReplacement === true);
478
- await backend.delete(pendingAccount);
829
+ if (accepted.length + pending.length > 1) {
830
+ throw new Error('multiple Borg enrollment transactions require an explicit server endpoint');
479
831
  }
832
+ if (accepted[0])
833
+ return { kind: 'accepted', marker: accepted[0] };
834
+ if (pending[0])
835
+ return { kind: 'pending', pending: pending[0] };
836
+ return null;
480
837
  });
481
838
  }
482
839
  /** Delete only the exact definitively rejected pending attempt. */
@@ -484,7 +841,7 @@ export async function clearPendingServerEnrollment(origin, trustIdentity, retryK
484
841
  validateUuid(retryKey, 'enrollment retry key');
485
842
  const backend = await getServerCredentialBackend();
486
843
  const account = serverPendingEnrollmentAccount(origin, trustIdentity);
487
- await withCredentialStoreLock(async () => {
844
+ await withEnrollmentOriginLock(origin, () => withCredentialStoreLock(async () => {
488
845
  const stored = await backend.get(account);
489
846
  if (!stored)
490
847
  return;
@@ -497,7 +854,7 @@ export async function clearPendingServerEnrollment(origin, trustIdentity, retryK
497
854
  return;
498
855
  }
499
856
  await backend.delete(account);
500
- });
857
+ }), { processShared: processSharedEnrollmentLock() });
501
858
  }
502
859
  /** Persist one repository-scoped cube-create idempotency key in the 0600 credential store. */
503
860
  export async function getOrCreatePendingServerCubeCreation(input) {
@@ -598,28 +955,53 @@ export async function clearPendingServerCubeCreation(record) {
598
955
  export async function clearServerCredential(origin, trustIdentity) {
599
956
  const backend = await getServerCredentialBackend();
600
957
  const pendingAccount = serverPendingEnrollmentAccount(origin, trustIdentity);
601
- await withCredentialStoreLock(async () => {
958
+ await withEnrollmentOriginLock(origin, () => withCredentialStoreLock(async () => {
602
959
  await backend.delete(serverCredentialAccount(origin, trustIdentity));
603
960
  await backend.delete(pendingAccount);
604
- });
961
+ }), { processShared: processSharedEnrollmentLock() });
605
962
  }
606
- /** Clear only the failed enrollment transaction for one origin/identity. */
607
- export async function clearEnrollmentTransaction(origin, trustIdentity) {
608
- validateServerCredentialBinding(origin, trustIdentity);
963
+ /** Clear only the exact failed enrollment transaction that the operator reviewed. */
964
+ export async function clearEnrollmentTransaction(expected) {
965
+ validateServerCredentialBinding(expected.origin, expected.trustIdentity);
609
966
  const backend = await getServerCredentialBackend();
610
- const pendingAccount = serverPendingEnrollmentAccount(origin, trustIdentity);
611
- const credentialAccount = serverCredentialAccount(origin, trustIdentity);
612
- await withCredentialStoreLock(async () => {
967
+ const pendingAccount = serverPendingEnrollmentAccount(expected.origin, expected.trustIdentity);
968
+ return withEnrollmentOriginLock(expected.origin, () => withCredentialStoreLock(async () => {
969
+ if (await markerForOriginUnlocked(backend, expected.origin)) {
970
+ throw new InvitationArtifactRecoveryError();
971
+ }
613
972
  if (backend.entries && backend.replaceAccounts) {
614
973
  const accounts = await backend.entries();
974
+ const stored = accounts[pendingAccount];
975
+ if (!stored)
976
+ return false;
977
+ let current;
978
+ try {
979
+ current = decodePendingServerEnrollment(stored, expected.origin, expected.trustIdentity);
980
+ }
981
+ catch {
982
+ return false;
983
+ }
984
+ if (JSON.stringify(current) !== JSON.stringify(expected))
985
+ return false;
615
986
  const next = { ...accounts };
616
987
  delete next[pendingAccount];
617
- delete next[credentialAccount];
618
988
  await backend.replaceAccounts(next);
619
- return;
989
+ return (await backend.get(pendingAccount)) === null;
990
+ }
991
+ const stored = await backend.get(pendingAccount);
992
+ if (!stored)
993
+ return false;
994
+ let current;
995
+ try {
996
+ current = decodePendingServerEnrollment(stored, expected.origin, expected.trustIdentity);
620
997
  }
998
+ catch {
999
+ return false;
1000
+ }
1001
+ if (JSON.stringify(current) !== JSON.stringify(expected))
1002
+ return false;
621
1003
  await backend.delete(pendingAccount);
622
- await backend.delete(credentialAccount);
623
- });
1004
+ return (await backend.get(pendingAccount)) === null;
1005
+ }), { processShared: processSharedEnrollmentLock() });
624
1006
  }
625
1007
  //# sourceMappingURL=config.js.map