borgmcp 2.9.0 → 2.10.2

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 (87) hide show
  1. package/dist/assimilate-cmd.d.ts +13 -0
  2. package/dist/assimilate-cmd.d.ts.map +1 -1
  3. package/dist/assimilate-cmd.js +177 -11
  4. package/dist/assimilate-cmd.js.map +1 -1
  5. package/dist/assimilate-deps.d.ts.map +1 -1
  6. package/dist/assimilate-deps.js +26 -1
  7. package/dist/assimilate-deps.js.map +1 -1
  8. package/dist/assimilate-welcome.d.ts.map +1 -1
  9. package/dist/assimilate-welcome.js +1 -0
  10. package/dist/assimilate-welcome.js.map +1 -1
  11. package/dist/claude.d.ts.map +1 -1
  12. package/dist/claude.js +27 -0
  13. package/dist/claude.js.map +1 -1
  14. package/dist/cli-help.d.ts +1 -0
  15. package/dist/cli-help.d.ts.map +1 -1
  16. package/dist/cli-help.js +13 -0
  17. package/dist/cli-help.js.map +1 -1
  18. package/dist/config.d.ts +11 -1
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +169 -13
  21. package/dist/config.js.map +1 -1
  22. package/dist/invitation-artifact.d.ts +33 -0
  23. package/dist/invitation-artifact.d.ts.map +1 -0
  24. package/dist/invitation-artifact.js +89 -0
  25. package/dist/invitation-artifact.js.map +1 -0
  26. package/dist/launch-gate.d.ts +5 -4
  27. package/dist/launch-gate.d.ts.map +1 -1
  28. package/dist/launch-gate.js +7 -8
  29. package/dist/launch-gate.js.map +1 -1
  30. package/dist/parse-assimilate-args.d.ts.map +1 -1
  31. package/dist/parse-assimilate-args.js +9 -3
  32. package/dist/parse-assimilate-args.js.map +1 -1
  33. package/dist/recover-enrollment-cmd.d.ts +17 -0
  34. package/dist/recover-enrollment-cmd.d.ts.map +1 -0
  35. package/dist/recover-enrollment-cmd.js +62 -0
  36. package/dist/recover-enrollment-cmd.js.map +1 -0
  37. package/dist/regen-format.d.ts +9 -0
  38. package/dist/regen-format.d.ts.map +1 -1
  39. package/dist/regen-format.js +15 -0
  40. package/dist/regen-format.js.map +1 -1
  41. package/dist/regen.js +27 -3
  42. package/dist/regen.js.map +1 -1
  43. package/dist/seat-store.d.ts.map +1 -1
  44. package/dist/seat-store.js +7 -0
  45. package/dist/seat-store.js.map +1 -1
  46. package/dist/server-facade.d.ts +1 -1
  47. package/dist/server-facade.d.ts.map +1 -1
  48. package/dist/server-facade.js +13 -2
  49. package/dist/server-facade.js.map +1 -1
  50. package/dist/server-handshake.d.ts +19 -3
  51. package/dist/server-handshake.d.ts.map +1 -1
  52. package/dist/server-handshake.js +86 -9
  53. package/dist/server-handshake.js.map +1 -1
  54. package/dist/server-trust.d.ts +8 -0
  55. package/dist/server-trust.d.ts.map +1 -1
  56. package/dist/server-trust.js +165 -5
  57. package/dist/server-trust.js.map +1 -1
  58. package/dist/token-store.d.ts +3 -0
  59. package/dist/token-store.d.ts.map +1 -1
  60. package/dist/token-store.js +2 -0
  61. package/dist/token-store.js.map +1 -1
  62. package/dist/unknown-subcommand.d.ts +1 -1
  63. package/dist/unknown-subcommand.d.ts.map +1 -1
  64. package/dist/unknown-subcommand.js +1 -0
  65. package/dist/unknown-subcommand.js.map +1 -1
  66. package/docs/EXTRACTION_PROVENANCE.md +11 -7
  67. package/docs/LOCAL_SERVER.md +12 -3
  68. package/docs/RELEASING.md +47 -7
  69. package/package.json +2 -2
  70. package/src/assimilate-cmd.ts +231 -26
  71. package/src/assimilate-deps.ts +26 -0
  72. package/src/assimilate-welcome.ts +1 -0
  73. package/src/claude.ts +22 -0
  74. package/src/cli-help.ts +16 -0
  75. package/src/config.ts +165 -10
  76. package/src/invitation-artifact.ts +116 -0
  77. package/src/launch-gate.ts +7 -8
  78. package/src/parse-assimilate-args.ts +11 -4
  79. package/src/recover-enrollment-cmd.ts +63 -0
  80. package/src/regen-format.ts +25 -0
  81. package/src/regen.ts +30 -2
  82. package/src/seat-store.ts +6 -0
  83. package/src/server-facade.ts +13 -2
  84. package/src/server-handshake.ts +113 -6
  85. package/src/server-trust.ts +180 -5
  86. package/src/token-store.ts +5 -0
  87. package/src/unknown-subcommand.ts +1 -0
