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
package/src/config.ts CHANGED
@@ -14,9 +14,23 @@ import {
14
14
  type TokenBackend,
15
15
  } from './token-store.js';
16
16
  import { BORG_USER_ROOT, SERVER_CREDENTIALS_FILE } from './credential-paths.js';
17
+ import { withEnrollmentOriginLock } from './enrollment-lock.js';
18
+ import {
19
+ InvitationArtifactRecoveryError,
20
+ MISKEYED_RECOVERY_ERROR,
21
+ } from './invitation-artifact.js';
22
+ import type {
23
+ AcceptedEnrollmentMarker,
24
+ EnrollmentArtifactBinding,
25
+ EnrollmentReplacementCapability,
26
+ EnrollmentRollbackRecord,
27
+ EnrollmentTrustPointer,
28
+ } from './enrollment-types.js';
17
29
 
18
30
  const SERVER_CREDENTIAL_RECORD_VERSION = 2 as const;
19
- const SERVER_PENDING_ENROLLMENT_RECORD_VERSION = 1 as const;
31
+ const SERVER_PENDING_ENROLLMENT_RECORD_VERSION = 3 as const;
32
+ const LEGACY_SERVER_PENDING_ENROLLMENT_RECORD_VERSION = 1 as const;
33
+ const SERVER_ACCEPTED_ENROLLMENT_MARKER_VERSION = 1 as const;
20
34
  const SERVER_CUBE_RETRY_RECORD_VERSION = 2 as const;
21
35
  // The 0600 credential store (Queen rescope: replaces the OS keychain). A single
22
36
  // file holds every parent credential/enrollment record; a single flock
@@ -48,7 +62,8 @@ export interface PendingServerEnrollmentRecord {
48
62
  retryKey: string;
49
63
  credential: string;
50
64
  clientName?: string;
51
- replacementCapability?: string;
65
+ artifactBinding?: EnrollmentArtifactBinding;
66
+ replacementCapability?: EnrollmentReplacementCapability;
52
67
  }
53
68
 
54
69
  export interface PendingServerCubeCreationRecord {
@@ -133,6 +148,57 @@ function serverPendingEnrollmentAccount(origin: string, trustIdentity: string):
133
148
  return `borg-server-enrollment-pending:${binding}`;
134
149
  }
135
150
 
151
+ function serverAcceptedEnrollmentAccount(origin: string): string {
152
+ const binding = createHash('sha256').update(origin).digest('hex');
153
+ return `borg-server-enrollment-accepted:${binding}`;
154
+ }
155
+
156
+ function serverEnrollmentRollbackAccount(origin: string, retryKey: string): string {
157
+ const binding = createHash('sha256').update(origin).update('\0').update(retryKey).digest('hex');
158
+ return `borg-server-enrollment-rollback:${binding}`;
159
+ }
160
+
161
+ function digestAccountSnapshot(accounts: Record<string, string>, origin: string): string {
162
+ const selected = Object.entries(accounts)
163
+ .filter(([, value]) => {
164
+ try {
165
+ const parsed = JSON.parse(value) as { version?: unknown; origin?: unknown };
166
+ return parsed.version === SERVER_CREDENTIAL_RECORD_VERSION && parsed.origin === origin;
167
+ } catch {
168
+ return false;
169
+ }
170
+ })
171
+ .sort(([left], [right]) => left.localeCompare(right));
172
+ return createHash('sha256').update(JSON.stringify(selected)).digest('hex');
173
+ }
174
+
175
+ function validateArtifactBinding(binding: EnrollmentArtifactBinding | undefined): void {
176
+ if (binding === undefined) return;
177
+ validateServerCredentialBinding(binding.endpoint, binding.trustIdentity);
178
+ if (
179
+ !Number.isSafeInteger(binding.artifactFormatVersion) || binding.artifactFormatVersion < 1 ||
180
+ !/^[a-f0-9]{64}$/.test(binding.artifactDigest) ||
181
+ !/^[a-f0-9]{64}$/.test(binding.caSpkiSha256) ||
182
+ !/^[a-f0-9]{64}$/.test(binding.stagedGenerationId) ||
183
+ binding.endpoint.length < 1 ||
184
+ binding.expectedAuthority !== 'owner' && binding.expectedAuthority !== 'client'
185
+ ) throw new Error('invalid Borg enrollment artifact binding');
186
+ }
187
+
188
+ function validateReplacementCapability(
189
+ capability: EnrollmentReplacementCapability | undefined,
190
+ ): void {
191
+ if (capability === undefined) return;
192
+ validateUuid(capability.token, 'replacement capability');
193
+ validateServerCredentialBinding(capability.endpoint, capability.trustIdentity);
194
+ if (
195
+ !/^[a-f0-9]{64}$/.test(capability.priorAccountsDigest) ||
196
+ !/^[a-f0-9]{64}$/.test(capability.caSpkiSha256) ||
197
+ !/^[a-f0-9]{64}$/.test(capability.artifactDigest)
198
+ ) throw new Error('invalid Borg enrollment replacement capability');
199
+ validateUuid(capability.retryKey, 'enrollment retry key');
200
+ }
201
+
136
202
  function validateUuid(value: string, label: string): void {
137
203
  if (!UUID_RE.test(value)) throw new Error(`invalid Borg server ${label}`);
138
204
  }
@@ -320,6 +386,122 @@ function encodeServerCredentialRecord(record: ServerCredentialRecord, serverCapa
320
386
  });
321
387
  }
322
388
 
