@c956180462/awbs 0.0.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 (50) hide show
  1. package/AWBS_CORE_DESIGN.md +983 -0
  2. package/AWBS_CURRENT_FEATURES.md +463 -0
  3. package/LICENSE +21 -0
  4. package/README.md +265 -0
  5. package/TASK_001_VIEW_AUTHORITY.md +446 -0
  6. package/TASK_003_AUTHORITY_LEDGER_AND_DB_AUDIT.md +268 -0
  7. package/TASK_004_TRUSTED_AUTHORITY_LAYER.md +547 -0
  8. package/TASK_005_AUTHORITY_SESSION.md +218 -0
  9. package/TASK_006_TRUST_BOUNDARY_HARDENING.md +381 -0
  10. package/TASK_007_TRUSTED_OPERATION_ENTRY.md +129 -0
  11. package/bin/awbs.js +2 -0
  12. package/docs/DEVELOPMENT_LEARNING.md +319 -0
  13. package/docs/FULL_CHAIN.md +295 -0
  14. package/docs/PRODUCT.md +188 -0
  15. package/docs/USAGE.md +294 -0
  16. package/package.json +45 -0
  17. package/src/adapters/file-summary-store.ts +88 -0
  18. package/src/adapters/git-cli.ts +107 -0
  19. package/src/adapters/local-authority-session.ts +606 -0
  20. package/src/adapters/local-file-database.ts +199 -0
  21. package/src/adapters/sealed-authority.ts +725 -0
  22. package/src/adapters/session-authority-client.ts +176 -0
  23. package/src/adapters/sqlite-index-store.ts +176 -0
  24. package/src/cli.ts +491 -0
  25. package/src/domain/authority-types.ts +194 -0
  26. package/src/domain/constants.ts +11 -0
  27. package/src/domain/errors.ts +6 -0
  28. package/src/domain/hash.ts +27 -0
  29. package/src/domain/path-policy.ts +36 -0
  30. package/src/domain/paths.ts +65 -0
  31. package/src/domain/session-proof.ts +140 -0
  32. package/src/domain/session-types.ts +101 -0
  33. package/src/domain/types.ts +94 -0
  34. package/src/ports/authority-session.ts +8 -0
  35. package/src/ports/authority.ts +26 -0
  36. package/src/ports/file-database.ts +18 -0
  37. package/src/ports/git.ts +23 -0
  38. package/src/ports/index-store.ts +7 -0
  39. package/src/ports/summary-store.ts +16 -0
  40. package/src/runtime.ts +56 -0
  41. package/src/session-entry.ts +1 -0
  42. package/src/usecases/authority.ts +53 -0
  43. package/src/usecases/changeset.ts +437 -0
  44. package/src/usecases/db.ts +192 -0
  45. package/src/usecases/index.ts +136 -0
  46. package/src/usecases/init.ts +48 -0
  47. package/src/usecases/ledger.ts +146 -0
  48. package/src/usecases/session.ts +48 -0
  49. package/src/usecases/trusted-chain.ts +56 -0
  50. package/src/usecases/view.ts +166 -0