package/src/regen.ts CHANGED
@@ -22,12 +22,18 @@ import { monitorStateRootForWorktree } from './inbox-monitor.js';
22
22
  import {
23
23
  parseHookSource,
24
24
  formatLeanOrientation,
25
+ formatPlainSessionReminder,
26
+ shouldRelayPlainSessionReminder,
25
27
  type AgentKind,
26
28
  } from './regen-format.js';
27
29
  import { resolveSessionAgentKind } from './codex-app-wake.js';
28
30
  import { resolveReportableSessionAgentKind } from './agent-runtime.js';
29
31
  import { handleVersionFlag } from './version.js';
30
- import { gateAllowsActivation } from './launch-gate.js';
32
+ import {
33
+ BORG_LAUNCH_REMINDER_DISABLED_ENV,
34
+ BORG_SESSION_ENV,
35
+ gateAllowsActivation,
36
+ } from './launch-gate.js';
31
37
  import { resolveWorkingRepo } from './working-repo.js';
32
38
  import {
33
39
  confirmDisplayIdentity,
@@ -56,18 +62,40 @@ async function readStdin(): Promise<string> {
56
62
 
57
63
  async function main(): Promise<void> {
58
64
  handleVersionFlag();
65
+ const hookSource = parseHookSource(await readStdin());
59
66
  // gh#673 P1 (WI-4): the SessionStart orientation only activates in
60
67
  // borg-launched sessions — a vanilla `claude` anywhere (including an
61
68
  // assimilated repo) stays vanilla. Exit-0 no-op: a hook must never
62
69
  // block session start. ACTIVATION-only, never a security gate.
63
70
  if (!gateAllowsActivation('borg-regen SessionStart hook')) {
71
+ // launch-reminder-ux: plain sessions get one model-mediated relay only on
72
+ // the initial SessionStart event. No repo connection means no output.
73
+ let hasActiveCube = false;
74
+ try {
75
+ hasActiveCube = (await getActiveCube()) !== null;
76
+ } catch {
77
+ hasActiveCube = false;
78
+ }
79
+ if (shouldRelayPlainSessionReminder({
80
+ source: hookSource,
81
+ agentKind: resolveSessionAgentKind(),
82
+ hasActiveCube,
83
+ borgSession: process.env[BORG_SESSION_ENV],
84
+ disabled: process.env[BORG_LAUNCH_REMINDER_DISABLED_ENV],
85
+ })) {
86
+ try {
87
+ process.stdout.write(`${formatPlainSessionReminder()}\n`);
88
+ } catch {
89
+ // Plain-session discovery is best-effort and must stay silent on failure.
90
+ }
91
+ }
64
92
  return;
65
93
  }
66
94
  // gh#926/gh#927: the SessionStart `source` (startup/resume/clear/compact)
67
95
  // tags the orientation; on `/clear` Claude Code has cleared the
68
96
  // session-scoped `/loop` + `ScheduleWakeup`, and the kickoff prompt is
69
97
  // gone, so the lean orientation adds a "re-establish your wake path" note.
70
- const source = parseHookSource(await readStdin());
98
+ const source = hookSource;
71
99
 
72
100
  const active = await getActiveCube();
73
101
  if (!active) {
package/src/seat-store.ts CHANGED
@@ -238,6 +238,12 @@ export async function atomicWrite0600(
238
238
  await assertSecureFile(filePath);
239
239
  }
240
240
  await rename(tmp, filePath);
241
+ const parent = await open(dirname(filePath), 'r');
242
+ try {
243
+ await parent.sync();
244
+ } finally {
245
+ await parent.close();
246
+ }
241
247
  } catch (err) {
242
248
  await unlink(tmp).catch(() => {});
243
249
  throw err;
@@ -5,7 +5,18 @@ import { cubeInitHelpText, isHelpFlag, serverHelpText } from './cli-help.js';
5
5
  import { consolePrefix } from './console-prefix.js';
6
6
  import { getPackageVersion } from './version.js';
7
7
 
8
- export const SERVER_LIFECYCLE_COMMANDS = ['setup', 'start', 'stop', 'status', 'update', 'invite', 'dashboard'] as const;
8
+ export const SERVER_LIFECYCLE_COMMANDS = [
9
+ 'setup',
10
+ 'start',
11
+ 'stop',
12
+ 'status',
13
+ 'update',
14
+ 'invite',
15
+ 'cert-reissue',
16
+ 'client-list',
17
+ 'client-grant',
18
+ 'dashboard',
19
+ ] as const;
9
20
  export type ServerLifecycleCommand = typeof SERVER_LIFECYCLE_COMMANDS[number];
10
21
 
11
22
  export type ParsedServerFacadeArgs =
@@ -162,7 +173,7 @@ function inertCommand(command: string): string {
162
173
  export function unknownServerCommandText(command: string): string {
163
174
  return (
164
175
  `Unknown server command: ${inertCommand(command)}.\n` +
165
- `Available commands: setup, start, stop, status, update, invite, dashboard, cube init.\n` +
176
+ `Available commands: setup, start, stop, status, update, invite, cert-reissue, client-list, client-grant, dashboard, cube init.\n` +
166
177
  `Next: run borg server --help.\n`
167
178
  );
168
179
  }
@@ -8,6 +8,7 @@ import {
8
8
  REPOSITORY_CUBE_RESOLVE_PATH,
9
9
  createAttachRequestEnvelope,
10
10
  createProtocolEnvelope,
11
+ encodeInvitationArtifact,
11
12
  decodeAssociateRepositoryCubeRequest,
12
13
  decodeAssociateRepositoryCubeResponseEnvelope,
13
14
  decodeAttachResponseEnvelope,
@@ -17,6 +18,7 @@ import {
17
18
  decodeEnrollmentExchangeResponseEnvelope,
18
19
  decodeProtocolErrorEnvelope,
19
20
  decodeProtocolTagPreflight,
21
+ type InvitationArtifact,
20
22
  decodeResolveRepositoryCubeRequest,
21
23
  decodeResolveRepositoryCubeResponseEnvelope,
22
24
  ErrorCode,
@@ -36,6 +38,7 @@ import {
36
38
  clearPendingServerEnrollment,
37
39
  getServerCredential,
38
40
  getServerCredentialRecord,
41
+ hasServerCredentialForOrigin,
39
42
  getPendingServerEnrollment,
40
43
  getOrCreatePendingServerCubeCreation,
41
44
  getOrCreatePendingServerEnrollment,
@@ -65,8 +68,17 @@ import { DroneEvictedError, DRONE_EVICTED_CODE } from './drone-lifecycle.js';
65
68
  import { readBoundedResponseBody } from './server-response.js';
66
69
  import {
67
70
  loadBorgServerTrust,
71
+ loadBorgServerTrustFromPresentedChain,
68
72
  type BorgServerTrust,
73
+ type StagedBorgServerTrust,
69
74
  } from './server-trust.js';
75
+ import {
76
+ InvitationArtifactCompatibilityError,
77
+ InvitationArtifactStorageError,
78
+ InvitationArtifactTransportError,
79
+ InvitationArtifactTrustError,
80
+ decodeAndVerifyInvitationArtifact,
81
+ } from './invitation-artifact.js';
70
82
 
71
83
  const HANDSHAKE_BODY_LIMIT = 64 * 1024;
72
84
  const HANDSHAKE_TIMEOUT_MS = 5_000;
@@ -453,32 +465,48 @@ export async function enrollBorgServer(
453
465
  activateEnrollment?: typeof activatePendingServerEnrollment;
454
466
  clearPendingEnrollment?: typeof clearPendingServerEnrollment;
455
467
  loadCredentialRecord?: typeof getServerCredentialRecord;
468
+ hasCredentialForOrigin?: typeof hasServerCredentialForOrigin;
456
469
  confirmReplacement?: () => Promise<boolean>;
457
470
  clientName?: string;
471
+ expectedAuthority?: InvitationArtifact['authority'];
472
+ commitTrust?: (activate: () => Promise<void>) => Promise<void>;
473
+ discardTrust?: () => Promise<void>;
458
474
  } = {},
459
475
  ): Promise<NewServerEnrollment> {
460
476
  const fetchImpl = deps.fetchImpl ?? fetch;
461
477
  // Credential-free tag preflight FIRST (CR fb4d6eba): after pinned TLS, an
462
478
  // incompatible server must be rejected before any credential is created or
463
479
  // persisted and before any invitation/secret-bearing request is sent.
464
- const protocol = await preflightBorgServerTag(origin, fetchImpl);
480
+ let protocol: ProtocolTagPreflight;
481
+ try {
482
+ protocol = await preflightBorgServerTag(origin, fetchImpl);
483
+ } catch (error) {
484
+ await deps.discardTrust?.();
485
+ throw error;
486
+ }
465
487
 
466
488
  const existingCredential = await (deps.loadCredentialRecord ?? getServerCredentialRecord)(
467
489
  origin,
468
490
  trustIdentity,
469
491
  );
492
+ const existingOriginCredential = existingCredential !== null || await (
493
+ deps.hasCredentialForOrigin ?? hasServerCredentialForOrigin
494
+ )(origin);
470
495
  let replacementConfirmed = false;
471
- if (existingCredential) {
496
+ let replacementCapability: string | undefined;
497
+ if (existingOriginCredential) {
472
498
  const confirmed = deps.confirmReplacement !== undefined
473
499
  ? await deps.confirmReplacement()
474
500
  : false;
475
501
  if (!confirmed) {
502
+ await deps.discardTrust?.();
476
503
  throw new BorgServerError(
477
504
  'LOCAL_CREDENTIAL_EXISTS',
478
505
  'a local enrollment already exists for this server and was not replaced',
479
506
  );
480
507
  }
481
508
  replacementConfirmed = true;
509
+ replacementCapability = randomUUID();
482
510
  }
483
511
 
484
512
  const pending = await (deps.prepareEnrollment ?? getOrCreatePendingServerEnrollment)({
@@ -486,6 +514,7 @@ export async function enrollBorgServer(
486
514
  trustIdentity,
487
515
  invitation,
488
516
  ...(deps.clientName ? { clientName: deps.clientName } : {}),
517
+ ...(replacementCapability === undefined ? {} : { replacementCapability }),
489
518
  });
490
519
  const request = decodeEnrollmentExchangeRequest({
491
520
  invitation: pending.invitation,
@@ -515,9 +544,13 @@ export async function enrollBorgServer(
515
544
  clearTimeout(timeout);
516
545
  }
517
546
  }
518
- if (!response) throw lastTransportError;
547
+ if (!response) {
548
+ await deps.discardTrust?.();
549
+ throw lastTransportError;
550
+ }
519
551
 
520
552
  if (response.status === 401 || response.status === 403) {
553
+ await deps.discardTrust?.();
521
554
  await (deps.clearPendingEnrollment ?? clearPendingServerEnrollment)(
522
555
  origin,
523
556
  trustIdentity,
@@ -529,6 +562,7 @@ export async function enrollBorgServer(
529
562
  );
530
563
  }
531
564
  if (response.status !== 201) {
565
+ await deps.discardTrust?.();
532
566
  throw new Error(`Borg server enrollment failed (HTTP ${response.status})`);
533
567
  }
534
568
  let decoded: ReturnType<typeof decodeEnrollmentExchangeResponseEnvelope>;
@@ -537,19 +571,37 @@ export async function enrollBorgServer(
537
571
  JSON.parse(await readHandshakeBodyWithTimeout(response)),
538
572
  );
539
573
  } catch (error) {
574
+ await deps.discardTrust?.();
540
575
  if (error instanceof Error && error.message.includes('response limit')) throw error;
541
576
  throw new Error('Borg server returned an invalid enrollment envelope');
542
577
  }
543
578
 
544
- await (deps.activateEnrollment ?? activatePendingServerEnrollment)({
579
+ if (deps.expectedAuthority !== undefined) {
580
+ const owner = decoded.payload.server_capabilities.length > 0;
581
+ if ((deps.expectedAuthority === 'owner') !== owner) {
582
+ await (deps.clearPendingEnrollment ?? clearPendingServerEnrollment)(
583
+ origin,
584
+ trustIdentity,
585
+ pending.retryKey,
586
+ );
587
+ await deps.discardTrust?.();
588
+ throw new InvitationArtifactTrustError();
589
+ }
590
+ }
591
+
592
+ const activate = () => (deps.activateEnrollment ?? activatePendingServerEnrollment)({
545
593
  origin,
546
594
  trustIdentity,
547
595
  retryKey: pending.retryKey,
548
596
  credential: pending.credential,
549
597
  clientId: decoded.payload.client_id,
550
598
  serverCapabilities: decoded.payload.server_capabilities,
551
- ...(replacementConfirmed ? { allowReplacement: true } : {}),
599
+ ...(replacementConfirmed || pending.replacementCapability !== undefined
600
+ ? { allowReplacement: true, ...(pending.replacementCapability === undefined ? {} : { replacementCapability: pending.replacementCapability }) }
601
+ : {}),
552
602
  });
603
+ if (deps.commitTrust) await deps.commitTrust(activate);
604
+ else await activate();
553
605
  return {
554
606
  token: pending.credential,
555
607
  trustIdentity,
@@ -1055,6 +1107,55 @@ export async function enrollLocalBorgServer(
1055
1107
  });
1056
1108
  }
1057
1109
 
1110
+ /** Enroll from a verified v2 artifact whose endpoint and CA pin are authoritative. */
1111
+ export async function enrollLocalBorgServerArtifact(
1112
+ artifact: InvitationArtifact,
1113
+ deps: {
1114
+ invitation?: string;
1115
+ confirmReplacement?: () => Promise<boolean>;
1116
+ clientName?: string;
1117
+ loadTrustFromPresentedChain?: typeof loadBorgServerTrustFromPresentedChain;
1118
+ prepareEnrollment?: typeof getOrCreatePendingServerEnrollment;
1119
+ activateEnrollment?: typeof activatePendingServerEnrollment;
1120
+ clearPendingEnrollment?: typeof clearPendingServerEnrollment;
1121
+ loadCredentialRecord?: typeof getServerCredentialRecord;
1122
+ hasCredentialForOrigin?: typeof hasServerCredentialForOrigin;
1123
+ } = {},
1124
+ ): Promise<NewServerEnrollment> {
1125
+ // Preserve the byte-exact opaque artifact from the operator's prompt. The
1126
+ // decoded object is enough to inspect the endpoint/pin, but it is not the
1127
+ // enrollment protocol's invitation material: replacing it with
1128
+ // `verifiedArtifact.secret` silently degrades v2 composite artifacts.
1129
+ const opaqueArtifact = deps.invitation ?? encodeInvitationArtifact(artifact);
1130
+ const verifiedArtifact = decodeAndVerifyInvitationArtifact(opaqueArtifact);
1131
+ let trust: StagedBorgServerTrust;
1132
+ try {
1133
+ trust = await (deps.loadTrustFromPresentedChain ?? loadBorgServerTrustFromPresentedChain)(
1134
+ verifiedArtifact.endpoint,
1135
+ verifiedArtifact.ca_spki_sha256,
1136
+ );
1137
+ } catch (error) {
1138
+ if (error instanceof InvitationArtifactCompatibilityError) throw error;
1139
+ if (error instanceof InvitationArtifactTransportError) throw error;
1140
+ if (error instanceof InvitationArtifactTrustError) throw error;
1141
+ if (error instanceof InvitationArtifactStorageError) throw error;
1142
+ throw new InvitationArtifactTransportError();
1143
+ }
1144
+ return enrollBorgServer(verifiedArtifact.endpoint, trust.identity, opaqueArtifact, {
1145
+ fetchImpl: trust.fetchImpl,
1146
+ ...(deps.confirmReplacement === undefined ? {} : { confirmReplacement: deps.confirmReplacement }),
1147
+ ...(deps.clientName === undefined ? {} : { clientName: deps.clientName }),
1148
+ ...(deps.prepareEnrollment === undefined ? {} : { prepareEnrollment: deps.prepareEnrollment }),
1149
+ ...(deps.activateEnrollment === undefined ? {} : { activateEnrollment: deps.activateEnrollment }),
1150
+ ...(deps.clearPendingEnrollment === undefined ? {} : { clearPendingEnrollment: deps.clearPendingEnrollment }),
1151
+ ...(deps.loadCredentialRecord === undefined ? {} : { loadCredentialRecord: deps.loadCredentialRecord }),
1152
+ ...(deps.hasCredentialForOrigin === undefined ? {} : { hasCredentialForOrigin: deps.hasCredentialForOrigin }),
1153
+ expectedAuthority: verifiedArtifact.authority,
1154
+ commitTrust: trust.commitTrust,
1155
+ discardTrust: trust.discardTrust,
1156
+ });
1157
+ }
1158
+
1058
1159
  /** Load verified trust and resume a prior ambiguous enrollment before prompting. */
1059
1160
  export async function resumeLocalBorgServerEnrollment(
1060
1161
  origin: string,
@@ -1064,7 +1165,13 @@ export async function resumeLocalBorgServerEnrollment(
1064
1165
  onPending?: () => void;
1065
1166
  } = {},
1066
1167
  ): Promise<NewServerEnrollment | null> {
1067
- const trust = await (deps.loadTrust ?? loadBorgServerTrust)(origin);
1168
+ let trust: BorgServerTrust;
1169
+ try {
1170
+ trust = await (deps.loadTrust ?? loadBorgServerTrust)(origin);
1171
+ } catch (error) {
1172
+ if (error instanceof Error && /trust files were not found|trust metadata/i.test(error.message)) return null;
1173
+ throw error;
1174
+ }
1068
1175
  return resumeBorgServerEnrollment(origin, trust.identity, {
1069
1176
  fetchImpl: trust.fetchImpl,
1070
1177
  ...(deps.loadPendingEnrollment === undefined
@@ -1,12 +1,20 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { createHash, timingSafeEqual, X509Certificate } from 'node:crypto';
3
3
  import { constants } from 'node:fs';
4
- import { open } from 'node:fs/promises';
4
+ import { access, mkdir, open, rename, rm, writeFile } from 'node:fs/promises';
5
5
  import { request as httpsRequest } from 'node:https';
6
+ import { connect as tlsConnect } from 'node:tls';
6
7
  import { homedir } from 'node:os';
7
8
  import { join, resolve } from 'node:path';
8
9
  import { Readable } from 'node:stream';
9
10
  import { BorgServerTrustError } from './server-errors.js';
11
+ import {
12
+ InvitationArtifactCompatibilityError,
13
+ InvitationArtifactRecoveryError,
14
+ InvitationArtifactStorageError,
15
+ InvitationArtifactTransportError,
16
+ InvitationArtifactTrustError,
17
+ } from './invitation-artifact.js';
10
18
 
11
19
  // CR5 TLS LATTICE: OpenSSL/Node TLS certificate-verification error codes. A raw
12
20
  // CA / cert-chain / SAN failure from the pinned transport is a potential MITM and
@@ -48,6 +56,11 @@ export interface BorgServerTrust {
48
56
  fetchImpl: ServerFetch;
49
57
  }
50
58
 
59
+ export interface StagedBorgServerTrust extends BorgServerTrust {
60
+ commitTrust: (activate: () => Promise<void>) => Promise<void>;
61
+ discardTrust: () => Promise<void>;
62
+ }
63
+
51
64
  interface ServerTrustConfig {
52
65
  ca_spki_sha256: string;
53
66
  }
@@ -58,6 +71,20 @@ function serverDataDirectory(): string {
58
71
  return resolve(process.env.BORG_SERVER_DATA_DIR ?? join(homedir(), '.borg', 'server'));
59
72
  }
60
73
 
74
+ function remoteTrustDirectory(origin: string): string {
75
+ const key = createHash('sha256').update(origin).digest('hex');
76
+ return join(homedir(), '.borg', 'server-trust', key);
77
+ }
78
+
79
+ async function trustFilesExist(directory: string): Promise<boolean> {
80
+ try {
81
+ await Promise.all([access(join(directory, 'ca.crt')), access(join(directory, 'server.json'))]);
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
61
88
  async function readTrustFile(path: string): Promise<string> {
62
89
  let handle;
63
90
  try {
@@ -82,6 +109,15 @@ async function readTrustFile(path: string): Promise<string> {
82
109
  }
83
110
  }
84
111
 
112
+ async function syncPath(path: string): Promise<void> {
113
+ const handle = await open(path, 'r');
114
+ try {
115
+ await handle.sync();
116
+ } finally {
117
+ await handle.close();
118
+ }
119
+ }
120
+
85
121
  function decodeTrustConfig(value: string): ServerTrustConfig {
86
122
  let parsed: unknown;
87
123
  try {
@@ -228,15 +264,22 @@ export function createPinnedServerFetch(origin: string, caCertificate: string):
228
264
 
229
265
  export async function loadBorgServerTrust(
230
266
  origin: string,
231
- dataDirectory = serverDataDirectory(),
267
+ dataDirectory?: string,
232
268
  ): Promise<BorgServerTrust> {
233
- const key = `${dataDirectory}\0${origin}`;
269
+ const requestedDirectory = dataDirectory ?? serverDataDirectory();
270
+ const key = `${requestedDirectory}\0${origin}`;
234
271
  let pending = trustCache.get(key);
235
272
  if (!pending) {
236
273
  pending = (async () => {
274
+ const remoteDirectory = remoteTrustDirectory(origin);
275
+ const directory = dataDirectory === undefined && await trustFilesExist(remoteDirectory)
276
+ ? remoteDirectory
277
+ : dataDirectory === undefined && !(await trustFilesExist(requestedDirectory))
278
+ ? remoteDirectory
279
+ : requestedDirectory;
237
280
  const [certificate, configText] = await Promise.all([
238
- readTrustFile(join(dataDirectory, 'ca.crt')),
239
- readTrustFile(join(dataDirectory, 'server.json')),
281
+ readTrustFile(join(directory, 'ca.crt')),
282
+ readTrustFile(join(directory, 'server.json')),
240
283
  ]);
241
284
  const config = decodeTrustConfig(configText);
242
285
  const identity = verifyCaIdentity(certificate, config.ca_spki_sha256);
@@ -251,6 +294,138 @@ export async function loadBorgServerTrust(
251
294
  return pending;
252
295
  }
253
296
 
297
+ function pemCertificate(raw: Buffer): string {
298
+ const body = raw.toString('base64').match(/.{1,64}/g)?.join('\n') ?? '';
299
+ return `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n`;
300
+ }
301
+
302
+ function chainCertificates(socket: { getPeerCertificate(detailed: true): any }): Buffer[] {
303
+ const result: Buffer[] = [];
304
+ const seen = new Set<string>();
305
+ let current = socket.getPeerCertificate(true);
306
+ while (current?.raw instanceof Buffer) {
307
+ const key = current.raw.toString('base64');
308
+ if (seen.has(key)) break;
309
+ seen.add(key);
310
+ result.push(current.raw);
311
+ current = current.issuerCertificate;
312
+ }
313
+ return result;
314
+ }
315
+
316
+ async function fetchPresentedChain(origin: string): Promise<Buffer[]> {
317
+ const url = new URL(origin);
318
+ return new Promise((resolveChain, rejectChain) => {
319
+ const socket = tlsConnect({
320
+ host: url.hostname.replace(/^\[(.*)\]$/, '$1'),
321
+ port: url.port === '' ? 443 : Number(url.port),
322
+ rejectUnauthorized: false,
323
+ servername: url.hostname.replace(/^\[(.*)\]$/, '$1'),
324
+ });
325
+ const timeout = setTimeout(() => {
326
+ socket.destroy();
327
+ rejectChain(new InvitationArtifactTransportError());
328
+ }, 5_000);
329
+ socket.once('secureConnect', () => {
330
+ clearTimeout(timeout);
331
+ const chain = chainCertificates(socket);
332
+ socket.destroy();
333
+ resolveChain(chain);
334
+ });
335
+ socket.once('error', () => {
336
+ clearTimeout(timeout);
337
+ rejectChain(new InvitationArtifactTransportError());
338
+ });
339
+ });
340
+ }
341
+
342
+ /** Bootstrap a remote pinned transport from the CA chain presented by the server. */
343
+ export async function loadBorgServerTrustFromPresentedChain(
344
+ origin: string,
345
+ caSpkiSha256: string,
346
+ ): Promise<StagedBorgServerTrust> {
347
+ const chain = await fetchPresentedChain(origin);
348
+ let sawCa = false;
349
+ for (const raw of chain) {
350
+ try {
351
+ const certificate = new X509Certificate(raw);
352
+ if (!certificate.ca) continue;
353
+ sawCa = true;
354
+ const actual = createHash('sha256')
355
+ .update(certificate.publicKey.export({ type: 'spki', format: 'der' }))
356
+ .digest('hex');
357
+ if (actual !== caSpkiSha256) continue;
358
+ return stageBorgServerTrust(origin, pemCertificate(raw), `spki-sha256:${actual}`);
359
+ } catch {
360
+ // Ignore malformed chain members; the typed compatibility failure below is safer.
361
+ }
362
+ }
363
+ if (!sawCa) throw new InvitationArtifactCompatibilityError();
364
+ throw new InvitationArtifactTrustError();
365
+ }
366
+
367
+ export async function stageBorgServerTrust(
368
+ origin: string,
369
+ certificate: string,
370
+ identity: string,
371
+ ): Promise<StagedBorgServerTrust> {
372
+ const directory = remoteTrustDirectory(origin);
373
+ const parent = resolve(directory, '..');
374
+ await mkdir(parent, { recursive: true, mode: 0o700 });
375
+ const suffix = `${process.pid}-${Date.now()}`;
376
+ const temporaryDirectory = `${directory}.tmp-${suffix}`;
377
+ const temporaryCertificate = join(temporaryDirectory, 'ca.crt');
378
+ const temporaryConfig = join(temporaryDirectory, 'server.json');
379
+ try {
380
+ await mkdir(temporaryDirectory, { recursive: false, mode: 0o700 });
381
+ await Promise.all([
382
+ writeFile(temporaryCertificate, certificate, { mode: 0o600, flag: 'wx' }),
383
+ writeFile(temporaryConfig, JSON.stringify({ ca_spki_sha256: identity.replace(/^spki-sha256:/, '') }), { mode: 0o600, flag: 'wx' }),
384
+ ]);
385
+ await Promise.all([syncPath(temporaryCertificate), syncPath(temporaryConfig), syncPath(temporaryDirectory)]);
386
+ } catch {
387
+ await rm(temporaryDirectory, { recursive: true, force: true });
388
+ throw new InvitationArtifactStorageError();
389
+ }
390
+ let committed = false;
391
+ return {
392
+ identity,
393
+ fetchImpl: createPinnedServerFetch(origin, certificate),
394
+ commitTrust: async (activate) => {
395
+ if (committed) return;
396
+ const backupDirectory = `${directory}.backup-${suffix}`;
397
+ let backedUp = false;
398
+ try {
399
+ await rename(directory, backupDirectory).then(() => { backedUp = true; }).catch((error: unknown) => {
400
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
401
+ });
402
+ await rename(temporaryDirectory, directory);
403
+ await syncPath(resolve(directory, '..'));
404
+ await activate();
405
+ committed = true;
406
+ await rm(backupDirectory, { recursive: true, force: true }).catch(() => undefined);
407
+ } catch (error) {
408
+ await rm(directory, { recursive: true, force: true });
409
+ await rm(temporaryDirectory, { recursive: true, force: true });
410
+ if (backedUp) {
411
+ await rename(backupDirectory, directory);
412
+ await syncPath(resolve(directory, '..'));
413
+ }
414
+ throw new InvitationArtifactRecoveryError();
415
+ }
416
+ },
417
+ discardTrust: async () => {
418
+ if (committed) return;
419
+ await rm(temporaryDirectory, { recursive: true, force: true });
420
+ },
421
+ };
422
+ }
423
+
254
424
  export function __clearServerTrustCacheForTest(): void {
255
425
  trustCache.clear();
256
426
  }
427
+
428
+ export async function clearBorgServerTrust(origin: string): Promise<void> {
429
+ await rm(remoteTrustDirectory(origin), { recursive: true, force: true });
430
+ trustCache.delete(`${serverDataDirectory()}\0${origin}`);
431
+ }
@@ -20,6 +20,9 @@ export type TokenBackendName = 'file';
20
20
  export interface TokenBackend {
21
21
  readonly name: TokenBackendName;
22
22
  get(account: string): Promise<string | null>;
23
+ readonly entries?: () => Promise<Record<string, string>>;
24
+ /** Replace the complete account map in one backend transaction when available. */
25
+ readonly replaceAccounts?: (accounts: Record<string, string>) => Promise<void>;
23
26
  set(account: string, value: string): Promise<void>;
24
27
  delete(account: string): Promise<void>;
25
28
  }
@@ -87,6 +90,8 @@ export function makeFileBackend(
87
90
  atomicWrite0600(filePath, JSON.stringify({ version: 1, accounts }, null, 2) + '\n', options);
88
91
  return {
89
92
  name: 'file',
93
+ entries: load,
94
+ replaceAccounts: save,
90
95
  async get(account) {
91
96
  const accounts = await load();
92
97
  return Object.prototype.hasOwnProperty.call(accounts, account) ? accounts[account] : null;
@@ -17,6 +17,7 @@ export const KNOWN_SUBCOMMANDS = [
17
17
  'update',
18
18
  'assimilate',
19
19
  'reset-local-seat',
20
+ 'recover-enrollment',
20
21
  'spawn',
21
22
  'sync',
22
23
  'cleanup',