389
+ function decodeActiveServerCredentialRecord(
390
+ stored: string,
391
+ origin: string,
392
+ trustIdentity: string,
393
+ ): ActiveServerCredentialRecord {
394
+ const record = JSON.parse(stored) as Partial<ActiveServerCredentialRecord> & {
395
+ version?: unknown;
396
+ };
397
+ if (
398
+ record.version !== SERVER_CREDENTIAL_RECORD_VERSION ||
399
+ record.origin !== origin ||
400
+ record.trustIdentity !== trustIdentity ||
401
+ typeof record.credential !== 'string' ||
402
+ !/^[A-Za-z0-9_-]{43}$/.test(record.credential) ||
403
+ (record.clientId !== null &&
404
+ (typeof record.clientId !== 'string' || !UUID_RE.test(record.clientId))) ||
405
+ !Array.isArray(record.serverCapabilities)
406
+ ) throw new Error('invalid Borg server credential record');
407
+ const serverCapabilities = validateServerCapabilities(record.serverCapabilities);
408
+ return {
409
+ origin,
410
+ trustIdentity,
411
+ credential: record.credential,
412
+ clientId: record.clientId,
413
+ serverCapabilities,
414
+ };
415
+ }
416
+
417
+ function decodeAcceptedEnrollmentMarker(value: string): AcceptedEnrollmentMarker {
418
+ const marker = JSON.parse(value) as Partial<AcceptedEnrollmentMarker>;
419
+ if (
420
+ marker.version !== SERVER_ACCEPTED_ENROLLMENT_MARKER_VERSION ||
421
+ marker.state !== 'accepted' ||
422
+ typeof marker.origin !== 'string' ||
423
+ typeof marker.trustIdentity !== 'string' ||
424
+ typeof marker.generationId !== 'string' || !/^[a-f0-9]{64}$/.test(marker.generationId) ||
425
+ (marker.previousPointer !== null && typeof marker.previousPointer !== 'object') ||
426
+ typeof marker.activeDigest !== 'string' || !/^[a-f0-9]{64}$/.test(marker.activeDigest) ||
427
+ typeof marker.rollbackAccount !== 'string' ||
428
+ typeof marker.rollbackDigest !== 'string' || !/^[a-f0-9]{64}$/.test(marker.rollbackDigest)
429
+ ) throw new Error('invalid Borg enrollment recovery marker');
430
+ validateServerCredentialBinding(marker.origin, marker.trustIdentity);
431
+ if (marker.previousPointer !== null) {
432
+ const pointer = marker.previousPointer as Partial<EnrollmentTrustPointer>;
433
+ if (
434
+ pointer.version !== 1 || pointer.origin !== marker.origin ||
435
+ typeof pointer.generationId !== 'string' || !/^[a-f0-9]{64}$/.test(pointer.generationId) ||
436
+ typeof pointer.trustIdentity !== 'string'
437
+ ) throw new Error('invalid Borg enrollment recovery marker');
438
+ validateServerCredentialBinding(pointer.origin, pointer.trustIdentity);
439
+ }
440
+ return marker as AcceptedEnrollmentMarker;
441
+ }
442
+
443
+ function decodeRollbackRecord(
444
+ value: string,
445
+ marker: AcceptedEnrollmentMarker,
446
+ ): EnrollmentRollbackRecord {
447
+ if (createHash('sha256').update(value).digest('hex') !== marker.rollbackDigest) {
448
+ throw new Error('Borg enrollment rollback snapshot digest is invalid');
449
+ }
450
+ const record = JSON.parse(value) as Partial<EnrollmentRollbackRecord>;
451
+ if (
452
+ record.version !== 1 || record.state !== 'rollback-snapshot' || record.origin !== marker.origin ||
453
+ typeof record.snapshot !== 'object' || record.snapshot === null ||
454
+ typeof record.snapshot.pendingAccount !== 'string' ||
455
+ typeof record.snapshot.pendingValue !== 'string' ||
456
+ typeof record.snapshot.activeAccounts !== 'object' || record.snapshot.activeAccounts === null
457
+ ) throw new Error('invalid Borg enrollment rollback snapshot');
458
+ for (const [account, stored] of Object.entries(record.snapshot.activeAccounts)) {
459
+ if (typeof account !== 'string' || typeof stored !== 'string') {
460
+ throw new Error('invalid Borg enrollment rollback snapshot');
461
+ }
462
+ let active: Partial<ActiveServerCredentialRecord> & { version?: unknown };
463
+ try { active = JSON.parse(stored) as typeof active; } catch {
464
+ throw new Error('invalid Borg enrollment rollback snapshot');
465
+ }
466
+ if (
467
+ active.version !== SERVER_CREDENTIAL_RECORD_VERSION || active.origin !== marker.origin ||
468
+ typeof active.trustIdentity !== 'string' ||
469
+ account !== serverCredentialAccount(marker.origin, active.trustIdentity)
470
+ ) throw new Error('invalid Borg enrollment rollback snapshot');
471
+ }
472
+ let pendingRaw: { origin?: unknown; trustIdentity?: unknown };
473
+ try { pendingRaw = JSON.parse(record.snapshot.pendingValue) as typeof pendingRaw; } catch {
474
+ throw new Error('invalid Borg enrollment rollback snapshot');
475
+ }
476
+ if (typeof pendingRaw.origin !== 'string' || typeof pendingRaw.trustIdentity !== 'string') {
477
+ throw new Error('invalid Borg enrollment rollback snapshot');
478
+ }
479
+ const pending = decodePendingServerEnrollment(
480
+ record.snapshot.pendingValue,
481
+ pendingRaw.origin,
482
+ pendingRaw.trustIdentity,
483
+ );
484
+ if (
485
+ pending.origin !== marker.origin || pending.trustIdentity !== marker.trustIdentity ||
486
+ record.snapshot.pendingAccount !== serverPendingEnrollmentAccount(marker.origin, marker.trustIdentity) ||
487
+ marker.rollbackAccount !== serverEnrollmentRollbackAccount(marker.origin, pending.retryKey)
488
+ ) throw new Error('invalid Borg enrollment rollback snapshot');
489
+ return record as EnrollmentRollbackRecord;
490
+ }
491
+
492
+ async function markerForOriginUnlocked(
493
+ backend: TokenBackend,
494
+ origin: string,
495
+ ): Promise<AcceptedEnrollmentMarker | null> {
496
+ const stored = await backend.get(serverAcceptedEnrollmentAccount(origin));
497
+ if (!stored) return null;
498
+ return decodeAcceptedEnrollmentMarker(stored);
499
+ }
500
+
501
+ function processSharedEnrollmentLock(): boolean {
502
+ return !testBackendInjected;
503
+ }
504
+
323
505
  /**
324
506
  * Persist one self-hosted server credential in the dedicated 0600 credential store.
325
507
  *
@@ -332,10 +514,12 @@ function encodeServerCredentialRecord(record: ServerCredentialRecord, serverCapa
332
514
  */
