@verax-ai/proxy 0.2.2 → 0.4.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.
@@ -10,7 +10,46 @@
10
10
  *
11
11
  * signatures each record verifies against a public key
12
12
  * chain each record names the hash of the one before it
13
- * effects each effect row is bound to a decision by `effectHash`
13
+ * effects an effect is bound only when a decision with that ref exists and
14
+ * its recorded `effectHash` equals the row's `effectHash`, and
15
+ * the row carries an attestation and a witness receipt that both
16
+ * verify under one effect key. The signed COSE payload is
17
+ * `{ ref, effectHash, witnessClass, resultHash }`, compared after
18
+ * decode, and the receipt's effect (`body.effects[0]`, every
19
+ * field the writer put there) must be that same row. A
20
+ * thrown call relaxes only the hash, and only for the tool that
21
+ * was allowed: some decision on that ref has `decision: "allow"`
22
+ * and this row's class is exactly that decision's `effectClass`
23
+ * plus `:threw`. A deny, or an allow of a different tool, does
24
+ * not bind. The signature is still required. A
25
+ * `duplicate-effect` row is bound to the decision by ref and to
26
+ * the fixed refusal hash the writer stores, not to the
27
+ * decision's effectHash; the signature is still required.
28
+ * Anything else that claims that class is named. A ref has at
29
+ * most one primary effect row (a row that is not
30
+ * `duplicate-effect`). A further primary row for that ref is
31
+ * named `ref X has more than one effect row` and is not bound.
32
+ * A `self` row is checked under the effect key:
33
+ * `effectPublicKeyPem` when the reader pins one, otherwise the
34
+ * first receipt key on a `self` row. A `same-org` row is checked
35
+ * under the witness key: `witnessPublicKeyPem` when the reader
36
+ * pins one, otherwise the first receipt key on a `same-org` row.
37
+ * Pinning one of those keys and leaving the other to the file
38
+ * does not verify: a pinned effect key with any `same-org` row
39
+ * requires a pinned witness key, and a pinned witness key with
40
+ * any `self` row requires a pinned effect key. Agreement of a
41
+ * key with the files is not trust.
42
+ *
43
+ * Checkpoints are a fifth statement. Each row in `checkpoints.jsonl` must
44
+ * verify under one witness key: `checkpointPublicKeyPem` when the reader
45
+ * pins one, otherwise the first key the file carries. A line that does not
46
+ * parse, or that parses but is not a checkpoint, is named and `ok` is false.
47
+ * A missing file is not that: the tail line says there is no checkpoint.
48
+ * A row that does not
49
+ * verify is named and `ok` is false. The `prevCheckpointHash` chain is
50
+ * checked with `findCheckpointChainBreak`. The tail uses only the prefix
51
+ * that verified. A key taken from the file shows the checkpoints agree
52
+ * with each other, not that the key was ever trusted.
14
53
  *
15
54
  * And a fourth, which matters most and is the easiest to fudge: **which key**.
16
55
  * A ledger checked against the key sitting next to it is internally
@@ -20,31 +59,119 @@
20
59
  * the answer rather than a footnote, and `publicKeyPem` lets a reader pin a
21
60
  * copy they hold themselves.
22
61
  */
23
- import { existsSync, readFileSync } from "node:fs";
62
+ import { createPublicKey } from "node:crypto";
63
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
24
64
  import { join } from "node:path";
