borgmcp-server 0.1.1

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 (71) hide show
  1. package/LICENSE +105 -0
  2. package/NOTICE +8 -0
  3. package/README.md +119 -0
  4. package/SECURITY.md +38 -0
  5. package/THIRD_PARTY_NOTICES.md +35 -0
  6. package/dist/bootstrap.d.ts +18 -0
  7. package/dist/bootstrap.js +104 -0
  8. package/dist/bootstrap.js.map +1 -0
  9. package/dist/cli.d.ts +7 -0
  10. package/dist/cli.js +96 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/coordination-api.d.ts +25 -0
  13. package/dist/coordination-api.js +457 -0
  14. package/dist/coordination-api.js.map +1 -0
  15. package/dist/credentials.d.ts +58 -0
  16. package/dist/credentials.js +244 -0
  17. package/dist/credentials.js.map +1 -0
  18. package/dist/enrollment.d.ts +17 -0
  19. package/dist/enrollment.js +122 -0
  20. package/dist/enrollment.js.map +1 -0
  21. package/dist/https-server.d.ts +94 -0
  22. package/dist/https-server.js +814 -0
  23. package/dist/https-server.js.map +1 -0
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.js +2 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/main.d.ts +3 -0
  28. package/dist/main.js +67 -0
  29. package/dist/main.js.map +1 -0
  30. package/dist/migrations.d.ts +8 -0
  31. package/dist/migrations.js +366 -0
  32. package/dist/migrations.js.map +1 -0
  33. package/dist/network-policy.d.ts +13 -0
  34. package/dist/network-policy.js +49 -0
  35. package/dist/network-policy.js.map +1 -0
  36. package/dist/operator-error.d.ts +3 -0
  37. package/dist/operator-error.js +63 -0
  38. package/dist/operator-error.js.map +1 -0
  39. package/dist/principal.d.ts +32 -0
  40. package/dist/principal.js +64 -0
  41. package/dist/principal.js.map +1 -0
  42. package/dist/protocol-draft.d.ts +2 -0
  43. package/dist/protocol-draft.js +31 -0
  44. package/dist/protocol-draft.js.map +1 -0
  45. package/dist/service.d.ts +61 -0
  46. package/dist/service.js +455 -0
  47. package/dist/service.js.map +1 -0
  48. package/dist/start-options.d.ts +2 -0
  49. package/dist/start-options.js +46 -0
  50. package/dist/start-options.js.map +1 -0
  51. package/dist/store.d.ts +327 -0
  52. package/dist/store.js +1729 -0
  53. package/dist/store.js.map +1 -0
  54. package/npm-shrinkwrap.json +1942 -0
  55. package/package.json +60 -0
  56. package/src/bootstrap.ts +127 -0
  57. package/src/cli.ts +102 -0
  58. package/src/coordination-api.ts +508 -0
  59. package/src/credentials.ts +319 -0
  60. package/src/enrollment.ts +156 -0
  61. package/src/https-server.ts +962 -0
  62. package/src/index.ts +3 -0
  63. package/src/main.ts +73 -0
  64. package/src/migrations.ts +394 -0
  65. package/src/network-policy.ts +65 -0
  66. package/src/operator-error.ts +97 -0
  67. package/src/principal.ts +106 -0
  68. package/src/protocol-draft.ts +32 -0
  69. package/src/service.ts +525 -0
  70. package/src/start-options.ts +46 -0
  71. package/src/store.ts +2316 -0
