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.
- package/dist/assimilate-cmd.d.ts +13 -0
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +177 -11
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +26 -1
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/assimilate-welcome.d.ts.map +1 -1
- package/dist/assimilate-welcome.js +1 -0
- package/dist/assimilate-welcome.js.map +1 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +27 -0
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.d.ts +1 -0
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +13 -0
- package/dist/cli-help.js.map +1 -1
- package/dist/config.d.ts +11 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +169 -13
- package/dist/config.js.map +1 -1
- package/dist/invitation-artifact.d.ts +33 -0
- package/dist/invitation-artifact.d.ts.map +1 -0
- package/dist/invitation-artifact.js +89 -0
- package/dist/invitation-artifact.js.map +1 -0
- package/dist/launch-gate.d.ts +5 -4
- package/dist/launch-gate.d.ts.map +1 -1
- package/dist/launch-gate.js +7 -8
- package/dist/launch-gate.js.map +1 -1
- package/dist/parse-assimilate-args.d.ts.map +1 -1
- package/dist/parse-assimilate-args.js +9 -3
- package/dist/parse-assimilate-args.js.map +1 -1
- package/dist/recover-enrollment-cmd.d.ts +17 -0
- package/dist/recover-enrollment-cmd.d.ts.map +1 -0
- package/dist/recover-enrollment-cmd.js +62 -0
- package/dist/recover-enrollment-cmd.js.map +1 -0
- package/dist/regen-format.d.ts +9 -0
- package/dist/regen-format.d.ts.map +1 -1
- package/dist/regen-format.js +15 -0
- package/dist/regen-format.js.map +1 -1
- package/dist/regen.js +27 -3
- package/dist/regen.js.map +1 -1
- package/dist/seat-store.d.ts.map +1 -1
- package/dist/seat-store.js +7 -0
- package/dist/seat-store.js.map +1 -1
- package/dist/server-facade.d.ts +1 -1
- package/dist/server-facade.d.ts.map +1 -1
- package/dist/server-facade.js +13 -2
- package/dist/server-facade.js.map +1 -1
- package/dist/server-handshake.d.ts +19 -3
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +86 -9
- package/dist/server-handshake.js.map +1 -1
- package/dist/server-trust.d.ts +8 -0
- package/dist/server-trust.d.ts.map +1 -1
- package/dist/server-trust.js +165 -5
- package/dist/server-trust.js.map +1 -1
- package/dist/token-store.d.ts +3 -0
- package/dist/token-store.d.ts.map +1 -1
- package/dist/token-store.js +2 -0
- package/dist/token-store.js.map +1 -1
- package/dist/unknown-subcommand.d.ts +1 -1
- package/dist/unknown-subcommand.d.ts.map +1 -1
- package/dist/unknown-subcommand.js +1 -0
- package/dist/unknown-subcommand.js.map +1 -1
- package/docs/EXTRACTION_PROVENANCE.md +11 -7
- package/docs/LOCAL_SERVER.md +12 -3
- package/docs/RELEASING.md +47 -7
- package/package.json +2 -2
- package/src/assimilate-cmd.ts +231 -26
- package/src/assimilate-deps.ts +26 -0
- package/src/assimilate-welcome.ts +1 -0
- package/src/claude.ts +22 -0
- package/src/cli-help.ts +16 -0
- package/src/config.ts +165 -10
- package/src/invitation-artifact.ts +116 -0
- package/src/launch-gate.ts +7 -8
- package/src/parse-assimilate-args.ts +11 -4
- package/src/recover-enrollment-cmd.ts +63 -0
- package/src/regen-format.ts +25 -0
- package/src/regen.ts +30 -2
- package/src/seat-store.ts +6 -0
- package/src/server-facade.ts +13 -2
- package/src/server-handshake.ts +113 -6
- package/src/server-trust.ts +180 -5
- package/src/token-store.ts +5 -0
- 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 {
|
|
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 =
|
|
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;
|
package/src/server-facade.ts
CHANGED
|
@@ -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 = [
|
|
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
|
}
|
package/src/server-handshake.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/server-trust.ts
CHANGED
|
@@ -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
|
|
267
|
+
dataDirectory?: string,
|
|
232
268
|
): Promise<BorgServerTrust> {
|
|
233
|
-
const
|
|
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(
|
|
239
|
-
readTrustFile(join(
|
|
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
|
+
}
|
package/src/token-store.ts
CHANGED
|
@@ -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;
|