333
515
  export async function storeServerCredential(
334
516
  record: ServerCredentialRecord,
335
- options: { allowReplacement?: boolean } = {},
336
517
  ): Promise<void> {
337
518
  const backend = await getServerCredentialBackend();
338
- await withCredentialStoreLock(() => writeServerCredentialRecord(backend, record, options.allowReplacement === true));
519
+ await withEnrollmentOriginLock(record.origin, () =>
520
+ withCredentialStoreLock(() => writeServerCredentialRecord(backend, record, false)), {
521
+ processShared: processSharedEnrollmentLock(),
522
+ });
339
523
  }
340
524
 
341
525
  /** Read an authority-bound active client record, failing closed on corruption. */
@@ -344,40 +528,16 @@ export async function getServerCredentialRecord(
344
528
  trustIdentity: string,
345
529
  ): Promise<ActiveServerCredentialRecord | null> {
346
530
  const backend = await getServerCredentialBackend();
347
- const stored = await backend.get(serverCredentialAccount(origin, trustIdentity));
348
- if (!stored) return null;
349
- try {
350
- const record = JSON.parse(stored) as Partial<ActiveServerCredentialRecord> & {
351
- version?: unknown;
352
- };
353
- if (
354
- record.version !== SERVER_CREDENTIAL_RECORD_VERSION ||
355
- record.origin !== origin ||
356
- record.trustIdentity !== trustIdentity ||
357
- typeof record.credential !== 'string' ||
358
- !/^[A-Za-z0-9_-]{43}$/.test(record.credential) ||
359
- (record.clientId !== null &&
360
- (typeof record.clientId !== 'string' || !UUID_RE.test(record.clientId))) ||
361
- !Array.isArray(record.serverCapabilities)
362
- ) {
363
- return null;
364
- }
365
- let serverCapabilities: ServerCapability[];
531
+ return withEnrollmentOriginLock(origin, () => withCredentialStoreLock(async () => {
532
+ if (await markerForOriginUnlocked(backend, origin)) throw new InvitationArtifactRecoveryError();
533
+ const stored = await backend.get(serverCredentialAccount(origin, trustIdentity));
534
+ if (!stored) return null;
366
535
  try {
367
- serverCapabilities = validateServerCapabilities(record.serverCapabilities);
536
+ return decodeActiveServerCredentialRecord(stored, origin, trustIdentity);
368
537
  } catch {
369
538
  return null;
370
539
  }
371
- return {
372
- origin,
373
- trustIdentity,
374
- credential: record.credential,
375
- clientId: record.clientId,
376
- serverCapabilities,
377
- };
378
- } catch {
379
- return null;
380
- }
540
+ }), { processShared: processSharedEnrollmentLock() });
381
541
  }
382
542
 
383
543
  /** Read only the bearer for existing call sites that do not need capability metadata. */