@@ -0,0 +1,319 @@
1
+ import {
2
+ createHmac,
3
+ randomBytes,
4
+ randomUUID,
5
+ timingSafeEqual,
6
+ } from "node:crypto";
7
+
8
+ import {
9
+ clientPrincipal,
10
+ droneSessionPrincipal,
11
+ type Principal,
12
+ } from "./principal.js";
13
+ import type {
14
+ CredentialStore,
15
+ DigestPair,
16
+ ScopedStore,
17
+ SeatAttachRecord,
18
+ } from "./store.js";
19
+ import { operatorErrors } from "./operator-error.js";
20
+
21
+ const tokenPattern = /^[A-Za-z0-9_-]{43,1024}$/u;
22
+ const dummyVerifier = Buffer.alloc(32);
23
+ type CredentialPurpose = "recovery" | "invitation" | "client" | "drone-session";
24
+
25
+ export interface EnrollmentRequest {
26
+ readonly invitation: string;
27
+ readonly retryKey: string;
28
+ readonly clientCredential: string;
29
+ readonly clientName?: string;
30
+ }
31
+
32
+ export interface EnrollmentResponse {
33
+ readonly purpose: "owner" | "client";
34
+ readonly clientId: string;
35
+ readonly serverCapabilities: readonly [] | readonly ["create_cube"];
36
+ }
37
+
38
+ export interface SeatAttachResponse extends SeatAttachRecord {
39
+ readonly credential: string;
40
+ }
41
+
42
+ export class CredentialDigester {
43
+ readonly #key: Buffer;
44
+
45
+ constructor(key: Buffer) {
46
+ if (key.length !== 32) throw new Error("Credential digest key must contain 32 bytes.");
47
+ this.#key = Buffer.from(key);
48
+ }
49
+
50
+ digest(secret: string, purpose: CredentialPurpose): DigestPair {
51
+ if (!tokenPattern.test(secret)) throw new Error("Credential must be unpadded base64url.");
52
+ const lookup = createHmac("sha256", this.#key)
53
+ .update(`lookup:${purpose}:`)
54
+ .update(secret)
55
+ .digest()
56
+ .subarray(0, 16);
57
+ const verifier = createHmac("sha256", this.#key)
58
+ .update(`verify:${purpose}:`)
59
+ .update(secret)
60
+ .digest();
61
+ return { lookup, verifier };
62
+ }
63
+
64
+ verify(secret: string, purpose: CredentialPurpose, stored?: Buffer): boolean {
65
+ let candidate: Buffer;
66
+ let valid = true;
67
+ try {
68
+ candidate = this.digest(secret, purpose).verifier;
69
+ } catch {
70
+ candidate = dummyVerifier;
71
+ valid = false;
72
+ }
73
+ const expected = stored?.length === 32 ? stored : dummyVerifier;
74
+ return timingSafeEqual(candidate, expected) && stored !== undefined && valid;
75
+ }
76
+
77
+ destroy(): void {
78
+ this.#key.fill(0);
79
+ }
80
+ }
81
+
82
+ export class LiveCredentialRegistry {
83
+ readonly #sessions = new Map<string, Set<AbortController>>();
84
+ readonly #keys = new Map<AbortController, readonly string[]>();
85
+
86
+ register(identities: string | readonly string[]): {
87
+ readonly signal: AbortSignal;
88
+ readonly release: () => void;
89
+ } {
90
+ const controller = new AbortController();
91
+ const keys = [...new Set(typeof identities === "string" ? [identities] : identities)];
92
+ this.#keys.set(controller, keys);
93
+ for (const key of keys) {
94
+ const sessions = this.#sessions.get(key) ?? new Set<AbortController>();
95
+ sessions.add(controller);
96
+ this.#sessions.set(key, sessions);
97
+ }
98
+ return {
99
+ signal: controller.signal,
100
+ release: () => this.#release(controller),
101
+ };
102
+ }
103
+
104
+ invalidate(identity: string): void {
105
+ const sessions = this.#sessions.get(identity);
106
+ if (sessions === undefined) return;
107
+ for (const controller of [...sessions]) {
108
+ this.#release(controller);
109
+ controller.abort();
110
+ }
111
+ }
112
+
113
+ activeSessionCount(clientId: string): number {
114
+ return this.#sessions.get(clientId)?.size ?? 0;
115
+ }
116
+
117
+ #release(controller: AbortController): void {
118
+ const keys = this.#keys.get(controller);
119
+ if (keys === undefined) return;
120
+ this.#keys.delete(controller);
121
+ for (const key of keys) {
122
+ const sessions = this.#sessions.get(key);
123
+ sessions?.delete(controller);
124
+ if (sessions?.size === 0) this.#sessions.delete(key);
125
+ }
126
+ }
127
+ }
128
+
129
+ export class CredentialAuthority {
130
+ readonly #store: CredentialStore;
131
+ readonly #digester: CredentialDigester;
132
+ readonly #clock: () => Date;
133
+ readonly #registry: LiveCredentialRegistry;
134
+
135
+ constructor(
136
+ store: CredentialStore,
137
+ digester: CredentialDigester,
138
+ clock: () => Date = () => new Date(),
139
+ registry = new LiveCredentialRegistry(),
140
+ ) {
141
+ this.#store = store;
142
+ this.#digester = digester;
143
+ this.#clock = clock;
144
+ this.#registry = registry;
145
+ }
146
+
147
+ createRecoveryCredential(): string {
148
+ const secret = generateSecret();
149
+ this.#store.createRecoveryCredential(
150
+ randomUUID(),
151
+ this.#digester.digest(secret, "recovery"),
152
+ );
153
+ return secret;
154
+ }
155
+
156
+ createBootstrapInvitation(ttlMs: number): string {
157
+ return this.#createInvitation("owner", ttlMs);
158
+ }
159
+
160
+ createInvitation(recoveryCredential: string, ttlMs: number): string | null {
161
+ const digest = safeDigest(this.#digester, recoveryCredential, "recovery");
162
+ const stored = this.#store.findRecoveryCredential(digest.lookup);
163
+ if (!this.#digester.verify(recoveryCredential, "recovery", stored?.verifier)) return null;
164
+ return this.#createInvitation("client", ttlMs);
165
+ }
166
+
167
+ replaceOwnerInvitation(recoveryCredential: string, ttlMs: number): string | null {
168
+ const digest = safeDigest(this.#digester, recoveryCredential, "recovery");
169
+ const stored = this.#store.findRecoveryCredential(digest.lookup);
170
+ if (!this.#digester.verify(recoveryCredential, "recovery", stored?.verifier)) return null;
171
+ return this.#createInvitation("owner", ttlMs);
172
+ }
173
+
174
+ #createInvitation(purpose: "owner" | "client", ttlMs: number): string {
175
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 1_000 || ttlMs > 86_400_000) {
176
+ throw new Error("Invitation TTL must be an integer from 1000 to 86400000 milliseconds.");
177
+ }
178
+ const secret = generateSecret();
179
+ this.#store.createInvitation({
180
+ id: randomUUID(),
181
+ digest: this.#digester.digest(secret, "invitation"),
182
+ expiresAt: new Date(this.#clock().getTime() + ttlMs).toISOString(),
183
+ purpose,
184
+ });
185
+ return secret;
186
+ }
187
+
188
+ exchangeInvitation(request: EnrollmentRequest): EnrollmentResponse | null {
189
+ const invitationDigest = safeDigest(this.#digester, request.invitation, "invitation");
190
+ const stored = this.#store.findInvitation(invitationDigest.lookup);
191
+ const verified = this.#digester.verify(
192
+ request.invitation,
193
+ "invitation",
194
+ stored?.verifier,
195
+ );
196
+ const clientId = randomUUID();
197
+ const claimed = this.#store.claimInvitation({
198
+ invitationId: stored?.id ?? "00000000-0000-4000-8000-000000000000",
199
+ clientId,
200
+ requestedClientName: request.clientName ?? null,
201
+ retryKey: request.retryKey,
202
+ credentialId: randomUUID(),
203
+ credentialDigest: safeDigest(this.#digester, request.clientCredential, "client"),
204
+ });
205
+ return !verified || stored?.expiresAt === undefined || claimed === null ? null : {
206
+ purpose: claimed.purpose,
207
+ clientId: claimed.clientId,
208
+ serverCapabilities: claimed.serverCapabilities,
209
+ };
210
+ }
211
+
212
+ authenticate(authorization: string | undefined): Principal | null {
213
+ const result = this.authenticateStatus(authorization);
214
+ return typeof result === "object" ? result : null;
215
+ }
216
+
217
+ authenticateStatus(
218
+ authorization: string | undefined,
219
+ ): Principal | "missing" | "invalid" | "revoked" {
220
+ if (authorization === undefined) return "missing";
221
+ const secret = bearerSecret(authorization);
222
+ const clientDigest = safeDigest(this.#digester, secret, "client");
223
+ const droneDigest = safeDigest(this.#digester, secret, "drone-session");
224
+ const client = this.#store.findClientCredential(clientDigest.lookup);
225
+ const drone = this.#store.findDroneSessionCredential(droneDigest.lookup);
226
+ const clientValid = this.#digester.verify(secret, "client", client?.verifier) &&
227
+ client?.clientId !== undefined;
228
+ const droneValid = this.#digester.verify(secret, "drone-session", drone?.verifier) &&
229
+ drone !== null;
230
+ if (clientValid) {
231
+ if (client!.revokedAt != null) return "revoked";
232
+ return clientPrincipal(client!.clientId!);
233
+ }
234
+ if (droneValid) {
235
+ if (drone!.revokedAt != null || drone!.expiresAt <= this.#clock().toISOString()) {
236
+ return "revoked";
237
+ }
238
+ return droneSessionPrincipal({
239
+ id: drone!.sessionId,
240
+ clientId: drone!.clientId,
241
+ cubeId: drone!.cubeId,
242
+ droneId: drone!.droneId,
243
+ });
244
+ }
245
+ return "invalid";
246
+ }
247
+
248
+ attachSeat(
249
+ store: ScopedStore,
250
+ request: {
251
+ readonly cubeId: string;
252
+ readonly roleId: string;
253
+ readonly retryKey: string;
254
+ readonly priorDroneId?: string;
255
+ },
256
+ ): SeatAttachResponse {
257
+ const credential = generateSecret();
258
+ const record = store.attachSeat({
259
+ ...request,
260
+ droneId: randomUUID(),
261
+ sessionId: randomUUID(),
262
+ credentialId: randomUUID(),
263
+ credentialDigest: this.#digester.digest(credential, "drone-session"),
264
+ expiresAt: new Date(this.#clock().getTime() + 86_400_000).toISOString(),
265
+ });
266
+ for (const sessionId of record.revokedSessionIds) this.#registry.invalidate(sessionId);
267
+ return { ...record, credential };
268
+ }
269
+
270
+ rotateClient(clientId: string): string {
271
+ if (!this.#store.clientIsActive(clientId)) throw operatorErrors.CLIENT_NOT_FOUND;
272
+ const secret = generateSecret();
273
+ const rotated = this.#store.rotateClientCredential({
274
+ clientId,
275
+ credentialId: randomUUID(),
276
+ credentialDigest: this.#digester.digest(secret, "client"),
277
+ });
278
+ if (!rotated) throw operatorErrors.CLIENT_NOT_FOUND;
279
+ this.#registry.invalidate(clientId);
280
+ return secret;
281
+ }
282
+
283
+ revokeClient(clientId: string): void {
284
+ if (!this.#store.clientExists(clientId)) throw operatorErrors.CLIENT_NOT_FOUND;
285
+ this.#store.revokeClientCredentials(clientId);
286
+ this.#registry.invalidate(clientId);
287
+ }
288
+
289
+ registerLiveSession(principal: string | Principal) {
290
+ if (typeof principal === "string") return this.#registry.register(principal);
291
+ return this.#registry.register(
292
+ principal.kind === "drone-session"
293
+ ? [principal.id, principal.clientId]
294
+ : principal.id,
295
+ );
296
+ }
297
+ }
298
+
299
+ export function generateSecret(): string {
300
+ return randomBytes(32).toString("base64url");
301
+ }
302
+
303
+ function bearerSecret(authorization: string | undefined): string {
304
+ if (authorization === undefined) return "";
305
+ const match = /^Bearer ([A-Za-z0-9_-]{43,1024})$/u.exec(authorization);
306
+ return match?.[1] ?? "";
307
+ }
308
+
309
+ function safeDigest(
310
+ digester: CredentialDigester,
311
+ secret: string,
312
+ purpose: CredentialPurpose,
313
+ ): DigestPair {
314
+ try {
315
+ return digester.digest(secret, purpose);
316
+ } catch {
317
+ return { lookup: Buffer.alloc(16), verifier: dummyVerifier };
318
+ }
319
+ }
@@ -0,0 +1,156 @@
1
+ import type { CredentialAuthority } from "./credentials.js";
2
+ import { StorageCapacityError } from "./store.js";
3
+
4
+ interface EnrollmentEnvelope {
5
+ readonly protocol_version: "1";
6
+ readonly request_id: string;
7
+ readonly payload: {
8
+ readonly invitation: string;
9
+ readonly retry_key: string;
10
+ readonly client_credential: string;
11
+ readonly client_name?: string;
12
+ };
13
+ }
14
+
15
+ export function createEnrollmentExchange(authority: CredentialAuthority) {
16
+ return async (body: unknown): Promise<{
17
+ readonly status: 201 | 400 | 401 | 507;
18
+ readonly body?: unknown;
19
+ }> => {
20
+ let envelope: EnrollmentEnvelope;
21
+ try {
22
+ envelope = decodeEnrollmentEnvelope(body);
23
+ } catch {
24
+ return {
25
+ status: 400,
26
+ body: errorEnvelope(
27
+ "INVALID_INPUT",
28
+ "Invalid enrollment request.",
29
+ safeRequestId(body),
30
+ ),
31
+ };
32
+ }
33
+ let response;
34
+ try {
35
+ response = authority.exchangeInvitation({
36
+ invitation: envelope.payload.invitation,
37
+ retryKey: envelope.payload.retry_key,
38
+ clientCredential: envelope.payload.client_credential,
39
+ ...(envelope.payload.client_name === undefined
40
+ ? {}
41
+ : { clientName: envelope.payload.client_name }),
42
+ });
43
+ } catch (error) {
44
+ if (!(error instanceof StorageCapacityError)) throw error;
45
+ return {
46
+ status: 507,
47
+ body: errorEnvelope("CAPACITY_EXCEEDED", error.message, envelope.request_id),
48
+ };
49
+ }
50
+ if (response === null) {
51
+ return {
52
+ status: 401,
53
+ body: errorEnvelope(
54
+ "AUTH_INVALID",
55
+ "Enrollment authentication failed.",
56
+ envelope.request_id,
57
+ ),
58
+ };
59
+ }
60
+ return {
61
+ status: 201,
62
+ body: {
63
+ protocol_version: "1",
64
+ request_id: envelope.request_id,
65
+ payload: {
66
+ purpose: response.purpose,
67
+ client_id: response.clientId,
68
+ server_capabilities: response.serverCapabilities,
69
+ },
70
+ },
71
+ };
72
+ };
73
+ }
74
+
75
+ function errorEnvelope(
76
+ code: "INVALID_INPUT" | "AUTH_INVALID" | "CAPACITY_EXCEEDED",
77
+ message: string,
78
+ requestId?: string,
79
+ ) {
80
+ return {
81
+ protocol_version: "1" as const,
82
+ ...(requestId === undefined ? {} : { request_id: requestId }),
83
+ error: { code, message },
84
+ };
85
+ }
86
+
87
+ function safeRequestId(value: unknown): string | undefined {
88
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
89
+ const requestId = (value as Record<string, unknown>)["request_id"];
90
+ return typeof requestId === "string" && /^[A-Za-z0-9._-]{8,128}$/u.test(requestId)
91
+ ? requestId
92
+ : undefined;
93
+ }
94
+
95
+ export function decodeEnrollmentEnvelope(value: unknown): EnrollmentEnvelope {
96
+ const envelope = exactRecord(value, ["protocol_version", "request_id", "payload"]);
97
+ if (envelope["protocol_version"] !== "1") throw new Error("Invalid enrollment request.");
98
+ const requestId = envelope["request_id"];
99
+ if (typeof requestId !== "string" || !/^[A-Za-z0-9._-]{8,128}$/u.test(requestId)) {
100
+ throw new Error("Invalid enrollment request.");
101
+ }
102
+ const payload = exactRecord(
103
+ envelope["payload"],
104
+ ["invitation", "retry_key", "client_credential"],
105
+ ["client_name"],
106
+ );
107
+ const invitation = payload["invitation"];
108
+ if (typeof invitation !== "string" || !/^[A-Za-z0-9_-]{43,1024}$/u.test(invitation)) {
109
+ throw new Error("Invalid enrollment request.");
110
+ }
111
+ const retryKey = payload["retry_key"];
112
+ if (typeof retryKey !== "string" ||
113
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(retryKey)) {
114
+ throw new Error("Invalid enrollment request.");
115
+ }
116
+ const clientCredential = payload["client_credential"];
117
+ if (typeof clientCredential !== "string" ||
118
+ !/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/u.test(clientCredential)) {
119
+ throw new Error("Invalid enrollment request.");
120
+ }
121
+ const clientName = payload["client_name"];
122
+ if (clientName !== undefined &&
123
+ (typeof clientName !== "string" || Buffer.byteLength(clientName) > 120 ||
124
+ !/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/u.test(clientName))) {
125
+ throw new Error("Invalid enrollment request.");
126
+ }
127
+ return {
128
+ protocol_version: "1",
129
+ request_id: requestId,
130
+ payload: clientName === undefined
131
+ ? { invitation, retry_key: retryKey.toLowerCase(), client_credential: clientCredential }
132
+ : {
133
+ invitation,
134
+ retry_key: retryKey.toLowerCase(),
135
+ client_credential: clientCredential,
136
+ client_name: clientName,
137
+ },
138
+ };
139
+ }
140
+
141
+ function exactRecord(
142
+ value: unknown,
143
+ required: readonly string[],
144
+ optional: readonly string[] = [],
145
+ ): Record<string, unknown> {
146
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
147
+ throw new Error("Invalid enrollment request.");
148
+ }
149
+ const record = value as Record<string, unknown>;
150
+ const allowed = new Set([...required, ...optional]);
151
+ if (Object.keys(record).some((key) => !allowed.has(key)) ||
152
+ required.some((key) => !Object.hasOwn(record, key))) {
153
+ throw new Error("Invalid enrollment request.");
154
+ }
155
+ return record;
156
+ }