25
- import { findDecisionRecordChainBreak, verifyDecisionRecord } from "@cedulon/core";
26
- import { readLedgerManifest } from "./ledger-manifest.js";
27
- /** Every decisions file this directory holds, oldest piece first. */
28
- function decisionFiles(dir) {
29
- const manifest = readLedgerManifest(dir);
30
- if (manifest && Array.isArray(manifest.pieces) && manifest.pieces.length > 0) {
31
- return manifest.pieces.map((p) => join(dir, p.decisions)).filter((p) => existsSync(p));
65
+ import { findCheckpointChainBreak, verifyCheckpoint, verifyCheckpointUnderPin, } from "@cedulon/checkpoint";
66
+ import { canonical, decisionRecordHash, findDecisionRecordChainBreak, verifyDecisionRecord } from "@cedulon/core";
67
+ import { coseFromHex, decodeCoseSign1, verifyCoseSign1 } from "@cedulon/cose";
68
+ import { verifyEffectExtract } from "@cedulon/effect-extract";
69
+ import { checkpointsPath, readCheckpointFile } from "./checkpoints.js";
70
+ import { sha256Canonical } from "./hash.js";
71
+ import { ledgerPiecePathProblem, readLedgerManifest, requireLedgerPiecePath, indexPath, manifestPath, parseIndexText, } from "./ledger-manifest.js";
72
+ /**
73
+ * A symbolic link is a named problem and is not read. Missing files are not
74
+ * this case. `lstatSync` does not follow the link.
75
+ */
76
+ function symbolicLinkProblem(path) {
77
+ try {
78
+ if (lstatSync(path).isSymbolicLink())
79
+ return `symbolic link: ${path}`;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ return null;
85
+ }
86
+ /**
87
+ * A piece path that leaves the directory is a problem and is not opened.
88
+ * A piece that is a symbolic link is named and is not opened. A decisions or
89
+ * effects file the manifest names and the disk lacks is `missing piece`.
90
+ * `existsSync` runs only after the path and link checks.
91
+ */
92
+ function takePieceFile(dir, rel, problems, read) {
93
+ const problem = ledgerPiecePathProblem(dir, rel);
94
+ if (problem !== null) {
95
+ problems.push(problem);
96
+ return null;
97
+ }
98
+ const path = requireLedgerPiecePath(dir, rel);
99
+ const link = symbolicLinkProblem(path);
100
+ if (link !== null) {
101
+ problems.push(link);
102
+ return null;
103
+ }
104
+ if (!read)
105
+ return null;
106
+ // The writer creates the effects file for every piece, including a piece
107
+ // that has no effects yet (an empty file). A name in the manifest with no
108
+ // file on disk is not that empty piece.
109
+ if (!existsSync(path)) {
110
+ problems.push(`missing piece: ${rel}`);
111
+ return null;
112
+ }
113
+ return path;
114
+ }
115
+ /** Decision and effect files this directory holds, oldest piece first. Inputs are confined and not read. */
116
+ function ledgerFiles(dir, problems) {
117
+ const manifestFile = manifestPath(dir);
118
+ const manifestLink = symbolicLinkProblem(manifestFile);
119
+ if (manifestLink !== null) {
120
+ problems.push(manifestLink);
121
+ return { decisions: [], effects: [] };
32
122
  }
33
- const tek = join(dir, "decisions.jsonl");
34
- return existsSync(tek) ? [tek] : [];
123
+ if (existsSync(manifestFile)) {
124
+ try {
125
+ const manifest = readLedgerManifest(dir);
126
+ if (manifest && Array.isArray(manifest.pieces) && manifest.pieces.length > 0) {
127
+ return pieceFiles(dir, manifest.pieces, problems);
128
+ }
129
+ }
130
+ catch (err) {
131
+ problems.push(err instanceof Error ? err.message : "ledger-manifest-unreadable");
132
+ return { decisions: [], effects: [] };
133
+ }
134
+ }
135
+ return legacyFiles(dir);
136
+ }
137
+ function legacyFiles(dir) {
138
+ const decisionsPath = join(dir, "decisions.jsonl");
139
+ const effectsPath = join(dir, "effects.jsonl");
140
+ return {
141
+ decisions: existsSync(decisionsPath) ? [decisionsPath] : [],
142
+ effects: existsSync(effectsPath) ? [effectsPath] : [],
143
+ };
35
144
  }
36
- function effectFiles(dir) {
37
- const manifest = readLedgerManifest(dir);
38
- if (manifest && Array.isArray(manifest.pieces) && manifest.pieces.length > 0) {
39
- return manifest.pieces.map((p) => join(dir, p.effects)).filter((p) => existsSync(p));
145
+ function pieceFiles(dir, pieces, problems) {
146
+ const decisions = [];
147
+ const effects = [];
148
+ for (const p of pieces) {
149
+ const decision = takePieceFile(dir, p.decisions, problems, true);
150
+ if (decision)
151
+ decisions.push(decision);
152
+ const effect = takePieceFile(dir, p.effects, problems, true);
153
+ if (effect)
154
+ effects.push(effect);
155
+ takePieceFile(dir, p.inputs, problems, false);
40
156
  }
41
- const tek = join(dir, "effects.jsonl");
42
- return existsSync(tek) ? [tek] : [];
157
+ return { decisions, effects };
43
158
  }
44
159
  /** Parses JSONL, reporting the line a bad row sits on rather than throwing. */
45
160
  function readJsonl(path, problems) {
46
161
  const out = [];
47
- const text = readFileSync(path, "utf8");
162
+ const link = symbolicLinkProblem(path);
163
+ if (link !== null) {
164
+ problems.push(link);
165
+ return out;
166
+ }
167
+ let text;
168
+ try {
169
+ text = readFileSync(path, "utf8");
170
+ }
171
+ catch {
172
+ problems.push(`unreadable file: ${path}`);
173
+ return out;
174
+ }
48
175
  const lines = text.split("\n");
49
176
  for (let i = 0; i < lines.length; i += 1) {
50
177
  const line = lines[i].trim();
@@ -59,12 +186,441 @@ function readJsonl(path, problems) {
59
186
  }
60
187
  return out;
61
188
  }
189
+ function isPublicKeyPem(pem) {
190
+ return typeof pem === "string" && pem.includes("BEGIN PUBLIC KEY");
191
+ }
192
+ /**
193
+ * RFC 9864 assigns alg -19 to Ed25519 only. A key that parses as something
194
+ * else is refused here. The same check belongs in `verifyCoseSign1`; this
195
+ * package does not patch that dependency.
196
+ * A PEM that does not parse is left to the verifier, which already fails it.
197
+ */
198
+ function ed25519Refusal(pem) {
199
+ if (typeof pem !== "string" || pem.trim() === "")
200
+ return null;
201
+ try {
202
+ const key = createPublicKey(pem);
203
+ if (key.asymmetricKeyType === "ed25519")
204
+ return null;
205
+ return "public key is not ed25519";
206
+ }
207
+ catch {
208
+ return null;
209
+ }
210
+ }
211
+ function noteEd25519(pem, problems) {
212
+ const refusal = ed25519Refusal(pem);
213
+ if (refusal)
214
+ problems.push(refusal);
215
+ }
216
+ /**
217
+ * The writer signs `{ ref, effectHash, witnessClass, resultHash }` as COSE
218
+ * Sign1 and a one-row extract. A row with either missing is not bound. Both
219
+ * must verify under the key the caller settled on for this row's witness
220
+ * class — never a key this row brought by itself.
221
+ */
222
+ function effectSignatureCoversRow(effect, effectKey) {
223
+ const coseHex = effect.attestation?.coseHex;
224
+ const hasAtt = typeof coseHex === "string" && coseHex !== "";
225
+ const hasReceipt = effect.receipt !== undefined;
226
+ if (!hasAtt || !hasReceipt || !effect.receipt || !isPublicKeyPem(effectKey))
227
+ return false;
228
+ if (ed25519Refusal(effectKey))
229
+ return false;
230
+ const ref = effect.row?.ref;
231
+ const effectHash = effect.row?.effectHash;
232
+ const witnessClass = effect.witnessClass;
233
+ if (typeof ref !== "string" || typeof effectHash !== "string" || typeof witnessClass !== "string") {
234
+ return false;
235
+ }
236
+ const expected = canonical({
237
+ ref,
238
+ effectHash,
239
+ witnessClass,
240
+ resultHash: typeof effect.resultHash === "string" ? effect.resultHash : null,
241
+ });
242
+ let decodedPayload;
243
+ try {
244
+ const decoded = decodeCoseSign1(coseFromHex(coseHex));
245
+ decodedPayload = JSON.parse(Buffer.from(decoded.payload).toString("utf8"));
246
+ }
247
+ catch {
248
+ return false;
249
+ }
250
+ try {
251
+ if (canonical(decodedPayload) !== expected)
252
+ return false;
253
+ }
254
+ catch {
255
+ return false;
256
+ }
257
+ try {
258
+ if (!verifyCoseSign1(coseFromHex(coseHex), effectKey, "application/json"))
259
+ return false;
260
+ }
261
+ catch {
262
+ return false;
263
+ }
264
+ try {
265
+ if (!verifyEffectExtract(effect.receipt, effectKey))
266
+ return false;
267
+ }
268
+ catch {
269
+ return false;
270
+ }
271
+ let signedRow;
272
+ try {
273
+ signedRow = effect.receipt.body.effects[0];
274
+ }
275
+ catch {
276
+ return false;
277
+ }
278
+ if (signedRow == null || !effect.row)
279
+ return false;
280
+ try {
281
+ if (canonical(signedRow) !== canonical(effect.row))
282
+ return false;
283
+ }
284
+ catch {
285
+ return false;
286
+ }
287
+ return true;
288
+ }
289
+ function effectProblem(effect, ref) {
290
+ const signedClass = effect.receipt?.body?.effects?.[0]?.effectClass;
291
+ const diskClass = effect.row?.effectClass;
292
+ if (typeof signedClass === "string" && signedClass !== diskClass) {
293
+ return `effect class is not the signed class: ref ${ref ?? "(missing)"}`;
294
+ }
295
+ return `effect attestation does not cover the row: ref ${ref ?? "(missing)"}`;
296
+ }
297
+ function firstReceiptKey(rows, sameOrg) {
298
+ for (const row of rows) {
299
+ if ((row.witnessClass === "same-org") !== sameOrg)
300
+ continue;
301
+ const pem = row.receipt?.publicKeyPem;
302
+ if (isPublicKeyPem(pem))
303
+ return pem;
304
+ }
305
+ return null;
306
+ }
307
+ const AGREE = "this shows the files are internally consistent, not that the key was ever trusted. Pin a key you hold to check that.";
308
+ function witnessTrustOf(pinned, taken, sameOrgCount) {
309
+ if (sameOrgCount === 0) {
310
+ return {
311
+ source: "none",
312
+ publicKeyPem: null,
313
+ note: "no same-org effects, so no witness key was used",
314
+ };
315
+ }
316
+ if (pinned !== "") {
317
+ return {
318
+ source: "pinned",
319
+ publicKeyPem: pinned,
320
+ note: "verified against a key the reader supplied, not one taken from these files",
321
+ };
322
+ }
323
+ if (taken) {
324
+ return {
325
+ source: "in-ledger",
326
+ publicKeyPem: taken,
327
+ note: `verified against the key carried in the same-org effects themselves: ${AGREE}`,
328
+ };
329
+ }
330
+ return {
331
+ source: "none",
332
+ publicKeyPem: null,
333
+ note: "no witness key was found in these files",
334
+ };
335
+ }
336
+ function firstCheckpointKey(rows) {
337
+ for (const row of rows) {
338
+ if (isPublicKeyPem(row.publicKeyPem))
339
+ return row.publicKeyPem;
340
+ }
341
+ return null;
342
+ }
343
+ function checkpointTrustOf(pinned, taken, checkpointCount) {
344
+ if (checkpointCount === 0) {
345
+ return {
346
+ source: "none",
347
+ publicKeyPem: null,
348
+ note: "no checkpoints, so no checkpoint key was used",
349
+ };
350
+ }
351
+ if (pinned !== "") {
352
+ return {
353
+ source: "pinned",
354
+ publicKeyPem: pinned,
355
+ note: "verified against a key the reader supplied, not one taken from these files",
356
+ };
357
+ }
358
+ if (taken) {
359
+ return {
360
+ source: "in-ledger",
361
+ publicKeyPem: taken,
362
+ note: "verified against the key carried in the checkpoints themselves: this shows the files are " +
363
+ "internally consistent, not that the key was ever trusted. Pin a key you hold to check that.",
364
+ };
365
+ }
366
+ return {
367
+ source: "none",
368
+ publicKeyPem: null,
369
+ note: "no checkpoint key was found in these files",
370
+ };
371
+ }
372
+ /** One key for every row. A pin ignores the key a row carries. */
373
+ function checkpointRowVerifies(row, key) {
374
+ try {
375
+ if (key && ed25519Refusal(key))
376
+ return false;
377
+ if (key)
378
+ return verifyCheckpointUnderPin(row, key) && verifyCheckpoint(row, key);
379
+ return verifyCheckpoint(row);
380
+ }
381
+ catch {
382
+ return false;
383
+ }
384
+ }
385
+ function effectTrustOf(pinned, taken, effectCount) {
386
+ if (effectCount === 0) {
387
+ return {
388
+ source: "none",
389
+ publicKeyPem: null,
390
+ note: "no effects, so no effect key was used",
391
+ };
392
+ }
393
+ if (pinned !== "") {
394
+ return {
395
+ source: "pinned",
396
+ publicKeyPem: pinned,
397
+ note: "verified against a key the reader supplied, not one taken from these files",
398
+ };
399
+ }
400
+ if (taken) {
401
+ return {
402
+ source: "in-ledger",
403
+ publicKeyPem: taken,
404
+ note: `verified against the key carried in the self effects themselves: ${AGREE}`,
405
+ };
406
+ }
407
+ return {
408
+ source: "none",
409
+ publicKeyPem: null,
410
+ note: "no effect key was found in these files",
411
+ };
412
+ }
413
+ const INDEX_NONE = "index: none (cannot check for removed records)";
414
+ const EFFECT_COMPLETENESS_UNCHECKED = "effect completeness was not checked";
415
+ const EFFECT_COMPLETENESS_CHECKED = "effect completeness checked";
416
+ const TAIL_NONE = "tail: no checkpoint; removing the newest records with their effects is not detectable from these files";
417
+ /**
418
+ * A torn last line is the crash the writer already documents: earlier lines
419
+ * parsed, so that tail is skipped. Any other line that is not JSON, and a
420
+ * file whose every line fails to parse, is a problem.
421
+ */
422
+ function indexLineProblems(text) {
423
+ const lines = text.split("\n");
424
+ const filled = [];
425
+ for (let i = 0; i < lines.length; i += 1) {
426
+ if (lines[i] !== "")
427
+ filled.push({ n: i + 1, line: lines[i] });
428
+ }
429
+ const problems = [];
430
+ let parsedOk = 0;
431
+ for (let i = 0; i < filled.length; i += 1) {
432
+ const last = i === filled.length - 1;
433
+ try {
434
+ JSON.parse(filled[i].line);
435
+ parsedOk += 1;
436
+ }
437
+ catch {
438
+ if (!last || parsedOk === 0)
439
+ problems.push(`index line ${filled[i].n} is not JSON`);
440
+ }
441
+ }
442
+ return problems;
443
+ }
444
+ function effectRefsInIndex(text) {
445
+ const refs = new Set();
446
+ for (const line of parseIndexText(text)) {
447
+ if (line.hasEffect || line.kind === "effect")
448
+ refs.add(line.ref);
449
+ }
450
+ return refs;
451
+ }
452
+ /**
453
+ * `null` means the index is not there or could not be read, so effect
454
+ * completeness was not checked. An empty set means it was checked and it
455
+ * marks no effect.
456
+ */
457
+ function indexStatement(dir, decisionRefs) {
458
+ const indexFile = indexPath(dir);
459
+ const indexLink = symbolicLinkProblem(indexFile);
460
+ if (indexLink !== null) {
461
+ return { index: { present: true, missing: 0, line: indexLink }, problems: [indexLink], effectRefs: null };
462
+ }
463
+ if (!existsSync(indexFile)) {
464
+ return { index: { present: false, missing: 0, line: INDEX_NONE }, problems: [], effectRefs: null };
465
+ }
466
+ let text = "";
467
+ try {
468
+ text = readFileSync(indexFile, "utf8");
469
+ }
470
+ catch {
471
+ const line = "index could not be read";
472
+ return { index: { present: true, missing: 0, line }, problems: [line], effectRefs: null };
473
+ }
474
+ const parseProblems = indexLineProblems(text);
475
+ const lines = parseIndexText(text);
476
+ const named = new Set();
477
+ for (const line of lines)
478
+ named.add(line.ref);
479
+ const effectRefs = effectRefsInIndex(text);
480
+ let missing = 0;
481
+ for (const ref of named) {
482
+ if (!decisionRefs.has(ref))
483
+ missing += 1;
484
+ }
485
+ if (missing > 0) {
486
+ const line = `index names ${missing} record(s) the ledger no longer holds`;
487
+ return { index: { present: true, missing, line }, problems: [...parseProblems, line], effectRefs };
488
+ }
489
+ return {
490
+ index: { present: true, missing: 0, line: `index: ${named.size} ref(s), each still a decision` },
491
+ problems: parseProblems,
492
+ effectRefs,
493
+ };
494
+ }
495
+ function noteEffectCompleteness(effectRefs, bound, problems) {
496
+ if (effectRefs === null)
497
+ return EFFECT_COMPLETENESS_UNCHECKED;
498
+ for (const ref of effectRefs) {
499
+ if (!bound.has(ref)) {
500
+ problems.push(`index says ref ${ref} has an effect but no bound effect row`);
501
+ }
502
+ }
503
+ return EFFECT_COMPLETENESS_CHECKED;
504
+ }
505
+ function tailStatement(dir, records, checkpointPublicKeyPem) {
506
+ const checkpointFile = checkpointsPath(dir);
507
+ const checkpointLink = symbolicLinkProblem(checkpointFile);
508
+ if (checkpointLink !== null) {
509
+ const pinned = checkpointPublicKeyPem.trim();
510
+ return {
511
+ tail: { line: TAIL_NONE, checkpoint: null },
512
+ checkpointTrust: checkpointTrustOf(pinned, null, 0),
513
+ problems: [checkpointLink],
514
+ };
515
+ }
516
+ const loaded = readCheckpointFile(dir);
517
+ const rows = loaded.rows;
518
+ const pinned = checkpointPublicKeyPem.trim();
519
+ const taken = firstCheckpointKey(rows);
520
+ const key = pinned !== "" ? pinned : taken;
521
+ const checkpointTrust = checkpointTrustOf(pinned, taken, rows.length);
522
+ const problems = [...loaded.problems];
523
+ noteEd25519(key, problems);
524
+ for (let i = 0; i < rows.length; i += 1) {
525
+ if (!checkpointRowVerifies(rows[i], key)) {
526
+ problems.push(`checkpoint signature does not verify: checkpoint ${i}`);
527
+ }
528
+ }
529
+ let brk = null;
530
+ try {
531
+ brk = findCheckpointChainBreak(rows, key ?? undefined);
532
+ }
533
+ catch (err) {
534
+ const message = err instanceof Error ? err.message : String(err);
535
+ problems.push(`checkpoint chain could not be checked: ${message}`);
536
+ brk = rows.length > 0 ? { index: 0, reason: "bad-signature" } : null;
537
+ }
538
+ if (brk && brk.reason !== "bad-signature") {
539
+ problems.push(`checkpoint chain breaks at checkpoint ${brk.index} (${brk.reason}): prevCheckpointHash does not match`);
540
+ }
541
+ const covered = brk ? rows.slice(0, brk.index) : rows;
542
+ const newest = covered.length > 0 ? covered[covered.length - 1] : null;
543
+ if (!newest) {
544
+ return { tail: { line: TAIL_NONE, checkpoint: null }, checkpointTrust, problems };
545
+ }
546
+ const claims = newest.claims;
547
+ const receiptCount = typeof claims.receiptCount === "number" ? claims.receiptCount : null;
548
+ const chainHeadHash = typeof claims.chainHeadHash === "string" ? claims.chainHeadHash : null;
549
+ let ledgerHoldsRecord = null;
550
+ if (chainHeadHash) {
551
+ ledgerHoldsRecord = records.some((rec) => {
552
+ try {
553
+ return decisionRecordHash(rec) === chainHeadHash;
554
+ }
555
+ catch {
556
+ return false;
557
+ }
558
+ });
559
+ }
560
+ if (receiptCount !== null && records.length < receiptCount) {
561
+ problems.push(`checkpoint covers ${receiptCount} record(s); the ledger holds ${records.length}`);
562
+ }
563
+ if (ledgerHoldsRecord === false) {
564
+ problems.push("newest checkpoint names a record the ledger no longer holds");
565
+ }
566
+ const after = receiptCount === null ? null : records.length - receiptCount;
567
+ const line = after === null
568
+ ? "tail: newest checkpoint carries no record count to compare"
569
+ : `tail: ${after < 0 ? 0 : after} record(s) after the newest checkpoint are not covered`;
570
+ return {
571
+ tail: { line, checkpoint: { receiptCount, chainHeadHash, ledgerHoldsRecord } },
572
+ checkpointTrust,
573
+ problems,
574
+ };
575
+ }
576
+ function unreadableResult(dir, problem) {
577
+ const trust = { source: "none", publicKeyPem: null, note: problem };
578
+ return {
579
+ ok: false,
580
+ directory: dir,
581
+ decisions: 0,
582
+ effects: 0,
583
+ signaturesValid: 0,
584
+ signaturesInvalid: 0,
585
+ chainBreakAt: null,
586
+ effectsBound: 0,
587
+ effectsOrphaned: 0,
588
+ trust,
589
+ effectTrust: trust,
590
+ witnessTrust: trust,
591
+ checkpointTrust: trust,
592
+ index: { present: false, missing: 0, line: INDEX_NONE },
593
+ effectCompleteness: EFFECT_COMPLETENESS_UNCHECKED,
594
+ tail: { line: TAIL_NONE, checkpoint: null },
595
+ problems: [problem],
596
+ };
597
+ }
598
+ function isDecisionRow(row) {
599
+ if (!row || typeof row !== "object" || Array.isArray(row))
600
+ return false;
601
+ const claims = row.claims;
602
+ return claims !== null && typeof claims === "object";
603
+ }
62
604
  export async function verifyLedger(dir, opts = {}) {
605
+ try {
606
+ return await verifyLedgerUnchecked(dir, opts);
607
+ }
608
+ catch (err) {
609
+ const problem = err instanceof Error ? err.message : "ledger could not be read";
610
+ return unreadableResult(dir, problem);
611
+ }
612
+ }
613
+ async function verifyLedgerUnchecked(dir, opts = {}) {
63
614
  const problems = [];
64
- const kararDosyalari = decisionFiles(dir);
615
+ const files = ledgerFiles(dir, problems);
616
+ const kararDosyalari = files.decisions;
65
617
  const records = [];
66
618
  for (const path of kararDosyalari) {
67
619
  for (const row of readJsonl(path, problems)) {
620
+ if (!isDecisionRow(row)) {
621
+ problems.push("record is not a decision");
622
+ continue;
623
+ }
68
624
  records.push(row);
69
625
  }
70
626
  }
@@ -72,6 +628,12 @@ export async function verifyLedger(dir, opts = {}) {
72
628
  // would let a deleted ledger pass as a verified one.
73
629
  if (records.length === 0) {
74
630
  problems.push("no decisions found: this directory holds no ledger to verify");
631
+ noteEd25519(opts.publicKeyPem, problems);
632
+ noteEd25519(opts.effectPublicKeyPem, problems);
633
+ const emptyIndex = indexStatement(dir, new Set());
634
+ const emptyTail = tailStatement(dir, records, opts.checkpointPublicKeyPem ?? "");
635
+ const effectCompleteness = noteEffectCompleteness(emptyIndex.effectRefs, new Set(), problems);
636
+ problems.push(...emptyIndex.problems, ...emptyTail.problems);
75
637
  return {
76
638
  ok: false,
77
639
  directory: dir,
@@ -83,6 +645,12 @@ export async function verifyLedger(dir, opts = {}) {
83
645
  effectsBound: 0,
84
646
  effectsOrphaned: 0,
85
647
  trust: { source: "none", publicKeyPem: null, note: "no records, so no key was used" },
648
+ effectTrust: effectTrustOf(opts.effectPublicKeyPem?.trim() ?? "", null, 0),
649
+ witnessTrust: witnessTrustOf(opts.witnessPublicKeyPem?.trim() ?? "", null, 0),
650
+ checkpointTrust: emptyTail.checkpointTrust,
651
+ index: emptyIndex.index,
652
+ effectCompleteness,
653
+ tail: emptyTail.tail,
86
654
  problems,
87
655
  };
88
656
  }
@@ -103,11 +671,14 @@ export async function verifyLedger(dir, opts = {}) {
103
671
  };
104
672
  let signaturesValid = 0;
105
673
  let signaturesInvalid = 0;
674
+ const recordRefused = ed25519Refusal(anahtar);
675
+ if (recordRefused)
676
+ problems.push(recordRefused);
106
677
  for (let i = 0; i < records.length; i += 1) {
107
678
  const rec = records[i];
108
679
  let gecerli = false;
109
680
  try {
110
- gecerli = verifyDecisionRecord(rec, anahtar ?? undefined);
681
+ gecerli = !recordRefused && verifyDecisionRecord(rec, anahtar ?? undefined);
111
682
  }
112
683
  catch {
113
684
  gecerli = false;
@@ -124,36 +695,154 @@ export async function verifyLedger(dir, opts = {}) {
124
695
  // Returns `{ index, reason }` or null. The reason is carried through: a
125
696
  // broken link and a bad signature are different accidents, and a reader
126
697
  // chasing one should not be told the other.
127
- const brk = findDecisionRecordChainBreak(records, anahtar ? [anahtar] : undefined);
698
+ let brk = null;
699
+ try {
700
+ brk = findDecisionRecordChainBreak(records, anahtar ? [anahtar] : undefined);
701
+ }
702
+ catch (err) {
703
+ problems.push(err instanceof Error ? err.message : "record shape is not a decision");
704
+ }
128
705
  const chainBreakAt = brk ? brk.index : null;
129
706
  if (brk) {
130
707
  problems.push(`chain breaks at record ${brk.index} (${brk.reason}): a row was changed, removed or inserted`);
131
708
  }
132
- // Each effect names the decision it belongs to. An effect whose ref is on no
133
- // record is an action with no decision behind it — the loudest thing this
134
- // file can find, so it is counted rather than summarised away.
709
+ // An effect is bound only when a decision with that ref records the same
710
+ // effectHash. A `:threw` row relaxes the hash and nothing else: it binds
711
+ // only when some decision on that ref is an allow and the row's class is
712
+ // exactly that decision's effectClass plus `:threw`. A deny did not throw,
713
+ // and an allow of a different tool is not this class. A `duplicate-effect`
714
+ // row must carry the writer's fixed refusal hash. The attestation plus
715
+ // receipt still verify under one effect key. A mismatch is named and counts
716
+ // as orphaned. A second primary row for a ref that already has one is named
717
+ // and is not bound, even when the first row itself did not bind.
135
718
  const refler = new Set();
719
+ const hashesByRef = new Map();
720
+ const allowedClassesByRef = new Map();
136
721
  for (const r of records) {
137
- if (typeof r.claims?.ref === "string")
138
- refler.add(r.claims.ref);
722
+ if (typeof r.claims?.ref !== "string")
723
+ continue;
724
+ refler.add(r.claims.ref);
725
+ const recorded = r.claims?.effectHash;
726
+ if (typeof recorded === "string") {
727
+ const bag = hashesByRef.get(r.claims.ref) ?? new Set();
728
+ bag.add(recorded);
729
+ hashesByRef.set(r.claims.ref, bag);
730
+ }
731
+ if (r.claims.decision === "allow" && typeof r.claims.effectClass === "string" && r.claims.effectClass !== "") {
732
+ const allowed = allowedClassesByRef.get(r.claims.ref) ?? new Set();
733
+ allowed.add(r.claims.effectClass);
734
+ allowedClassesByRef.set(r.claims.ref, allowed);
735
+ }
736
+ }
737
+ const effectRows = [];
738
+ for (const path of files.effects) {
739
+ for (const row of readJsonl(path, problems)) {
740
+ if (!row || typeof row !== "object" || Array.isArray(row)) {
741
+ problems.push("effect is not an effect row");
742
+ continue;
743
+ }
744
+ effectRows.push(row);
745
+ }
746
+ }
747
+ const pinnedEffect = opts.effectPublicKeyPem?.trim() ?? "";
748
+ const pinnedWitness = opts.witnessPublicKeyPem?.trim() ?? "";
749
+ const selfKeyTaken = firstReceiptKey(effectRows, false);
750
+ const sameKeyTaken = firstReceiptKey(effectRows, true);
751
+ const effectKey = pinnedEffect !== "" ? pinnedEffect : selfKeyTaken;
752
+ const witnessKey = pinnedWitness !== "" ? pinnedWitness : sameKeyTaken;
753
+ const selfCount = effectRows.filter((row) => row.witnessClass !== "same-org").length;
754
+ const sameCount = effectRows.length - selfCount;
755
+ const effectTrust = effectTrustOf(pinnedEffect, selfKeyTaken, selfCount);
756
+ const witnessTrust = witnessTrustOf(pinnedWitness, sameKeyTaken, sameCount);
757
+ if (pinnedEffect !== "" && pinnedWitness === "" && sameCount > 0) {
758
+ problems.push("same-org rows need --witness-key when --effect-key is pinned");
759
+ }
760
+ if (pinnedWitness !== "" && pinnedEffect === "" && selfCount > 0) {
761
+ problems.push("self rows need --effect-key when --witness-key is pinned");
139
762
  }
140
- let effects = 0;
763
+ noteEd25519(effectKey, problems);
764
+ noteEd25519(witnessKey, problems);
765
+ let effects = effectRows.length;
141
766
  let effectsBound = 0;
142
767
  let effectsOrphaned = 0;
143
- for (const path of effectFiles(dir)) {
144
- for (const row of readJsonl(path, problems)) {
145
- effects += 1;
146
- const e = row;
147
- const ref = typeof e.row?.ref === "string" ? e.row.ref : null;
148
- if (ref !== null && refler.has(ref)) {
149
- effectsBound += 1;
768
+ const boundPrimaryRefs = new Set();
769
+ const primarySeen = new Set();
770
+ const duplicateSeen = new Set();
771
+ for (const e of effectRows) {
772
+ const ref = typeof e.row?.ref === "string" ? e.row.ref : null;
773
+ const effectHash = typeof e.row?.effectHash === "string" ? e.row.effectHash : null;
774
+ const effectClass = typeof e.row?.effectClass === "string" ? e.row.effectClass : "";
775
+ if (effectClass === "duplicate-effect") {
776
+ const sig = typeof e.attestation?.coseHex === "string" ? e.attestation.coseHex : "";
777
+ const dupKey = `${ref ?? ""}\0${sig}`;
778
+ if (sig !== "" && duplicateSeen.has(dupKey)) {
779
+ problems.push(`duplicate-effect row repeated: ref ${ref ?? "(missing)"}`);
780
+ continue;
781
+ }
782
+ if (sig !== "")
783
+ duplicateSeen.add(dupKey);
784
+ const fixed = ref === null ? null : sha256Canonical({ refused: "duplicate-effect", ref });
785
+ const boundRefusal = ref !== null && refler.has(ref) && effectHash !== null && effectHash === fixed;
786
+ if (!boundRefusal) {
787
+ effectsOrphaned += 1;
788
+ problems.push(ref === null || !refler.has(ref)
789
+ ? `duplicate-effect with no decision: ref ${ref ?? "(missing)"}`
790
+ : `duplicate-effect hash is not the fixed refusal: ref ${ref}`);
791
+ continue;
150
792
  }
151
- else {
793
+ if (!effectSignatureCoversRow(e, e.witnessClass === "same-org" ? witnessKey : effectKey)) {
152
794
  effectsOrphaned += 1;
153
- problems.push(`effect with no decision: ref ${ref ?? "(missing)"}`);
795
+ problems.push(effectProblem(e, ref));
796
+ continue;
154
797
  }
798
+ effectsBound += 1;
799
+ continue;
800
+ }
801
+ if (ref !== null && primarySeen.has(ref)) {
802
+ problems.push(`ref ${ref} has more than one effect row`);
803
+ continue;
804
+ }
805
+ if (ref !== null)
806
+ primarySeen.add(ref);
807
+ const hashes = ref === null ? undefined : hashesByRef.get(ref);
808
+ const hashMatch = effectHash !== null && hashes?.has(effectHash) === true;
809
+ if (effectClass.endsWith(":threw")) {
810
+ const allowed = ref === null ? undefined : allowedClassesByRef.get(ref);
811
+ let thrownOk = false;
812
+ if (allowed) {
813
+ for (const cls of allowed) {
814
+ if (`${cls}:threw` === effectClass) {
815
+ thrownOk = true;
816
+ break;
817
+ }
818
+ }
819
+ }
820
+ if (!thrownOk) {
821
+ effectsOrphaned += 1;
822
+ problems.push(`thrown effect does not match an allowed decision: ref ${ref ?? "(missing)"}`);
823
+ continue;
824
+ }
825
+ }
826
+ else if (!hashMatch) {
827
+ effectsOrphaned += 1;
828
+ problems.push(ref === null || !refler.has(ref)
829
+ ? `effect with no decision: ref ${ref ?? "(missing)"}`
830
+ : `effect hash does not match its decision: ref ${ref}`);
831
+ continue;
832
+ }
833
+ if (!effectSignatureCoversRow(e, e.witnessClass === "same-org" ? witnessKey : effectKey)) {
834
+ effectsOrphaned += 1;
835
+ problems.push(effectProblem(e, ref));
836
+ continue;
155
837
  }
838
+ effectsBound += 1;
839
+ if (ref !== null)
840
+ boundPrimaryRefs.add(ref);
156
841
  }
842
+ const indexed = indexStatement(dir, refler);
843
+ const effectCompleteness = noteEffectCompleteness(indexed.effectRefs, boundPrimaryRefs, problems);
844
+ const tailed = tailStatement(dir, records, opts.checkpointPublicKeyPem ?? "");
845
+ problems.push(...indexed.problems, ...tailed.problems);
157
846
  const ok = problems.length === 0 && signaturesInvalid === 0 && chainBreakAt === null && effectsOrphaned === 0;
158
847
  return {
159
848
  ok,
@@ -166,6 +855,12 @@ export async function verifyLedger(dir, opts = {}) {
166
855
  effectsBound,
167
856
  effectsOrphaned,
168
857
  trust,
858
+ effectTrust,
859
+ witnessTrust,
860
+ checkpointTrust: tailed.checkpointTrust,
861
+ index: indexed.index,
862
+ effectCompleteness,
863
+ tail: tailed.tail,
169
864
  problems,
170
865
  };
171
866
  }