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.
- package/dist/cli-help.js +3 -3
- package/dist/cli-help.js.map +1 -1
- package/dist/config.d.ts +34 -9
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +487 -105
- package/dist/config.js.map +1 -1
- package/dist/enrollment-lock.d.ts +10 -0
- package/dist/enrollment-lock.d.ts.map +1 -0
- package/dist/enrollment-lock.js +49 -0
- package/dist/enrollment-lock.js.map +1 -0
- package/dist/enrollment-types.d.ts +47 -0
- package/dist/enrollment-types.d.ts.map +1 -0
- package/dist/enrollment-types.js +2 -0
- package/dist/enrollment-types.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/invitation-artifact.d.ts +3 -1
- package/dist/invitation-artifact.d.ts.map +1 -1
- package/dist/invitation-artifact.js +3 -1
- package/dist/invitation-artifact.js.map +1 -1
- package/dist/recover-enrollment-cmd.d.ts.map +1 -1
- package/dist/recover-enrollment-cmd.js +34 -15
- package/dist/recover-enrollment-cmd.js.map +1 -1
- package/dist/remote-client.d.ts +3 -2
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +32 -11
- package/dist/remote-client.js.map +1 -1
- package/dist/server-errors.d.ts +4 -0
- package/dist/server-errors.d.ts.map +1 -1
- package/dist/server-errors.js +8 -0
- package/dist/server-errors.js.map +1 -1
- package/dist/server-handshake.d.ts +11 -3
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +103 -45
- package/dist/server-handshake.js.map +1 -1
- package/dist/server-trust.d.ts +14 -1
- package/dist/server-trust.d.ts.map +1 -1
- package/dist/server-trust.js +363 -66
- package/dist/server-trust.js.map +1 -1
- package/dist/tool-manifest.d.ts.map +1 -1
- package/dist/tool-manifest.js +4 -10
- package/dist/tool-manifest.js.map +1 -1
- package/docs/EXTRACTION_PROVENANCE.md +3 -3
- package/docs/RELEASING.md +7 -2
- package/package.json +1 -1
- package/src/cli-help.ts +3 -3
- package/src/config.ts +544 -101
- package/src/enrollment-lock.ts +53 -0
- package/src/enrollment-types.ts +51 -0
- package/src/index.ts +1 -1
- package/src/invitation-artifact.ts +7 -1
- package/src/recover-enrollment-cmd.ts +39 -15
- package/src/remote-client.ts +36 -10
- package/src/server-errors.ts +7 -0
- package/src/server-handshake.ts +130 -52
- package/src/server-trust.ts +392 -66
- 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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
536
|
+
return decodeActiveServerCredentialRecord(stored, origin, trustIdentity);
|
|
368
537
|
} catch {
|
|
369
538
|
return null;
|
|
370
539
|
}
|
|
371
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
774
|
+
retryKey,
|
|
530
775
|
credential: randomBytes(32).toString('base64url'),
|
|
531
776
|
...(input.clientName === undefined ? {} : { clientName: input.clientName }),
|
|
532
|
-
...(input.
|
|
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
|
-
|
|
554
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
|
781
|
-
export async function clearEnrollmentTransaction(
|
|
782
|
-
|
|
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
|
-
|
|
786
|
-
|
|
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.
|
|
797
|
-
});
|
|
1239
|
+
return (await backend.get(pendingAccount)) === null;
|
|
1240
|
+
}), { processShared: processSharedEnrollmentLock() });
|
|
798
1241
|
}
|