@@ -391,15 +551,18 @@ export async function getServerCredential(
391
551
  export async function hasServerCredentialForOrigin(origin: string): Promise<boolean> {
392
552
  const backend = await getServerCredentialBackend();
393
553
  if (!backend.entries) return false;
394
- const accounts = await backend.entries();
395
- return Object.values(accounts).some((value) => {
396
- try {
397
- const record = JSON.parse(value) as { version?: unknown; origin?: unknown };
398
- return record.version === SERVER_CREDENTIAL_RECORD_VERSION && record.origin === origin;
399
- } catch {
400
- return false;
401
- }
402
- });
554
+ return withEnrollmentOriginLock(origin, () => withCredentialStoreLock(async () => {
555
+ if (await markerForOriginUnlocked(backend, origin)) throw new InvitationArtifactRecoveryError();
556
+ const accounts = await backend.entries!();
557
+ return Object.values(accounts).some((value) => {
558
+ try {
559
+ const record = JSON.parse(value) as { version?: unknown; origin?: unknown };
560
+ return record.version === SERVER_CREDENTIAL_RECORD_VERSION && record.origin === origin;
561
+ } catch {
562
+ return false;
563
+ }
564
+ });
565
+ }), { processShared: processSharedEnrollmentLock() });
403
566
  }
404
567
 
405
568
  function decodePendingServerEnrollment(
@@ -412,7 +575,8 @@ function decodePendingServerEnrollment(
412
575
  state?: unknown;
413
576
  };
414
577
  if (
415
- record.version !== SERVER_PENDING_ENROLLMENT_RECORD_VERSION ||
578
+ record.version !== SERVER_PENDING_ENROLLMENT_RECORD_VERSION &&
579
+ record.version !== LEGACY_SERVER_PENDING_ENROLLMENT_RECORD_VERSION ||
416
580
  record.state !== 'pending' ||
417
581
  record.origin !== origin ||
418
582
  record.trustIdentity !== trustIdentity ||
@@ -420,12 +584,24 @@ function decodePendingServerEnrollment(
420
584
  typeof record.retryKey !== 'string' || !UUID_RE.test(record.retryKey) ||
421
585
  typeof record.credential !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(record.credential) ||
422
586
  (record.clientName !== undefined && typeof record.clientName !== 'string')
423
- || (record.replacementCapability !== undefined && !UUID_RE.test(record.replacementCapability))
424
587
  ) {
425
588
  throw new Error('invalid');
426
589
  }
427
590
  validateInvitation(record.invitation);
428
591
  validateClientName(record.clientName);
592
+ validateArtifactBinding(record.artifactBinding);
593
+ validateReplacementCapability(record.replacementCapability);
594
+ if (record.artifactBinding && (
595
+ record.artifactBinding.endpoint !== origin ||
596
+ record.artifactBinding.trustIdentity !== trustIdentity ||
597
+ record.replacementCapability && (
598
+ record.replacementCapability.endpoint !== origin ||
599
+ record.replacementCapability.trustIdentity !== trustIdentity ||
600
+ record.replacementCapability.retryKey !== record.retryKey ||
601
+ record.replacementCapability.artifactDigest !== record.artifactBinding.artifactDigest ||
602
+ record.replacementCapability.caSpkiSha256 !== record.artifactBinding.caSpkiSha256
603
+ )
604
+ )) throw new Error('invalid');
429
605
  return {
430
606
  origin,
431
607
  trustIdentity,
@@ -433,6 +609,7 @@ function decodePendingServerEnrollment(
433
609
  retryKey: record.retryKey,
434
610
  credential: record.credential,
435
611
  ...(record.clientName === undefined ? {} : { clientName: record.clientName }),
612
+ ...(record.artifactBinding === undefined ? {} : { artifactBinding: record.artifactBinding }),
436
613
  ...(record.replacementCapability === undefined ? {} : { replacementCapability: record.replacementCapability }),
437
614
  };
438
615
  }
@@ -445,7 +622,8 @@ export async function getPendingServerEnrollment(
445
622
  validateServerCredentialBinding(origin, trustIdentity);
446
623
  const backend = await getServerCredentialBackend();
447
624
  const account = serverPendingEnrollmentAccount(origin, trustIdentity);
448
- return withCredentialStoreLock(async () => {
625
+ return withEnrollmentOriginLock(origin, () => withCredentialStoreLock(async () => {
626
+ if (await markerForOriginUnlocked(backend, origin)) throw new InvitationArtifactRecoveryError();
449
627
  const stored = await backend.get(account);
450
628
  if (!stored) return null;
451
629
  try {
@@ -453,7 +631,7 @@ export async function getPendingServerEnrollment(
453
631
  } catch {
454
632
  throw new Error('pending Borg server enrollment is corrupt');
455
633
  }
456
- });
634
+ }), { processShared: processSharedEnrollmentLock() });
457
635
  }
458
636
 
459
637
  /** Find the sole pending enrollment so artifact-only retries need no invitation. */
@@ -462,16 +640,35 @@ export async function findPendingServerEnrollment(): Promise<PendingServerEnroll
462
640
  if (!backend.entries) return null;
463
641
  return withCredentialStoreLock(async () => {
464
642
  const matches: PendingServerEnrollmentRecord[] = [];
465
- for (const value of Object.values(await backend.entries!())) {
643
+ for (const [account, value] of Object.entries(await backend.entries!())) {
466
644
  try {
467
645
  const raw = JSON.parse(value) as Partial<PendingServerEnrollmentRecord> & {
468
646
  version?: unknown;
469
647
  state?: unknown;
470
648
  };
471
- if (raw.version !== SERVER_PENDING_ENROLLMENT_RECORD_VERSION || raw.state !== 'pending') continue;
649
+ if (raw.version === SERVER_ACCEPTED_ENROLLMENT_MARKER_VERSION && raw.state === 'accepted') {
650
+ const marker = decodeAcceptedEnrollmentMarker(value);
651
+ if (account !== serverAcceptedEnrollmentAccount(marker.origin)) {
652
+ throw new InvitationArtifactRecoveryError(MISKEYED_RECOVERY_ERROR);
653
+ }
654
+ throw new InvitationArtifactRecoveryError();
655
+ }
656
+ if (
657
+ raw.version !== SERVER_PENDING_ENROLLMENT_RECORD_VERSION &&
658
+ raw.version !== LEGACY_SERVER_PENDING_ENROLLMENT_RECORD_VERSION ||
659
+ raw.state !== 'pending'
660
+ ) continue;
472
661
  if (typeof raw.origin !== 'string' || typeof raw.trustIdentity !== 'string') continue;
662
+ if (account !== serverPendingEnrollmentAccount(raw.origin, raw.trustIdentity)) {
663
+ throw new InvitationArtifactRecoveryError(MISKEYED_RECOVERY_ERROR);
664
+ }
473
665
  matches.push(decodePendingServerEnrollment(value, raw.origin, raw.trustIdentity));
474
- } catch {
666
+ } catch (error) {
667
+ if (error instanceof InvitationArtifactRecoveryError) throw error;
668
+ if (
669
+ account.startsWith('borg-server-enrollment-pending:') ||
670
+ account.startsWith('borg-server-enrollment-accepted:')
671
+ ) throw new InvitationArtifactRecoveryError();
475
672
  // Ignore unrelated or corrupt records; the keyed resume path remains fail-closed.
476
673
  }
477
674
  }
@@ -491,16 +688,52 @@ export async function getOrCreatePendingServerEnrollment(
491
688
  trustIdentity: string;
492
689
  invitation: string;
493
690
  clientName?: string;
494
- replacementCapability?: string;
691
+ artifactBinding?: EnrollmentArtifactBinding;
692
+ },
693
+ ): Promise<PendingServerEnrollmentRecord> {
694
+ return getOrCreatePendingServerEnrollmentInternal(input, false);
695
+ }
696
+
697
+ /**
698
+ * Mint replacement consent only for the fresh interactive confirmation path.
699
+ * Resume can load and consume the resulting capability, but has no mint API.
700
+ */
701
+ export async function createPendingServerEnrollmentWithReplacementConsent(
702
+ input: {
703
+ origin: string;
704
+ trustIdentity: string;
705
+ invitation: string;
706
+ clientName?: string;
707
+ artifactBinding: EnrollmentArtifactBinding;
495
708
  },
709
+ ): Promise<PendingServerEnrollmentRecord> {
710
+ return getOrCreatePendingServerEnrollmentInternal(input, true);
711
+ }
712
+
713
+ async function getOrCreatePendingServerEnrollmentInternal(
714
+ input: {
715
+ origin: string;
716
+ trustIdentity: string;
717
+ invitation: string;
718
+ clientName?: string;
719
+ artifactBinding?: EnrollmentArtifactBinding;
720
+ },
721
+ mintReplacementConsent: boolean,
496
722
  ): Promise<PendingServerEnrollmentRecord> {
497
723
  validateServerCredentialBinding(input.origin, input.trustIdentity);
498
724
  validateInvitation(input.invitation);
499
725
  validateClientName(input.clientName);
500
- if (input.replacementCapability !== undefined) validateUuid(input.replacementCapability, 'replacement capability');
726
+ validateArtifactBinding(input.artifactBinding);
727
+ if (
728
+ input.artifactBinding !== undefined &&
729
+ createHash('sha256').update(input.invitation).digest('hex') !== input.artifactBinding.artifactDigest
730
+ ) throw new Error('enrollment invitation does not match its verified artifact binding');
731
+ if (mintReplacementConsent && input.artifactBinding === undefined) {
732
+ throw new Error('replacement consent requires a verified invitation artifact binding');
733
+ }
501
734
  const backend = await getServerCredentialBackend();
502
735
  const account = serverPendingEnrollmentAccount(input.origin, input.trustIdentity);
503
- return withCredentialStoreLock(async () => {
736
+ return withEnrollmentOriginLock(input.origin, () => withCredentialStoreLock(async () => {
504
737
  const stored = await backend.get(account);
505
738
  if (stored) {
506
739
  try {
@@ -512,7 +745,8 @@ export async function getOrCreatePendingServerEnrollment(
512
745
  if (
513
746
  record.invitation !== input.invitation ||
514
747
  record.clientName !== input.clientName
515
- || record.replacementCapability !== input.replacementCapability
748
+ || JSON.stringify(record.artifactBinding) !== JSON.stringify(input.artifactBinding)
749
+ || mintReplacementConsent && record.replacementCapability === undefined
516
750
  ) {
517
751
  throw new Error('mismatch');
518
752
  }
@@ -522,14 +756,26 @@ export async function getOrCreatePendingServerEnrollment(
522
756
  }
523
757
  }
524
758
 
759
+ const retryKey = randomUUID();
760
+ const accounts = backend.entries ? await backend.entries() : {};
761
+ const replacementCapability = mintReplacementConsent ? {
762
+ token: randomUUID(),
763
+ priorAccountsDigest: digestAccountSnapshot(accounts, input.origin),
764
+ endpoint: input.origin,
765
+ caSpkiSha256: input.artifactBinding!.caSpkiSha256,
766
+ trustIdentity: input.trustIdentity,
767
+ retryKey,
768
+ artifactDigest: input.artifactBinding!.artifactDigest,
769
+ } satisfies EnrollmentReplacementCapability : undefined;
525
770
  const record: PendingServerEnrollmentRecord = {
526
771
  origin: input.origin,
527
772
  trustIdentity: input.trustIdentity,
528
773
  invitation: input.invitation,
529
- retryKey: randomUUID(),
774
+ retryKey,
530
775
  credential: randomBytes(32).toString('base64url'),
531
776
  ...(input.clientName === undefined ? {} : { clientName: input.clientName }),
532
- ...(input.replacementCapability === undefined ? {} : { replacementCapability: input.replacementCapability }),
777
+ ...(input.artifactBinding === undefined ? {} : { artifactBinding: input.artifactBinding }),
778
+ ...(replacementCapability === undefined ? {} : { replacementCapability }),
533
779
  };
534
780
  validateEnrollmentCredential(record.credential);
535
781
  await backend.set(account, JSON.stringify({
@@ -538,7 +784,7 @@ export async function getOrCreatePendingServerEnrollment(
538
784
  ...record,
539
785
  }));
540
786
  return record;
541
- });
787
+ }), { processShared: processSharedEnrollmentLock() });
542
788
  }
543
789
 
544
790
  /** Activate the exact pending tuple only after a verified server response. */
@@ -550,8 +796,9 @@ export async function activatePendingServerEnrollment(
550
796
  credential: string;
551
797
  clientId: string;
552
798
  serverCapabilities: ServerCapability[];
553
- allowReplacement?: boolean;
554
- replacementCapability?: string;
799
+ generationId?: string;
800
+ previousPointer?: EnrollmentTrustPointer | null;
801
+ replacementCapabilityToken?: string;
555
802
  },
556
803
  ): Promise<void> {
557
804
  validateServerCredentialBinding(input.origin, input.trustIdentity);
@@ -561,7 +808,10 @@ export async function activatePendingServerEnrollment(
561
808
  const serverCapabilities = validateServerCapabilities(input.serverCapabilities);
562
809
  const backend = await getServerCredentialBackend();
563
810
  const pendingAccount = serverPendingEnrollmentAccount(input.origin, input.trustIdentity);
564
- await withCredentialStoreLock(async () => {
811
+ await withEnrollmentOriginLock(input.origin, () => withCredentialStoreLock(async () => {
812
+ if (!backend.entries || !backend.replaceAccounts) {
813
+ throw new Error('Borg credential store does not support atomic enrollment activation');
814
+ }
565
815
  const stored = await backend.get(pendingAccount);
566
816
  if (!stored) throw new Error('pending Borg server enrollment is missing');
567
817
  try {
@@ -570,25 +820,42 @@ export async function activatePendingServerEnrollment(
570
820
  input.origin,
571
821
  input.trustIdentity,
572
822
  );
573
- if (pending.retryKey !== input.retryKey || pending.credential !== input.credential ||
574
- pending.replacementCapability !== input.replacementCapability) {
823
+ if (pending.retryKey !== input.retryKey || pending.credential !== input.credential) {
575
824
  throw new Error('mismatch');
576
825
  }
577
826
  } catch {
578
827
  throw new Error('pending Borg server enrollment does not match the verified response');
579
828
  }
580
- if (!input.allowReplacement) {
581
- const existing = backend.entries
582
- ? Object.values(await backend.entries()).some((value) => {
583
- try {
584
- const record = JSON.parse(value) as { version?: unknown; origin?: unknown };
585
- return record.version === SERVER_CREDENTIAL_RECORD_VERSION && record.origin === input.origin;
586
- } catch {
587
- return false;
588
- }
589
- })
590
- : await backend.get(serverCredentialAccount(input.origin, input.trustIdentity)) !== null;
591
- if (existing) throw new Error('local Borg server enrollment already exists');
829
+ const pending = decodePendingServerEnrollment(stored, input.origin, input.trustIdentity);
830
+ const accounts = await backend.entries();
831
+ const activeAccounts = Object.fromEntries(Object.entries(accounts).filter(([, value]) => {
832
+ try {
833
+ const record = JSON.parse(value) as { version?: unknown; origin?: unknown };
834
+ return record.version === SERVER_CREDENTIAL_RECORD_VERSION && record.origin === input.origin;
835
+ } catch {
836
+ return false;
837
+ }
838
+ }));
839
+ if (Object.keys(activeAccounts).length > 0) {
840
+ const capability = pending.replacementCapability;
841
+ if (!capability || capability.token !== input.replacementCapabilityToken) {
842
+ throw new Error('local Borg server enrollment already exists and replacement consent is missing');
843
+ }
844
+ validateReplacementCapability(capability);
845
+ if (
846
+ capability.priorAccountsDigest !== digestAccountSnapshot(accounts, input.origin) ||
847
+ capability.endpoint !== input.origin ||
848
+ capability.trustIdentity !== input.trustIdentity ||
849
+ capability.retryKey !== input.retryKey ||
850
+ capability.artifactDigest !== pending.artifactBinding?.artifactDigest ||
851
+ capability.caSpkiSha256 !== pending.artifactBinding?.caSpkiSha256
852
+ ) throw new Error('Borg enrollment replacement consent does not match this transaction');
853
+ } else if (input.replacementCapabilityToken !== undefined) {
854
+ throw new Error('Borg enrollment replacement consent does not match this transaction');
855
+ }
856
+ const generationId = input.generationId ?? pending.artifactBinding?.stagedGenerationId;
857
+ if (pending.artifactBinding && generationId !== pending.artifactBinding.stagedGenerationId) {
858
+ throw new Error('pending Borg server enrollment generation does not match the verified response');
592
859
  }
593
860
  const activeRecord: ServerCredentialRecord = {
594
861
  origin: input.origin,
@@ -597,13 +864,9 @@ export async function activatePendingServerEnrollment(
597
864
  clientId: input.clientId,
598
865
  serverCapabilities,
599
866
  };
600
- // Replace the active record and retire the pending tuple in one account-map
601
- // write. A failed map write therefore preserves the old active + pending state.
602
- if (backend.entries && backend.replaceAccounts) {
603
- const accounts = await backend.entries();
604
- const target = serverCredentialAccount(input.origin, input.trustIdentity);
605
- const next = { ...accounts };
606
- for (const [account, value] of Object.entries(accounts)) {
867
+ const target = serverCredentialAccount(input.origin, input.trustIdentity);
868
+ const next = { ...accounts };
869
+ for (const [account, value] of Object.entries(accounts)) {
607
870
  try {
608
871
  const parsed = JSON.parse(value) as { version?: unknown; origin?: unknown };
609
872
  if (parsed.version === SERVER_CREDENTIAL_RECORD_VERSION && parsed.origin === input.origin) {
@@ -612,14 +875,173 @@ export async function activatePendingServerEnrollment(
612
875
  } catch {
613
876
  // Preserve unrelated malformed records; the backend's normal validation remains fail-closed.
614
877
  }
878
+ }
879
+ const activeValue = encodeServerCredentialRecord(activeRecord, serverCapabilities);
880
+ next[target] = activeValue;
881
+ delete next[pendingAccount];
882
+ if (generationId !== undefined) {
883
+ const rollbackAccount = serverEnrollmentRollbackAccount(input.origin, input.retryKey);
884
+ const rollbackRecord: EnrollmentRollbackRecord = {
885
+ version: 1,
886
+ state: 'rollback-snapshot',
887
+ origin: input.origin,
888
+ snapshot: { activeAccounts, pendingAccount, pendingValue: stored },
889
+ };
890
+ const rollbackValue = JSON.stringify(rollbackRecord);
891
+ const marker: AcceptedEnrollmentMarker = {
892
+ version: SERVER_ACCEPTED_ENROLLMENT_MARKER_VERSION,
893
+ state: 'accepted',
894
+ origin: input.origin,
895
+ trustIdentity: input.trustIdentity,
896
+ generationId,
897
+ previousPointer: input.previousPointer ?? null,
898
+ activeDigest: createHash('sha256').update(activeValue).digest('hex'),
899
+ rollbackAccount,
900
+ rollbackDigest: createHash('sha256').update(rollbackValue).digest('hex'),
901
+ };
902
+ next[rollbackAccount] = rollbackValue;
903
+ next[serverAcceptedEnrollmentAccount(input.origin)] = JSON.stringify(marker);
904
+ }
905
+ await backend.replaceAccounts(next);
906
+ }), { processShared: processSharedEnrollmentLock() });
907
+ }
908
+
909
+ export async function getAcceptedEnrollmentMarker(
910
+ origin: string,
911
+ ): Promise<AcceptedEnrollmentMarker | null> {
912
+ const backend = await getServerCredentialBackend();
913
+ return withEnrollmentOriginLock(origin, () => withCredentialStoreLock(
914
+ () => markerForOriginUnlocked(backend, origin),
915
+ ), { processShared: processSharedEnrollmentLock() });
916
+ }
917
+
918
+ /** Remove the gate only after the pointer and active account have been verified together. */
919
+ export async function finalizeAcceptedEnrollment(
920
+ origin: string,
921
+ trustIdentity: string,
922
+ generationId: string,
923
+ ): Promise<void> {
924
+ const backend = await getServerCredentialBackend();
925
+ await withEnrollmentOriginLock(origin, () => withCredentialStoreLock(async () => {
926
+ if (!backend.entries || !backend.replaceAccounts) {
927
+ throw new Error('Borg credential store does not support atomic enrollment finalization');
928
+ }
929
+ const accounts = await backend.entries();
930
+ const markerValue = accounts[serverAcceptedEnrollmentAccount(origin)];
931
+ if (!markerValue) return;
932
+ const marker = decodeAcceptedEnrollmentMarker(markerValue);
933
+ if (marker.trustIdentity !== trustIdentity || marker.generationId !== generationId) {
934
+ throw new Error('Borg enrollment recovery marker does not match committed trust');
935
+ }
936
+ const active = accounts[serverCredentialAccount(origin, trustIdentity)];
937
+ if (!active) throw new Error('Borg enrollment recovery marker has no matching active credential');
938
+ if (createHash('sha256').update(active).digest('hex') !== marker.activeDigest) {
939
+ throw new Error('Borg enrollment recovery marker active credential does not match the committed transaction');
940
+ }
941
+ try {
942
+ decodeActiveServerCredentialRecord(active, origin, trustIdentity);
943
+ } catch {
944
+ throw new Error('Borg enrollment recovery marker active credential is invalid');
945
+ }
946
+ const next = { ...accounts };
947
+ delete next[serverAcceptedEnrollmentAccount(origin)];
948
+ delete next[marker.rollbackAccount];
949
+ await backend.replaceAccounts(next);
950
+ }), { processShared: processSharedEnrollmentLock() });
951
+ }
952
+
953
+ /** Restore the complete pre-commit account snapshot after pointer restoration. */
954
+ export async function restoreAcceptedEnrollmentAccounts(
955
+ marker: AcceptedEnrollmentMarker,
956
+ ): Promise<void> {
957
+ const backend = await getServerCredentialBackend();
958
+ await withEnrollmentOriginLock(marker.origin, () => withCredentialStoreLock(async () => {
959
+ if (!backend.entries || !backend.replaceAccounts) {
960
+ throw new Error('Borg credential store does not support atomic enrollment rollback');
961
+ }
962
+ const accounts = await backend.entries();
963
+ const current = accounts[serverAcceptedEnrollmentAccount(marker.origin)];
964
+ if (!current || JSON.stringify(decodeAcceptedEnrollmentMarker(current)) !== JSON.stringify(marker)) {
965
+ throw new Error('Borg enrollment recovery marker changed during rollback');
966
+ }
967
+ const rollbackValue = accounts[marker.rollbackAccount];
968
+ if (!rollbackValue) throw new Error('Borg enrollment rollback snapshot is missing');
969
+ const rollback = decodeRollbackRecord(rollbackValue, marker).snapshot;
970
+ const next = { ...accounts };
971
+ for (const [account, value] of Object.entries(accounts)) {
972
+ try {
973
+ const parsed = JSON.parse(value) as { version?: unknown; origin?: unknown };
974
+ if (parsed.version === SERVER_CREDENTIAL_RECORD_VERSION && parsed.origin === marker.origin) {
975
+ delete next[account];
976
+ }
977
+ } catch {
978
+ // Preserve unrelated malformed entries for the backend's fail-closed reader.
979
+ }
980
+ }
981
+ Object.assign(next, rollback.activeAccounts);
982
+ next[rollback.pendingAccount] = rollback.pendingValue;
983
+ delete next[serverAcceptedEnrollmentAccount(marker.origin)];
984
+ delete next[marker.rollbackAccount];
985
+ await backend.replaceAccounts(next);
986
+ }), { processShared: processSharedEnrollmentLock() });
987
+ }
988
+
989
+ export type EnrollmentRecoveryTransaction =
990
+ | { kind: 'accepted'; marker: AcceptedEnrollmentMarker }
991
+ | { kind: 'pending'; pending: PendingServerEnrollmentRecord };
992
+
993
+ export async function findEnrollmentRecoveryTransaction(
994
+ selectedOrigin?: string,
995
+ ): Promise<EnrollmentRecoveryTransaction | null> {
996
+ if (selectedOrigin !== undefined) {
997
+ let parsed: URL;
998
+ try { parsed = new URL(selectedOrigin); } catch { throw new Error('invalid Borg server credential origin'); }
999
+ if (parsed.origin !== selectedOrigin || parsed.protocol !== 'https:') {
1000
+ throw new Error('Borg server credentials require a canonical HTTPS origin');
1001
+ }
1002
+ }
1003
+ const backend = await getServerCredentialBackend();
1004
+ if (!backend.entries) return null;
1005
+ return withCredentialStoreLock(async () => {
1006
+ const accepted: AcceptedEnrollmentMarker[] = [];
1007
+ const pending: PendingServerEnrollmentRecord[] = [];
1008
+ for (const [account, value] of Object.entries(await backend.entries!())) {
1009
+ try {
1010
+ const raw = JSON.parse(value) as { version?: unknown; state?: unknown; origin?: unknown; trustIdentity?: unknown };
1011
+ if (raw.version === SERVER_ACCEPTED_ENROLLMENT_MARKER_VERSION && raw.state === 'accepted') {
1012
+ const marker = decodeAcceptedEnrollmentMarker(value);
1013
+ if (account !== serverAcceptedEnrollmentAccount(marker.origin)) {
1014
+ throw new InvitationArtifactRecoveryError(MISKEYED_RECOVERY_ERROR);
1015
+ }
1016
+ if (selectedOrigin === undefined || marker.origin === selectedOrigin) accepted.push(marker);
1017
+ } else if (
1018
+ (raw.version === SERVER_PENDING_ENROLLMENT_RECORD_VERSION ||
1019
+ raw.version === LEGACY_SERVER_PENDING_ENROLLMENT_RECORD_VERSION) &&
1020
+ raw.state === 'pending' &&
1021
+ typeof raw.origin === 'string' && typeof raw.trustIdentity === 'string'
1022
+ ) {
1023
+ const record = decodePendingServerEnrollment(value, raw.origin, raw.trustIdentity);
1024
+ if (account !== serverPendingEnrollmentAccount(raw.origin, raw.trustIdentity)) {
1025
+ throw new InvitationArtifactRecoveryError(MISKEYED_RECOVERY_ERROR);
1026
+ }
1027
+ if (selectedOrigin === undefined || record.origin === selectedOrigin) pending.push(record);
1028
+ }
1029
+ } catch (error) {
1030
+ if (error instanceof InvitationArtifactRecoveryError) throw error;
1031
+ if (
1032
+ account.startsWith('borg-server-enrollment-accepted:') ||
1033
+ account.startsWith('borg-server-enrollment-pending:') ||
1034
+ account.startsWith('borg-server-enrollment-rollback:')
1035
+ ) throw new InvitationArtifactRecoveryError();
1036
+ // Unrelated corrupt entries are preserved for their owning reader.
615
1037
  }
616
- next[target] = encodeServerCredentialRecord(activeRecord, serverCapabilities);
617
- delete next[pendingAccount];
618
- await backend.replaceAccounts(next);
619
- } else {
620
- await writeServerCredentialRecord(backend, activeRecord, input.allowReplacement === true);
621
- await backend.delete(pendingAccount);
622
1038
  }
1039
+ if (accepted.length + pending.length > 1) {
1040
+ throw new Error('multiple Borg enrollment transactions require an explicit server endpoint');
1041
+ }
1042
+ if (accepted[0]) return { kind: 'accepted', marker: accepted[0] };
1043
+ if (pending[0]) return { kind: 'pending', pending: pending[0] };
1044
+ return null;
623
1045
  });
624
1046
  }
625
1047
 
@@ -632,7 +1054,7 @@ export async function clearPendingServerEnrollment(
632
1054
  validateUuid(retryKey, 'enrollment retry key');
633
1055
  const backend = await getServerCredentialBackend();
634
1056
  const account = serverPendingEnrollmentAccount(origin, trustIdentity);
635
- await withCredentialStoreLock(async () => {
1057
+ await withEnrollmentOriginLock(origin, () => withCredentialStoreLock(async () => {
636
1058
  const stored = await backend.get(account);
637
1059
  if (!stored) return;
638
1060
  try {
@@ -642,7 +1064,7 @@ export async function clearPendingServerEnrollment(
642
1064
  return;
643
1065
  }
644
1066
  await backend.delete(account);
645
- });
1067
+ }), { processShared: processSharedEnrollmentLock() });
646
1068
  }
647
1069
 
648
1070
  /** Persist one repository-scoped cube-create idempotency key in the 0600 credential store. */
@@ -771,28 +1193,49 @@ export async function clearPendingServerCubeCreation(
771
1193
  export async function clearServerCredential(origin: string, trustIdentity: string): Promise<void> {
772
1194
  const backend = await getServerCredentialBackend();
773
1195
  const pendingAccount = serverPendingEnrollmentAccount(origin, trustIdentity);
774
- await withCredentialStoreLock(async () => {
1196
+ await withEnrollmentOriginLock(origin, () => withCredentialStoreLock(async () => {
775
1197
  await backend.delete(serverCredentialAccount(origin, trustIdentity));
776
1198
  await backend.delete(pendingAccount);
777
- });
1199
+ }), { processShared: processSharedEnrollmentLock() });
778
1200
  }
779
1201
 
780
- /** Clear only the failed enrollment transaction for one origin/identity. */
781
- export async function clearEnrollmentTransaction(origin: string, trustIdentity: string): Promise<void> {
782
- validateServerCredentialBinding(origin, trustIdentity);
1202
+ /** Clear only the exact failed enrollment transaction that the operator reviewed. */
1203
+ export async function clearEnrollmentTransaction(
1204
+ expected: PendingServerEnrollmentRecord,
1205
+ ): Promise<boolean> {
1206
+ validateServerCredentialBinding(expected.origin, expected.trustIdentity);
783
1207
  const backend = await getServerCredentialBackend();
784
- const pendingAccount = serverPendingEnrollmentAccount(origin, trustIdentity);
785
- const credentialAccount = serverCredentialAccount(origin, trustIdentity);
786
- await withCredentialStoreLock(async () => {
1208
+ const pendingAccount = serverPendingEnrollmentAccount(expected.origin, expected.trustIdentity);
1209
+ return withEnrollmentOriginLock(expected.origin, () => withCredentialStoreLock(async () => {
1210
+ if (await markerForOriginUnlocked(backend, expected.origin)) {
1211
+ throw new InvitationArtifactRecoveryError();
1212
+ }
787
1213
  if (backend.entries && backend.replaceAccounts) {
788
1214
  const accounts = await backend.entries();
1215
+ const stored = accounts[pendingAccount];
1216
+ if (!stored) return false;
1217
+ let current: PendingServerEnrollmentRecord;
1218
+ try {
1219
+ current = decodePendingServerEnrollment(stored, expected.origin, expected.trustIdentity);
1220
+ } catch {
1221
+ return false;
1222
+ }
1223
+ if (JSON.stringify(current) !== JSON.stringify(expected)) return false;
789
1224
  const next = { ...accounts };
790
1225
  delete next[pendingAccount];
791
- delete next[credentialAccount];
792
1226
  await backend.replaceAccounts(next);
793
- return;
1227
+ return (await backend.get(pendingAccount)) === null;
794
1228
  }
1229
+ const stored = await backend.get(pendingAccount);
1230
+ if (!stored) return false;
1231
+ let current: PendingServerEnrollmentRecord;
1232
+ try {
1233
+ current = decodePendingServerEnrollment(stored, expected.origin, expected.trustIdentity);
1234
+ } catch {
1235
+ return false;
1236
+ }
1237
+ if (JSON.stringify(current) !== JSON.stringify(expected)) return false;
795
1238
  await backend.delete(pendingAccount);
796
- await backend.delete(credentialAccount);
797
- });
1239
+ return (await backend.get(pendingAccount)) === null;
1240
+ }), { processShared: processSharedEnrollmentLock() });
798
1241
  }