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/server-trust.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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 { access, mkdir, open, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { access, lstat, mkdir, open, rename, rm, unlink, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { request as httpsRequest } from 'node:https';
|
|
6
6
|
import { connect as tlsConnect } from 'node:tls';
|
|
7
7
|
import { homedir } from 'node:os';
|
|
@@ -15,6 +15,18 @@ import {
|
|
|
15
15
|
InvitationArtifactTransportError,
|
|
16
16
|
InvitationArtifactTrustError,
|
|
17
17
|
} from './invitation-artifact.js';
|
|
18
|
+
import { atomicWrite0600 } from './seat-store.js';
|
|
19
|
+
import { withEnrollmentOriginLock } from './enrollment-lock.js';
|
|
20
|
+
import {
|
|
21
|
+
finalizeAcceptedEnrollment,
|
|
22
|
+
getAcceptedEnrollmentMarker,
|
|
23
|
+
restoreAcceptedEnrollmentAccounts,
|
|
24
|
+
} from './config.js';
|
|
25
|
+
import type {
|
|
26
|
+
AcceptedEnrollmentMarker,
|
|
27
|
+
EnrollmentArtifactBinding,
|
|
28
|
+
EnrollmentTrustPointer,
|
|
29
|
+
} from './enrollment-types.js';
|
|
18
30
|
|
|
19
31
|
// CR5 TLS LATTICE: OpenSSL/Node TLS certificate-verification error codes. A raw
|
|
20
32
|
// CA / cert-chain / SAN failure from the pinned transport is a potential MITM and
|
|
@@ -57,7 +69,11 @@ export interface BorgServerTrust {
|
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
export interface StagedBorgServerTrust extends BorgServerTrust {
|
|
60
|
-
|
|
72
|
+
generationId: string;
|
|
73
|
+
commitTrust: (activate: (context: {
|
|
74
|
+
generationId: string;
|
|
75
|
+
previousPointer: EnrollmentTrustPointer | null;
|
|
76
|
+
}) => Promise<void>) => Promise<void>;
|
|
61
77
|
discardTrust: () => Promise<void>;
|
|
62
78
|
}
|
|
63
79
|
|
|
@@ -66,6 +82,24 @@ interface ServerTrustConfig {
|
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
const trustCache = new Map<string, Promise<BorgServerTrust>>();
|
|
85
|
+
type EnrollmentTrustFaultPoint =
|
|
86
|
+
| 'after-account-activation'
|
|
87
|
+
| 'after-pointer-publish'
|
|
88
|
+
| 'before-marker-finalize'
|
|
89
|
+
| 'during-pointer-rollback';
|
|
90
|
+
let enrollmentTrustFaultsForTest = new Set<EnrollmentTrustFaultPoint>();
|
|
91
|
+
|
|
92
|
+
function injectEnrollmentTrustFault(point: EnrollmentTrustFaultPoint): void {
|
|
93
|
+
if (enrollmentTrustFaultsForTest.has(point)) throw new Error(`injected ${point} failure`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function invalidateOriginTrustCache(origin: string): void {
|
|
97
|
+
for (const key of trustCache.keys()) {
|
|
98
|
+
if (key === origin || key.startsWith(`${origin}\0`) || key.endsWith(`\0${origin}`)) {
|
|
99
|
+
trustCache.delete(key);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
69
103
|
|
|
70
104
|
function serverDataDirectory(): string {
|
|
71
105
|
return resolve(process.env.BORG_SERVER_DATA_DIR ?? join(homedir(), '.borg', 'server'));
|
|
@@ -76,6 +110,24 @@ function remoteTrustDirectory(origin: string): string {
|
|
|
76
110
|
return join(homedir(), '.borg', 'server-trust', key);
|
|
77
111
|
}
|
|
78
112
|
|
|
113
|
+
function trustGenerationsDirectory(origin: string): string {
|
|
114
|
+
return join(remoteTrustDirectory(origin), 'generations');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function trustGenerationDirectory(origin: string, generationId: string): string {
|
|
118
|
+
return join(trustGenerationsDirectory(origin), generationId);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function trustPointerPath(origin: string): string {
|
|
122
|
+
return join(remoteTrustDirectory(origin), 'current.json');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function trustGenerationId(origin: string, certificate: string, identity: string): string {
|
|
126
|
+
return createHash('sha256')
|
|
127
|
+
.update(origin).update('\0').update(identity).update('\0').update(certificate)
|
|
128
|
+
.digest('hex');
|
|
129
|
+
}
|
|
130
|
+
|
|
79
131
|
async function trustFilesExist(directory: string): Promise<boolean> {
|
|
80
132
|
try {
|
|
81
133
|
await Promise.all([access(join(directory, 'ca.crt')), access(join(directory, 'server.json'))]);
|
|
@@ -85,6 +137,11 @@ async function trustFilesExist(directory: string): Promise<boolean> {
|
|
|
85
137
|
}
|
|
86
138
|
}
|
|
87
139
|
|
|
140
|
+
async function remoteTrustStateExists(origin: string): Promise<boolean> {
|
|
141
|
+
try { await access(trustPointerPath(origin)); return true; } catch { /* continue */ }
|
|
142
|
+
return trustFilesExist(remoteTrustDirectory(origin));
|
|
143
|
+
}
|
|
144
|
+
|
|
88
145
|
async function readTrustFile(path: string): Promise<string> {
|
|
89
146
|
let handle;
|
|
90
147
|
try {
|
|
@@ -109,6 +166,16 @@ async function readTrustFile(path: string): Promise<string> {
|
|
|
109
166
|
}
|
|
110
167
|
}
|
|
111
168
|
|
|
169
|
+
async function assertPrivateDirectory(path: string): Promise<void> {
|
|
170
|
+
const metadata = await lstat(path);
|
|
171
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
|
172
|
+
throw new Error('Borg server trust directories must be private real directories');
|
|
173
|
+
}
|
|
174
|
+
if (typeof process.getuid === 'function' && metadata.uid !== process.getuid()) {
|
|
175
|
+
throw new Error('Borg server trust directories must be owned by the current user');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
112
179
|
async function syncPath(path: string): Promise<void> {
|
|
113
180
|
const handle = await open(path, 'r');
|
|
114
181
|
try {
|
|
@@ -153,6 +220,119 @@ function verifyCaIdentity(certificate: string, expected: string): string {
|
|
|
153
220
|
return `spki-sha256:${actual.toString('hex')}`;
|
|
154
221
|
}
|
|
155
222
|
|
|
223
|
+
async function readGeneration(
|
|
224
|
+
origin: string,
|
|
225
|
+
generationId: string,
|
|
226
|
+
): Promise<{ certificate: string; identity: string }> {
|
|
227
|
+
if (!/^[a-f0-9]{64}$/.test(generationId)) throw new Error('Borg server trust pointer is invalid');
|
|
228
|
+
const directory = trustGenerationDirectory(origin, generationId);
|
|
229
|
+
const [certificate, configText] = await Promise.all([
|
|
230
|
+
readTrustFile(join(directory, 'ca.crt')),
|
|
231
|
+
readTrustFile(join(directory, 'server.json')),
|
|
232
|
+
]);
|
|
233
|
+
const config = decodeTrustConfig(configText);
|
|
234
|
+
const identity = verifyCaIdentity(certificate, config.ca_spki_sha256);
|
|
235
|
+
if (trustGenerationId(origin, certificate, identity) !== generationId) {
|
|
236
|
+
throw new Error('Borg server trust generation digest is invalid');
|
|
237
|
+
}
|
|
238
|
+
return { certificate, identity };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function readTrustPointer(origin: string): Promise<EnrollmentTrustPointer | null> {
|
|
242
|
+
try { await assertPrivateDirectory(remoteTrustDirectory(origin)); } catch (error) {
|
|
243
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
let raw: string;
|
|
247
|
+
try {
|
|
248
|
+
raw = await readTrustFile(trustPointerPath(origin));
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (error instanceof Error && /were not found/.test(error.message)) return null;
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
let pointer: Partial<EnrollmentTrustPointer>;
|
|
254
|
+
try { pointer = JSON.parse(raw) as Partial<EnrollmentTrustPointer>; } catch {
|
|
255
|
+
throw new Error('Borg server trust pointer is invalid');
|
|
256
|
+
}
|
|
257
|
+
if (
|
|
258
|
+
pointer.version !== 1 || pointer.origin !== origin ||
|
|
259
|
+
typeof pointer.generationId !== 'string' || !/^[a-f0-9]{64}$/.test(pointer.generationId) ||
|
|
260
|
+
typeof pointer.trustIdentity !== 'string'
|
|
261
|
+
) throw new Error('Borg server trust pointer is invalid');
|
|
262
|
+
return pointer as EnrollmentTrustPointer;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function publishTrustPointer(pointer: EnrollmentTrustPointer): Promise<void> {
|
|
266
|
+
const root = remoteTrustDirectory(pointer.origin);
|
|
267
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
268
|
+
await assertPrivateDirectory(root);
|
|
269
|
+
await atomicWrite0600(trustPointerPath(pointer.origin), `${JSON.stringify(pointer)}\n`);
|
|
270
|
+
invalidateOriginTrustCache(pointer.origin);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function removeTrustPointer(origin: string): Promise<void> {
|
|
274
|
+
try { await unlink(trustPointerPath(origin)); } catch (error) {
|
|
275
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
276
|
+
}
|
|
277
|
+
await syncPath(remoteTrustDirectory(origin));
|
|
278
|
+
invalidateOriginTrustCache(origin);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function publishGeneration(
|
|
282
|
+
origin: string,
|
|
283
|
+
certificate: string,
|
|
284
|
+
identity: string,
|
|
285
|
+
): Promise<string> {
|
|
286
|
+
const generationId = trustGenerationId(origin, certificate, identity);
|
|
287
|
+
const generations = trustGenerationsDirectory(origin);
|
|
288
|
+
const target = trustGenerationDirectory(origin, generationId);
|
|
289
|
+
await mkdir(generations, { recursive: true, mode: 0o700 });
|
|
290
|
+
await assertPrivateDirectory(resolve(remoteTrustDirectory(origin), '..'));
|
|
291
|
+
await assertPrivateDirectory(remoteTrustDirectory(origin));
|
|
292
|
+
await assertPrivateDirectory(generations);
|
|
293
|
+
if (await trustFilesExist(target)) {
|
|
294
|
+
const verified = await readGeneration(origin, generationId);
|
|
295
|
+
if (verified.identity !== identity) throw new Error('Borg server trust generation identity changed');
|
|
296
|
+
return generationId;
|
|
297
|
+
}
|
|
298
|
+
const temporary = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
299
|
+
try {
|
|
300
|
+
await mkdir(temporary, { mode: 0o700 });
|
|
301
|
+
const certificatePath = join(temporary, 'ca.crt');
|
|
302
|
+
const configPath = join(temporary, 'server.json');
|
|
303
|
+
await writeFile(certificatePath, certificate, { mode: 0o600, flag: 'wx' });
|
|
304
|
+
await syncPath(certificatePath);
|
|
305
|
+
await writeFile(configPath, JSON.stringify({
|
|
306
|
+
ca_spki_sha256: identity.replace(/^spki-sha256:/, ''),
|
|
307
|
+
}), { mode: 0o600, flag: 'wx' });
|
|
308
|
+
await syncPath(configPath);
|
|
309
|
+
await syncPath(temporary);
|
|
310
|
+
await rename(temporary, target);
|
|
311
|
+
await syncPath(generations);
|
|
312
|
+
const verified = await readGeneration(origin, generationId);
|
|
313
|
+
if (verified.identity !== identity) throw new Error('Borg server trust generation identity changed');
|
|
314
|
+
return generationId;
|
|
315
|
+
} catch (error) {
|
|
316
|
+
await rm(temporary, { recursive: true, force: true }).catch(() => undefined);
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function migrateLegacyRemoteTrust(origin: string): Promise<EnrollmentTrustPointer | null> {
|
|
322
|
+
const directory = remoteTrustDirectory(origin);
|
|
323
|
+
if (!await trustFilesExist(directory)) return null;
|
|
324
|
+
const [certificate, configText] = await Promise.all([
|
|
325
|
+
readTrustFile(join(directory, 'ca.crt')),
|
|
326
|
+
readTrustFile(join(directory, 'server.json')),
|
|
327
|
+
]);
|
|
328
|
+
const config = decodeTrustConfig(configText);
|
|
329
|
+
const identity = verifyCaIdentity(certificate, config.ca_spki_sha256);
|
|
330
|
+
const generationId = await publishGeneration(origin, certificate, identity);
|
|
331
|
+
const pointer: EnrollmentTrustPointer = { version: 1, origin, generationId, trustIdentity: identity };
|
|
332
|
+
await publishTrustPointer(pointer);
|
|
333
|
+
return pointer;
|
|
334
|
+
}
|
|
335
|
+
|
|
156
336
|
function responseHeaders(rawHeaders: string[]): Headers {
|
|
157
337
|
const headers = new Headers();
|
|
158
338
|
for (let index = 0; index < rawHeaders.length; index += 2) {
|
|
@@ -214,6 +394,10 @@ export function createPinnedServerFetch(origin: string, caCertificate: string):
|
|
|
214
394
|
headers: Object.fromEntries(headers.entries()),
|
|
215
395
|
ca: caCertificate,
|
|
216
396
|
rejectUnauthorized: true,
|
|
397
|
+
// The pinned CA is the authority boundary. OpenSSL still validates
|
|
398
|
+
// the complete chain; only the leaf's address SAN check is skipped
|
|
399
|
+
// so a DHCP change does not require reissuing the leaf certificate.
|
|
400
|
+
checkServerIdentity: () => undefined,
|
|
217
401
|
minVersion: 'TLSv1.3',
|
|
218
402
|
}, (incoming) => {
|
|
219
403
|
const status = incoming.statusCode ?? 500;
|
|
@@ -266,32 +450,48 @@ export async function loadBorgServerTrust(
|
|
|
266
450
|
origin: string,
|
|
267
451
|
dataDirectory?: string,
|
|
268
452
|
): Promise<BorgServerTrust> {
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
pending = (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
fetchImpl: createPinnedServerFetch(origin, certificate),
|
|
289
|
-
};
|
|
290
|
-
})();
|
|
291
|
-
trustCache.set(key, pending);
|
|
292
|
-
pending.catch(() => trustCache.delete(key));
|
|
453
|
+
const useRemoteState = dataDirectory === undefined && await remoteTrustStateExists(origin);
|
|
454
|
+
if (dataDirectory !== undefined || !useRemoteState && await trustFilesExist(serverDataDirectory())) {
|
|
455
|
+
const directory = dataDirectory ?? serverDataDirectory();
|
|
456
|
+
const key = `legacy\0${directory}\0${origin}`;
|
|
457
|
+
let pending = trustCache.get(key);
|
|
458
|
+
if (!pending) {
|
|
459
|
+
pending = (async () => {
|
|
460
|
+
const [certificate, configText] = await Promise.all([
|
|
461
|
+
readTrustFile(join(directory, 'ca.crt')),
|
|
462
|
+
readTrustFile(join(directory, 'server.json')),
|
|
463
|
+
]);
|
|
464
|
+
const config = decodeTrustConfig(configText);
|
|
465
|
+
const identity = verifyCaIdentity(certificate, config.ca_spki_sha256);
|
|
466
|
+
return { identity, fetchImpl: createPinnedServerFetch(origin, certificate) };
|
|
467
|
+
})();
|
|
468
|
+
trustCache.set(key, pending);
|
|
469
|
+
pending.catch(() => trustCache.delete(key));
|
|
470
|
+
}
|
|
471
|
+
return pending;
|
|
293
472
|
}
|
|
294
|
-
return
|
|
473
|
+
return withEnrollmentOriginLock(origin, async () => {
|
|
474
|
+
await recoverAcceptedEnrollment(origin);
|
|
475
|
+
const pointer = await readTrustPointer(origin) ?? await migrateLegacyRemoteTrust(origin);
|
|
476
|
+
if (!pointer) throw new Error('Borg server trust files were not found');
|
|
477
|
+
const key = `${origin}\0${pointer.generationId}\0${pointer.trustIdentity}`;
|
|
478
|
+
let pending = trustCache.get(key);
|
|
479
|
+
if (!pending) {
|
|
480
|
+
pending = (async () => {
|
|
481
|
+
const generation = await readGeneration(origin, pointer.generationId);
|
|
482
|
+
if (generation.identity !== pointer.trustIdentity) {
|
|
483
|
+
throw new Error('Borg server trust pointer identity does not match its generation');
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
identity: generation.identity,
|
|
487
|
+
fetchImpl: createPinnedServerFetch(origin, generation.certificate),
|
|
488
|
+
};
|
|
489
|
+
})();
|
|
490
|
+
trustCache.set(key, pending);
|
|
491
|
+
pending.catch(() => trustCache.delete(key));
|
|
492
|
+
}
|
|
493
|
+
return pending;
|
|
494
|
+
});
|
|
295
495
|
}
|
|
296
496
|
|
|
297
497
|
function pemCertificate(raw: Buffer): string {
|
|
@@ -369,63 +569,189 @@ export async function stageBorgServerTrust(
|
|
|
369
569
|
certificate: string,
|
|
370
570
|
identity: string,
|
|
371
571
|
): Promise<StagedBorgServerTrust> {
|
|
372
|
-
|
|
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');
|
|
572
|
+
let generationId: string;
|
|
379
573
|
try {
|
|
380
|
-
await
|
|
381
|
-
|
|
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)]);
|
|
574
|
+
generationId = await withEnrollmentOriginLock(origin, () =>
|
|
575
|
+
publishGeneration(origin, certificate, identity));
|
|
386
576
|
} catch {
|
|
387
|
-
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
388
577
|
throw new InvitationArtifactStorageError();
|
|
389
578
|
}
|
|
390
|
-
|
|
579
|
+
return stagedTrustFromGeneration(origin, generationId, identity, certificate);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function stagedTrustFromGeneration(
|
|
583
|
+
origin: string,
|
|
584
|
+
generationId: string,
|
|
585
|
+
identity: string,
|
|
586
|
+
certificate: string,
|
|
587
|
+
): StagedBorgServerTrust {
|
|
588
|
+
let finalized = false;
|
|
391
589
|
return {
|
|
392
590
|
identity,
|
|
591
|
+
generationId,
|
|
393
592
|
fetchImpl: createPinnedServerFetch(origin, certificate),
|
|
394
593
|
commitTrust: async (activate) => {
|
|
395
|
-
if (
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
594
|
+
if (finalized) return;
|
|
595
|
+
await withEnrollmentOriginLock(origin, async () => {
|
|
596
|
+
const previousPointer = await readTrustPointer(origin) ?? await migrateLegacyRemoteTrust(origin);
|
|
597
|
+
let activated = false;
|
|
598
|
+
try {
|
|
599
|
+
await activate({ generationId, previousPointer });
|
|
600
|
+
const accepted = await getAcceptedEnrollmentMarker(origin);
|
|
601
|
+
if (
|
|
602
|
+
!accepted || accepted.generationId !== generationId ||
|
|
603
|
+
accepted.trustIdentity !== identity
|
|
604
|
+
) throw new Error('Borg enrollment activation did not publish its accepted marker');
|
|
605
|
+
activated = true;
|
|
606
|
+
injectEnrollmentTrustFault('after-account-activation');
|
|
607
|
+
await publishTrustPointer({ version: 1, origin, generationId, trustIdentity: identity });
|
|
608
|
+
injectEnrollmentTrustFault('after-pointer-publish');
|
|
609
|
+
const verified = await readGeneration(origin, generationId);
|
|
610
|
+
if (verified.identity !== identity) throw new Error('published Borg trust generation changed');
|
|
611
|
+
injectEnrollmentTrustFault('before-marker-finalize');
|
|
612
|
+
await finalizeAcceptedEnrollment(origin, identity, generationId);
|
|
613
|
+
finalized = true;
|
|
614
|
+
} catch (error) {
|
|
615
|
+
let marker;
|
|
616
|
+
try { marker = await getAcceptedEnrollmentMarker(origin); } catch {
|
|
617
|
+
throw new InvitationArtifactRecoveryError();
|
|
618
|
+
}
|
|
619
|
+
// Activation is one atomic account-map replacement, but a durable
|
|
620
|
+
// backend may report an error after rename and before/while syncing
|
|
621
|
+
// the parent directory. The accepted marker, not the callback's
|
|
622
|
+
// return value, is therefore the authority for whether rollback is
|
|
623
|
+
// required.
|
|
624
|
+
if (!marker && !activated) throw error;
|
|
625
|
+
if (!marker) throw new InvitationArtifactRecoveryError();
|
|
626
|
+
try {
|
|
627
|
+
await restoreEnrollmentPointer(marker);
|
|
628
|
+
await restoreAcceptedEnrollmentAccounts(marker);
|
|
629
|
+
} catch {
|
|
630
|
+
throw new InvitationArtifactRecoveryError();
|
|
631
|
+
}
|
|
632
|
+
throw new InvitationArtifactRecoveryError();
|
|
413
633
|
}
|
|
414
|
-
|
|
415
|
-
}
|
|
634
|
+
});
|
|
416
635
|
},
|
|
417
636
|
discardTrust: async () => {
|
|
418
|
-
if (
|
|
419
|
-
await
|
|
637
|
+
if (finalized) return;
|
|
638
|
+
await withEnrollmentOriginLock(origin, async () => {
|
|
639
|
+
const pointer = await readTrustPointer(origin);
|
|
640
|
+
if (pointer?.generationId === generationId) return;
|
|
641
|
+
await rm(trustGenerationDirectory(origin, generationId), { recursive: true, force: true });
|
|
642
|
+
await syncPath(trustGenerationsDirectory(origin)).catch(() => undefined);
|
|
643
|
+
});
|
|
420
644
|
},
|
|
421
645
|
};
|
|
422
646
|
}
|
|
423
647
|
|
|
648
|
+
async function restoreEnrollmentPointer(marker: {
|
|
649
|
+
origin: string;
|
|
650
|
+
previousPointer: EnrollmentTrustPointer | null;
|
|
651
|
+
}): Promise<void> {
|
|
652
|
+
injectEnrollmentTrustFault('during-pointer-rollback');
|
|
653
|
+
if (marker.previousPointer === null) {
|
|
654
|
+
await removeTrustPointer(marker.origin);
|
|
655
|
+
if (await readTrustPointer(marker.origin) !== null) throw new Error('Borg trust pointer rollback failed');
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const previous = await readGeneration(marker.origin, marker.previousPointer.generationId);
|
|
659
|
+
if (previous.identity !== marker.previousPointer.trustIdentity) {
|
|
660
|
+
throw new Error('Borg previous trust generation is invalid');
|
|
661
|
+
}
|
|
662
|
+
await publishTrustPointer(marker.previousPointer);
|
|
663
|
+
const restored = await readTrustPointer(marker.origin);
|
|
664
|
+
if (restored?.generationId !== marker.previousPointer.generationId) {
|
|
665
|
+
throw new Error('Borg trust pointer rollback failed');
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function recoverAcceptedEnrollment(origin: string): Promise<void> {
|
|
670
|
+
const marker = await getAcceptedEnrollmentMarker(origin);
|
|
671
|
+
if (!marker) return;
|
|
672
|
+
try {
|
|
673
|
+
const generation = await readGeneration(origin, marker.generationId);
|
|
674
|
+
if (generation.identity !== marker.trustIdentity) throw new Error('Borg enrollment generation identity changed');
|
|
675
|
+
await publishTrustPointer({
|
|
676
|
+
version: 1,
|
|
677
|
+
origin,
|
|
678
|
+
generationId: marker.generationId,
|
|
679
|
+
trustIdentity: marker.trustIdentity,
|
|
680
|
+
});
|
|
681
|
+
await finalizeAcceptedEnrollment(origin, marker.trustIdentity, marker.generationId);
|
|
682
|
+
} catch {
|
|
683
|
+
try {
|
|
684
|
+
await restoreEnrollmentPointer(marker);
|
|
685
|
+
await restoreAcceptedEnrollmentAccounts(marker);
|
|
686
|
+
} catch {
|
|
687
|
+
throw new InvitationArtifactRecoveryError();
|
|
688
|
+
}
|
|
689
|
+
throw new InvitationArtifactRecoveryError();
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Verify the exact persisted artifact binding before any resumed network I/O. */
|
|
694
|
+
export async function loadStagedBorgServerTrust(
|
|
695
|
+
origin: string,
|
|
696
|
+
binding: EnrollmentArtifactBinding,
|
|
697
|
+
): Promise<StagedBorgServerTrust> {
|
|
698
|
+
return withEnrollmentOriginLock(origin, async () => {
|
|
699
|
+
if (
|
|
700
|
+
binding.endpoint !== origin || binding.trustIdentity !== `spki-sha256:${binding.caSpkiSha256}`
|
|
701
|
+
) throw new InvitationArtifactTrustError();
|
|
702
|
+
let generation: { certificate: string; identity: string };
|
|
703
|
+
try { generation = await readGeneration(origin, binding.stagedGenerationId); } catch {
|
|
704
|
+
throw new InvitationArtifactRecoveryError();
|
|
705
|
+
}
|
|
706
|
+
if (generation.identity !== binding.trustIdentity) throw new InvitationArtifactTrustError();
|
|
707
|
+
return stagedTrustFromGeneration(
|
|
708
|
+
origin,
|
|
709
|
+
binding.stagedGenerationId,
|
|
710
|
+
generation.identity,
|
|
711
|
+
generation.certificate,
|
|
712
|
+
);
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Explicit operator recovery restores the exact accepted journal that was reviewed. */
|
|
717
|
+
export async function restoreBorgServerEnrollment(expected: AcceptedEnrollmentMarker): Promise<boolean> {
|
|
718
|
+
return withEnrollmentOriginLock(expected.origin, async () => {
|
|
719
|
+
const marker = await getAcceptedEnrollmentMarker(expected.origin);
|
|
720
|
+
if (!marker || JSON.stringify(marker) !== JSON.stringify(expected)) return false;
|
|
721
|
+
await restoreEnrollmentPointer(expected);
|
|
722
|
+
await restoreAcceptedEnrollmentAccounts(expected);
|
|
723
|
+
return true;
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export async function clearStagedBorgServerTrust(
|
|
728
|
+
origin: string,
|
|
729
|
+
generationId: string | undefined,
|
|
730
|
+
): Promise<void> {
|
|
731
|
+
if (!generationId) return;
|
|
732
|
+
await withEnrollmentOriginLock(origin, async () => {
|
|
733
|
+
const pointer = await readTrustPointer(origin);
|
|
734
|
+
if (pointer?.generationId === generationId) {
|
|
735
|
+
throw new InvitationArtifactRecoveryError();
|
|
736
|
+
}
|
|
737
|
+
await rm(trustGenerationDirectory(origin, generationId), { recursive: true, force: true });
|
|
738
|
+
await syncPath(trustGenerationsDirectory(origin)).catch(() => undefined);
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
424
742
|
export function __clearServerTrustCacheForTest(): void {
|
|
425
743
|
trustCache.clear();
|
|
426
744
|
}
|
|
427
745
|
|
|
746
|
+
export function __setEnrollmentTrustFaultForTest(
|
|
747
|
+
points: EnrollmentTrustFaultPoint | EnrollmentTrustFaultPoint[] | null,
|
|
748
|
+
): void {
|
|
749
|
+
enrollmentTrustFaultsForTest = new Set(points === null ? [] : Array.isArray(points) ? points : [points]);
|
|
750
|
+
}
|
|
751
|
+
|
|
428
752
|
export async function clearBorgServerTrust(origin: string): Promise<void> {
|
|
429
|
-
await
|
|
430
|
-
|
|
753
|
+
await withEnrollmentOriginLock(origin, async () => {
|
|
754
|
+
await rm(remoteTrustDirectory(origin), { recursive: true, force: true });
|
|
755
|
+
invalidateOriginTrustCache(origin);
|
|
756
|
+
});
|
|
431
757
|
}
|
package/src/tool-manifest.ts
CHANGED
|
@@ -348,9 +348,9 @@ export const TOOL_MANIFEST: ToolManifestEntry[] = [
|
|
|
348
348
|
properties: {
|
|
349
349
|
name: {
|
|
350
350
|
type: 'string',
|
|
351
|
-
description: 'Cube name (
|
|
352
|
-
pattern: '^[
|
|
353
|
-
maxLength:
|
|
351
|
+
description: 'Cube name (starts with a letter or digit; letters, digits, spaces, dots, underscores, or hyphens; max 120 UTF-8 bytes).',
|
|
352
|
+
pattern: '^[A-Za-z0-9][A-Za-z0-9 ._-]*$',
|
|
353
|
+
maxLength: 120,
|
|
354
354
|
},
|
|
355
355
|
cube_directive: { type: 'string', description: 'Project-specific Markdown shown to every drone when it refreshes cube context.' },
|
|
356
356
|
template: {
|
|
@@ -363,17 +363,11 @@ export const TOOL_MANIFEST: ToolManifestEntry[] = [
|
|
|
363
363
|
},
|
|
364
364
|
{
|
|
365
365
|
name: 'borg_update-cube',
|
|
366
|
-
description: 'Update a cube\'s
|
|
366
|
+
description: 'Update a cube\'s cube_directive and/or message_taxonomy. Pass only what changes.',
|
|
367
367
|
inputSchema: {
|
|
368
368
|
type: 'object',
|
|
369
369
|
properties: {
|
|
370
370
|
cube_id: { type: 'string', description: 'UUID of the cube to update.' },
|
|
371
|
-
name: {
|
|
372
|
-
type: 'string',
|
|
373
|
-
description: 'New name (optional). Lowercase letters, digits, hyphens; max 64 chars.',
|
|
374
|
-
pattern: '^[a-z0-9-]+$',
|
|
375
|
-
maxLength: 64,
|
|
376
|
-
},
|
|
377
371
|
cube_directive: { type: 'string', description: 'New cube directive markdown (optional).' },
|
|
378
372
|
message_taxonomy: {
|
|
379
373
|
type: 'array',
|