@@ -0,0 +1,606 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync } from "node:crypto";
3
+ import { createServer, connect } from "node:net";
4
+ import { join, resolve } from "node:path";
5
+ import { platform } from "node:os";
6
+ import { AwbsError } from "../domain/errors.ts";
7
+ import { attachControllerProof, attachControllerResponseProof, verifyControllerProof } from "../domain/session-proof.ts";
8
+ import type { AuthorityLocal, AuthorityRepo } from "../domain/authority-types.ts";
9
+ import type {
10
+ AuthoritySessionControlInput,
11
+ AuthoritySessionDaemonStartup,
12
+ AuthoritySessionFile,
13
+ AuthoritySessionRecoverResult,
14
+ AuthoritySessionRequest,
15
+ AuthoritySessionResponse,
16
+ AuthoritySessionStartResult,
17
+ AuthoritySessionStatusReport,
18
+ AuthoritySessionStopResult,
19
+ RecoverySealEnvelope
20
+ } from "../domain/session-types.ts";
21
+ import type { AuthoritySessionPort } from "../ports/authority-session.ts";
22
+ import type { FileDatabasePort } from "../ports/file-database.ts";
23
+ import { LocalFileDatabaseAdapter } from "./local-file-database.ts";
24
+ import { SealedAuthorityAdapter } from "./sealed-authority.ts";
25
+
26
+ const RECOVERY_PEPPER = "awbs-recovery-secret-context-v1";
27
+
28
+ export class LocalAuthoritySessionAdapter implements AuthoritySessionPort {
29
+ private readonly files: FileDatabasePort;
30
+ private readonly cliPath: string;
31
+
32
+ constructor(files: FileDatabasePort, cliPath: string) {
33
+ this.files = files;
34
+ this.cliPath = cliPath;
35
+ }
36
+
37
+ async start(cwd: string, input: AuthoritySessionControlInput): Promise<AuthoritySessionStartResult> {
38
+ const root = this.files.findProjectRoot(cwd);
39
+ assertControlInput(input);
40
+
41
+ const currentStatus = this.status(root);
42
+ if (currentStatus.active) {
43
+ throw new AwbsError("Authority session is already active.");
44
+ }
45
+
46
+ const repo = this.readRepo(root);
47
+ if (repo.trustMode !== "ephemeral-local-key-v1") {
48
+ throw new AwbsError("Authority repo trustMode is not ephemeral-local-key-v1. Reinitialize this development database with the current AWBS version.");
49
+ }
50
+
51
+ const localPath = this.localPath(root);
52
+ if (!this.files.pathExists(localPath)) {
53
+ throw new AwbsError("Authority local material is missing. Run authority session recover with a recovery secret.");
54
+ }
55
+ const local = this.files.readJson<AuthorityLocal>(localPath);
56
+ const recoverySealPath = this.recoverySealPath(root);
57
+ this.files.writeJson(recoverySealPath, sealRecoveryLocal(repo.repoId, local, input.recoverySecret));
58
+
59
+ const startup: AuthoritySessionDaemonStartup = {
60
+ schemaVersion: 1,
61
+ root,
62
+ repoId: repo.repoId,
63
+ local,
64
+ controllerTokenHash: sha256String(input.controllerToken)
65
+ };
66
+
67
+ const startupPath = join(root, ".awbs", "private", `session-startup-${randomBytes(8).toString("hex")}.json`);
68
+ this.files.writeJson(startupPath, startup);
69
+ const child = spawn(process.execPath, [this.cliPath, "__session-daemon", "--startup-file", startupPath], {
70
+ cwd: root,
71
+ detached: true,
72
+ stdio: "ignore",
73
+ windowsHide: true
74
+ });
75
+ child.unref();
76
+
77
+ const ready = await this.waitForActive(root, 5000);
78
+ if (!ready.active) {
79
+ try {
80
+ child.kill();
81
+ } catch {
82
+ // Best-effort cleanup; startup failure is reported below.
83
+ }
84
+ this.files.removePath(recoverySealPath);
85
+ this.files.removePath(this.sessionPath(root));
86
+ this.files.removePath(startupPath);
87
+ throw new AwbsError(`Authority session did not become active: ${ready.errors.join("; ") || ready.status}`);
88
+ }
89
+
90
+ this.files.removePath(localPath);
91
+ return {
92
+ ...ready,
93
+ recoverySealPath
94
+ };
95
+ }
96
+
97
+ status(cwd: string): AuthoritySessionStatusReport {
98
+ const root = this.files.findProjectRoot(cwd);
99
+ const sessionPath = this.sessionPath(root);
100
+ if (!this.files.pathExists(sessionPath)) {
101
+ return inactiveStatus();
102
+ }
103
+
104
+ let session: AuthoritySessionFile;
105
+ try {
106
+ session = this.files.readJson<AuthoritySessionFile>(sessionPath);
107
+ } catch (error) {
108
+ return staleStatus([error instanceof Error ? error.message : String(error)]);
109
+ }
110
+
111
+ try {
112
+ const response = requestAuthoritySession(this.cliPath, root, { schemaVersion: 1, method: "status", root });
113
+ if (!response.ok) {
114
+ return staleStatus([response.error], session);
115
+ }
116
+ const active = response.result as AuthoritySessionStatusReport;
117
+ return active;
118
+ } catch (error) {
119
+ return staleStatus([error instanceof Error ? error.message : String(error)], session);
120
+ }
121
+ }
122
+
123
+ stop(cwd: string, controllerToken: string): AuthoritySessionStopResult {
124
+ const root = this.files.findProjectRoot(cwd);
125
+ assertSecret("controllerToken", controllerToken);
126
+ const response = requestAuthoritySession(this.cliPath, root, attachControllerProof({
127
+ schemaVersion: 1,
128
+ method: "stop",
129
+ root
130
+ }, controllerToken));
131
+ if (!response.ok) {
132
+ throw new AwbsError(response.error);
133
+ }
134
+ return response.result as AuthoritySessionStopResult;
135
+ }
136
+
137
+ recover(cwd: string, recoverySecret: string): AuthoritySessionRecoverResult {
138
+ const root = this.files.findProjectRoot(cwd);
139
+ assertSecret("recoverySecret", recoverySecret);
140
+ const currentStatus = this.status(root);
141
+ if (currentStatus.active) {
142
+ throw new AwbsError("Authority session is active. Stop it before recovering local material.");
143
+ }
144
+
145
+ const localPath = this.localPath(root);
146
+ if (this.files.pathExists(localPath)) {
147
+ return { recovered: false, localPath };
148
+ }
149
+
150
+ const repo = this.readRepo(root);
151
+ const recoverySealPath = this.recoverySealPath(root);
152
+ if (!this.files.pathExists(recoverySealPath)) {
153
+ throw new AwbsError("Authority recovery seal is missing.");
154
+ }
155
+
156
+ const envelope = this.files.readJson<RecoverySealEnvelope>(recoverySealPath);
157
+ const local = openRecoveryLocal(repo.repoId, envelope, recoverySecret);
158
+ this.files.writeJson(localPath, local);
159
+ this.files.removePath(recoverySealPath);
160
+ this.files.removePath(this.sessionPath(root));
161
+ return { recovered: true, localPath };
162
+ }
163
+
164
+ private async waitForActive(root: string, timeoutMs: number): Promise<AuthoritySessionStatusReport> {
165
+ const start = Date.now();
166
+ let last = inactiveStatus();
167
+ while (Date.now() - start < timeoutMs) {
168
+ last = this.status(root);
169
+ if (last.active) {
170
+ return last;
171
+ }
172
+ await sleep(50);
173
+ }
174
+ return last;
175
+ }
176
+
177
+ private readRepo(root: string): AuthorityRepo {
178
+ return this.files.readJson<AuthorityRepo>(this.repoPath(root));
179
+ }
180
+
181
+ private repoPath(root: string): string {
182
+ return join(root, ".awbs", "authority", "repo.json");
183
+ }
184
+
185
+ private localPath(root: string): string {
186
+ return join(root, ".awbs", "private", "local.json");
187
+ }
188
+
189
+ private sessionPath(root: string): string {
190
+ return join(root, ".awbs", "private", "session.json");
191
+ }
192
+
193
+ private recoverySealPath(root: string): string {
194
+ return join(root, ".awbs", "private", "recovery.seal.json");
195
+ }
196
+ }
197
+
198
+ export async function runAuthoritySessionDaemon(): Promise<void> {
199
+ const startup = readDaemonStartup(process.argv.slice(3));
200
+ const files = new LocalFileDatabaseAdapter();
201
+ const authority = new SealedAuthorityAdapter(files, { memoryLocal: startup.local });
202
+ const root = resolve(startup.root);
203
+ const boundStartup: AuthoritySessionDaemonStartup = { ...startup, root };
204
+ const usedControllerNonces = new Set<string>();
205
+ const repo = files.readJson<AuthorityRepo>(join(root, ".awbs", "authority", "repo.json"));
206
+ if (repo.repoId !== startup.repoId) {
207
+ throw new AwbsError("Authority session repo id mismatch.");
208
+ }
209
+
210
+ const server = createServer((socket) => {
211
+ let body = "";
212
+ let handled = false;
213
+ socket.setEncoding("utf8");
214
+ socket.on("data", (chunk) => {
215
+ body += chunk;
216
+ if (handled || !body.includes("\n")) {
217
+ return;
218
+ }
219
+ handled = true;
220
+ body = body.slice(0, body.indexOf("\n"));
221
+ let method = "";
222
+ try {
223
+ method = (JSON.parse(body) as AuthoritySessionRequest).method;
224
+ } catch {
225
+ method = "";
226
+ }
227
+ void handleSessionRequest(body, boundStartup, authority, files, server, usedControllerNonces)
228
+ .then((response) => {
229
+ socket.end(JSON.stringify(response), () => {
230
+ if (method === "stop" && response.ok) {
231
+ server.close(() => process.exit(0));
232
+ }
233
+ });
234
+ })
235
+ .catch((error) => {
236
+ socket.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) } satisfies AuthoritySessionResponse));
237
+ });
238
+ });
239
+ });
240
+ server.on("error", (error) => {
241
+ console.error(`authority session server error: ${error.message}`);
242
+ });
243
+
244
+ await new Promise<void>((resolve) => {
245
+ server.listen(0, "127.0.0.1", () => resolve());
246
+ });
247
+
248
+ const address = server.address();
249
+ if (!address || typeof address === "string") {
250
+ throw new AwbsError("Authority session failed to bind a local endpoint.");
251
+ }
252
+
253
+ const session: AuthoritySessionFile = {
254
+ schemaVersion: 1,
255
+ repoId: boundStartup.repoId,
256
+ trustMode: "ephemeral-local-key-v1",
257
+ pid: process.pid,
258
+ socketPath: `tcp://127.0.0.1:${address.port}`,
259
+ startedAt: new Date().toISOString(),
260
+ status: "active"
261
+ };
262
+ files.writeJson(join(boundStartup.root, ".awbs", "private", "session.json"), session);
263
+ await new Promise<void>((resolve) => {
264
+ server.on("close", () => resolve());
265
+ });
266
+ }
267
+
268
+ function readDaemonStartup(argv: string[]): AuthoritySessionDaemonStartup {
269
+ const startupFileIndex = argv.indexOf("--startup-file");
270
+ if (startupFileIndex >= 0) {
271
+ const startupPath = argv[startupFileIndex + 1];
272
+ if (!startupPath) {
273
+ throw new AwbsError("Missing --startup-file value for authority session daemon.");
274
+ }
275
+ const files = new LocalFileDatabaseAdapter();
276
+ try {
277
+ return files.readJson<AuthoritySessionDaemonStartup>(startupPath);
278
+ } finally {
279
+ files.removePath(startupPath);
280
+ }
281
+ }
282
+ throw new AwbsError("Authority session daemon requires --startup-file.");
283
+ }
284
+
285
+ export async function runAuthoritySessionRequest(): Promise<void> {
286
+ const payload = JSON.parse(await readStdin()) as { root: string; request: AuthoritySessionRequest };
287
+ const response = await sendRequestToActiveSession(payload.root, payload.request);
288
+ console.log(JSON.stringify(response));
289
+ }
290
+
291
+ function requestAuthoritySession(cliPath: string, root: string, request: AuthoritySessionRequest): AuthoritySessionResponse {
292
+ const stdout = execFileSync(process.execPath, [cliPath, "__session-request"], {
293
+ cwd: root,
294
+ input: JSON.stringify({ root, request }),
295
+ encoding: "utf8",
296
+ windowsHide: true
297
+ });
298
+ return JSON.parse(stdout) as AuthoritySessionResponse;
299
+ }
300
+
301
+ async function sendRequestToActiveSession(root: string, request: AuthoritySessionRequest): Promise<AuthoritySessionResponse> {
302
+ const files = new LocalFileDatabaseAdapter();
303
+ const sessionPath = join(root, ".awbs", "private", "session.json");
304
+ if (!files.pathExists(sessionPath)) {
305
+ return { ok: false, error: "Authority session is not active." };
306
+ }
307
+ const session = files.readJson<AuthoritySessionFile>(sessionPath);
308
+ const endpoint = parseSocketPath(session.socketPath);
309
+
310
+ return await new Promise<AuthoritySessionResponse>((resolve) => {
311
+ const socket = connect(endpoint.port, endpoint.host);
312
+ let response = "";
313
+ socket.setEncoding("utf8");
314
+ socket.on("connect", () => {
315
+ socket.write(`${JSON.stringify(request)}\n`);
316
+ });
317
+ socket.on("data", (chunk) => {
318
+ response += chunk;
319
+ });
320
+ socket.on("end", () => {
321
+ try {
322
+ resolve(JSON.parse(response) as AuthoritySessionResponse);
323
+ } catch (error) {
324
+ resolve({ ok: false, error: error instanceof Error ? error.message : String(error) });
325
+ }
326
+ });
327
+ socket.on("error", (error) => {
328
+ resolve({ ok: false, error: error.message });
329
+ });
330
+ });
331
+ }
332
+
333
+ async function handleSessionRequest(
334
+ body: string,
335
+ startup: AuthoritySessionDaemonStartup,
336
+ authority: SealedAuthorityAdapter,
337
+ files: LocalFileDatabaseAdapter,
338
+ server: ReturnType<typeof createServer>,
339
+ usedControllerNonces: Set<string>
340
+ ): Promise<AuthoritySessionResponse> {
341
+ let request: AuthoritySessionRequest | null = null;
342
+ let controllerProofAccepted = false;
343
+ try {
344
+ request = JSON.parse(body) as AuthoritySessionRequest;
345
+ if (request.schemaVersion !== 1) {
346
+ throw new AwbsError("Invalid authority session request schema.");
347
+ }
348
+ if (request.controllerProof) {
349
+ assertControllerProof(startup.controllerTokenHash, request, usedControllerNonces);
350
+ controllerProofAccepted = true;
351
+ } else if (isControllerMethod(request.method)) {
352
+ throw new AwbsError("Authority controller token is invalid.");
353
+ }
354
+
355
+ const requestedRoot = resolve(request.root || startup.root);
356
+ assertAllowedSessionRoot(startup.root, requestedRoot, startup.repoId, files);
357
+ const root = requestedRoot;
358
+ const args = request.args ?? [];
359
+ let result: unknown;
360
+ switch (request.method) {
361
+ case "status":
362
+ result = {
363
+ status: "active",
364
+ active: true,
365
+ repoId: startup.repoId,
366
+ pid: process.pid,
367
+ socketPath: files.readJson<AuthoritySessionFile>(join(startup.root, ".awbs", "private", "session.json")).socketPath,
368
+ startedAt: files.readJson<AuthoritySessionFile>(join(startup.root, ".awbs", "private", "session.json")).startedAt,
369
+ errors: []
370
+ } satisfies AuthoritySessionStatusReport;
371
+ break;
372
+ case "stop":
373
+ files.writeJson(join(startup.root, ".awbs", "private", "local.json"), startup.local);
374
+ files.removePath(join(startup.root, ".awbs", "private", "recovery.seal.json"));
375
+ files.removePath(join(startup.root, ".awbs", "private", "session.json"));
376
+ result = { stopped: true, localRestored: true } satisfies AuthoritySessionStopResult;
377
+ break;
378
+ case "ensureInitialized":
379
+ authority.ensureInitialized(root);
380
+ result = null;
381
+ break;
382
+ case "createView":
383
+ result = authority.createView(root, args[0] as never);
384
+ break;
385
+ case "getViewContract":
386
+ result = authority.getViewContract(root, args[0] as string, args[1] && typeof args[1] === "object" ? (args[1] as never) : undefined);
387
+ break;
388
+ case "revokeView":
389
+ result = authority.revokeView(root, args[0] as string);
390
+ break;
391
+ case "verify":
392
+ result = authority.verify(root);
393
+ break;
394
+ case "repairMirrors":
395
+ result = authority.repairMirrors(root);
396
+ break;
397
+ case "readCatalog":
398
+ result = authority.readCatalog(root);
399
+ break;
400
+ case "hasLedger":
401
+ result = authority.hasLedger(root);
402
+ break;
403
+ case "bootstrapLedger":
404
+ result = authority.bootstrapLedger(root, args[0] as string);
405
+ break;
406
+ case "readLedger":
407
+ result = authority.readLedger(root);
408
+ break;
409
+ case "recordChangesetApply":
410
+ result = authority.recordChangesetApply(root, args[0] as never);
411
+ break;
412
+ case "sealChangesetReceipt":
413
+ result = authority.sealChangesetReceipt(root, args[0] as string, args[1] as never);
414
+ break;
415
+ case "openChangesetReceipt":
416
+ result = authority.openChangesetReceipt(root, args[0] as string);
417
+ break;
418
+ default:
419
+ throw new AwbsError(`Unknown authority session method: ${request.method}`);
420
+ }
421
+ const response = { ok: true, result } satisfies AuthoritySessionResponse;
422
+ return controllerProofAccepted && request ? attachControllerResponseProof(response, request, startup.controllerTokenHash) : response;
423
+ } catch (error) {
424
+ const response = { ok: false, error: error instanceof Error ? error.message : String(error) } satisfies AuthoritySessionResponse;
425
+ return controllerProofAccepted && request ? attachControllerResponseProof(response, request, startup.controllerTokenHash) : response;
426
+ }
427
+ }
428
+
429
+ function isControllerMethod(method: string): boolean {
430
+ return new Set(["stop", "ensureInitialized", "createView", "revokeView", "bootstrapLedger", "recordChangesetApply", "repairMirrors"]).has(method);
431
+ }
432
+
433
+ function assertAllowedSessionRoot(boundRoot: string, requestedRoot: string, repoId: string, files: LocalFileDatabaseAdapter): void {
434
+ if (requestedRoot === boundRoot) {
435
+ return;
436
+ }
437
+ const requestedRepoPath = join(requestedRoot, ".awbs", "authority", "repo.json");
438
+ if (!files.pathExists(requestedRepoPath)) {
439
+ throw new AwbsError("Authority session can only serve its bound repository or an AWBS trusted worktree from the same Git repository.");
440
+ }
441
+ const requestedRepo = files.readJson<AuthorityRepo>(requestedRepoPath);
442
+ if (requestedRepo.repoId !== repoId) {
443
+ throw new AwbsError("Authority session repo id mismatch for requested root.");
444
+ }
445
+ const boundCommonDir = tryGitCommonDir(boundRoot);
446
+ const requestedCommonDir = tryGitCommonDir(requestedRoot);
447
+ if (boundCommonDir && requestedCommonDir && sameFilesystemPath(boundCommonDir, requestedCommonDir)) {
448
+ return;
449
+ }
450
+ throw new AwbsError("Authority session can only serve trusted worktrees from the same Git repository.");
451
+ }
452
+
453
+ function sameFilesystemPath(left: string, right: string): boolean {
454
+ const normalizedLeft = resolve(left);
455
+ const normalizedRight = resolve(right);
456
+ return platform() === "win32" ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() : normalizedLeft === normalizedRight;
457
+ }
458
+
459
+ function tryGitCommonDir(root: string): string | null {
460
+ try {
461
+ return gitCommonDir(root);
462
+ } catch {
463
+ return null;
464
+ }
465
+ }
466
+
467
+ function gitCommonDir(root: string): string {
468
+ try {
469
+ return resolve(execFileSync("git", ["-C", root, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8" }).trim());
470
+ } catch {
471
+ throw new AwbsError(`Cannot verify Git common directory for authority session root: ${root}`);
472
+ }
473
+ }
474
+
475
+ function assertControllerProof(expectedHash: string, request: AuthoritySessionRequest, usedNonces: Set<string>): void {
476
+ if (!verifyControllerProof(expectedHash, request, usedNonces)) {
477
+ throw new AwbsError("Authority controller token is invalid.");
478
+ }
479
+ }
480
+
481
+ function sealRecoveryLocal(repoId: string, local: AuthorityLocal, recoverySecret: string): RecoverySealEnvelope {
482
+ assertSecret("recoverySecret", recoverySecret);
483
+ const salt = randomBytes(24);
484
+ const nonce = randomBytes(12);
485
+ const key = deriveRecoveryKey(repoId, recoverySecret, salt);
486
+ const plaintext = Buffer.from(canonicalJson(local), "utf8");
487
+ const aad = { repoId, payloadType: "authority.local" as const };
488
+ const cipher = createCipheriv("aes-256-gcm", key, nonce);
489
+ cipher.setAAD(Buffer.from(canonicalJson(aad), "utf8"));
490
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
491
+ return {
492
+ schemaVersion: 1,
493
+ sealType: "awbs.recovery.seal.v1",
494
+ payloadType: "authority.local",
495
+ kdf: "scrypt-recovery-secret-v1",
496
+ aad,
497
+ salt: salt.toString("base64"),
498
+ nonce: nonce.toString("base64"),
499
+ ciphertext: ciphertext.toString("base64"),
500
+ tag: cipher.getAuthTag().toString("base64"),
501
+ contentHash: sha256String(plaintext.toString("utf8"))
502
+ };
503
+ }
504
+
505
+ function openRecoveryLocal(repoId: string, envelope: RecoverySealEnvelope, recoverySecret: string): AuthorityLocal {
506
+ if (envelope.sealType !== "awbs.recovery.seal.v1" || envelope.payloadType !== "authority.local") {
507
+ throw new AwbsError("Invalid authority recovery seal.");
508
+ }
509
+ try {
510
+ const key = deriveRecoveryKey(repoId, recoverySecret, Buffer.from(envelope.salt, "base64"));
511
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(envelope.nonce, "base64"));
512
+ decipher.setAAD(Buffer.from(canonicalJson(envelope.aad), "utf8"));
513
+ decipher.setAuthTag(Buffer.from(envelope.tag, "base64"));
514
+ const plaintext = Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, "base64")), decipher.final()]).toString("utf8");
515
+ if (sha256String(plaintext) !== envelope.contentHash) {
516
+ throw new AwbsError("Authority recovery content hash mismatch.");
517
+ }
518
+ return JSON.parse(plaintext) as AuthorityLocal;
519
+ } catch (error) {
520
+ if (error instanceof AwbsError) {
521
+ throw error;
522
+ }
523
+ throw new AwbsError("Failed to open authority recovery seal.");
524
+ }
525
+ }
526
+
527
+ function deriveRecoveryKey(repoId: string, recoverySecret: string, salt: Buffer): Buffer {
528
+ return scryptSync(`${recoverySecret}:${repoId}:${RECOVERY_PEPPER}`, salt, 32, { N: 16384, r: 8, p: 1 });
529
+ }
530
+
531
+ function parseSocketPath(socketPath: string): { host: string; port: number } {
532
+ const match = /^tcp:\/\/([^:]+):(\d+)$/.exec(socketPath);
533
+ if (!match) {
534
+ throw new AwbsError(`Unsupported authority session endpoint: ${socketPath}`);
535
+ }
536
+ return { host: match[1], port: Number(match[2]) };
537
+ }
538
+
539
+ async function readStdin(): Promise<string> {
540
+ const chunks: Buffer[] = [];
541
+ for await (const chunk of process.stdin) {
542
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
543
+ }
544
+ return Buffer.concat(chunks).toString("utf8");
545
+ }
546
+
547
+ function assertControlInput(input: AuthoritySessionControlInput): void {
548
+ assertSecret("recoverySecret", input.recoverySecret);
549
+ assertSecret("controllerToken", input.controllerToken);
550
+ }
551
+
552
+ function assertSecret(name: string, value: string | undefined): asserts value is string {
553
+ if (!value || value.trim().length === 0) {
554
+ throw new AwbsError(`${name} is required.`);
555
+ }
556
+ }
557
+
558
+ function inactiveStatus(): AuthoritySessionStatusReport {
559
+ return {
560
+ status: "inactive",
561
+ active: false,
562
+ repoId: null,
563
+ pid: null,
564
+ socketPath: null,
565
+ startedAt: null,
566
+ errors: []
567
+ };
568
+ }
569
+
570
+ function staleStatus(errors: string[], session?: AuthoritySessionFile): AuthoritySessionStatusReport {
571
+ return {
572
+ status: "stale",
573
+ active: false,
574
+ repoId: session?.repoId ?? null,
575
+ pid: session?.pid ?? null,
576
+ socketPath: session?.socketPath ?? null,
577
+ startedAt: session?.startedAt ?? null,
578
+ errors
579
+ };
580
+ }
581
+
582
+ async function sleep(ms: number): Promise<void> {
583
+ await new Promise((resolve) => setTimeout(resolve, ms));
584
+ }
585
+
586
+ function sha256String(value: string): string {
587
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
588
+ }
589
+
590
+ function canonicalJson(value: unknown): string {
591
+ return JSON.stringify(sortForCanonicalJson(value));
592
+ }
593
+
594
+ function sortForCanonicalJson(value: unknown): unknown {
595
+ if (Array.isArray(value)) {
596
+ return value.map(sortForCanonicalJson);
597
+ }
598
+ if (value && typeof value === "object") {
599
+ const result: Record<string, unknown> = {};
600
+ for (const key of Object.keys(value).sort()) {
601
+ result[key] = sortForCanonicalJson((value as Record<string, unknown>)[key]);
602
+ }
603
+ return result;
604
+ }
605
+ return value;
606
+ }