@sanning/proof 0.10.0 → 0.11.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.
package/src/chain.ts CHANGED
@@ -55,6 +55,7 @@
55
55
  // and can go.
56
56
 
57
57
  import { hexToBytes, sha256Hex } from "./crypto.js";
58
+ import { jcsParse } from "./verifier.js";
58
59
  import { resolveTraceEvents } from "./evidence.js";
59
60
 
60
61
  /**
@@ -203,7 +204,25 @@ function decodeRecord(hex: unknown): { record: EventRecord; bytes: Uint8Array }
203
204
  if (typeof hex !== "string" || hex.length === 0) return null;
204
205
  try {
205
206
  const bytes = hexToBytes(hex);
206
- const record = JSON.parse(new TextDecoder().decode(bytes)) as EventRecord;
207
+ // PINNED STRICT via `jcsParse`, and the twin at
208
+ // `src/sanning_proof/chain.py` is `jcs_loads` (proof#149). This was the
209
+ // LAST lenient decode pair in either kernel, and it was lenient on BOTH
210
+ // sides in different directions, so it agreed nowhere while looking like
211
+ // it agreed everywhere:
212
+ //
213
+ // * A bare `new TextDecoder()` here eats a leading byte-order mark and
214
+ // substitutes U+FFFD for every invalid sequence, so `BOM + JCS(r)`, a
215
+ // raw `0x80`, a CESU-8 surrogate pair and a truncated UTF-8 sequence
216
+ // each joined a chain HERE and joined none there.
217
+ // * Python's `json.loads` accepted `NaN`, `Infinity` and `-Infinity`,
218
+ // which RFC 8785 cannot emit and this kernel's `JSON.parse` rejects,
219
+ // so a record carrying one joined a chain THERE and none here.
220
+ //
221
+ // Five split readings on one pair, in two directions, and no test in
222
+ // either kernel could see them: each kernel agreed with itself. That is
223
+ // why agreement between two lenient readers is not evidence of anything,
224
+ // and why the comparison has to run on the same bytes.
225
+ const record = jcsParse(bytes) as EventRecord;
207
226
  if (record === null || typeof record !== "object") return null;
208
227
  return { record, bytes };
209
228
  } catch {
package/src/cli.ts CHANGED
@@ -11,9 +11,29 @@
11
11
  // 0 verified
12
12
  // 1 a real verification failure (bad signature / tamper / broken inclusion)
13
13
  // 2 malformed bundle (unparseable, unknown spec major, body_hash mismatch)
14
- // 3 gateway-unavailable when an on-chain re-fetch was requested
14
+ // 3 UNDETERMINED: a gateway was unreachable when an on-chain re-fetch was
15
+ // requested, or the PACK is `incomplete` (see below)
16
+ //
17
+ // ── The headline is the PACK verdict, not the bundle status (proof#110) ──
18
+ //
19
+ // This command used to print `result.status` as its headline and map it to an
20
+ // exit code directly. That is the verdict on the signed BUNDLE, and it is not
21
+ // the verdict the rest of the product prints. `gradePack` in `./grading.ts` is
22
+ // the kernel's one rule for what a verification SURFACE may claim about a pack,
23
+ // the browser verify page calls it, and this command referenced it zero times.
24
+ // So the same file read `Incomplete` on the page and `VERIFIED`, exit 0, at the
25
+ // terminal: two answers to one question, from one kernel, which is the exact
26
+ // defect `grading.ts` was brought into the kernel to end.
27
+ //
28
+ // The two enums stay distinct, deliberately (`grading.ts` says so in its own
29
+ // comments, and `evidence-bundle.md` §3 does not carry `incomplete`): the
30
+ // BUNDLE status is still reported, unchanged, on its own line. What changed is
31
+ // which of the two is the headline and which one the exit code follows.
32
+ //
15
33
  // `payloadHashOk === null` (a withheld record) is semantics-undetermined and
16
- // surfaces as a per-event note — it does NOT fail the run.
34
+ // surfaces as a per-event note, and it does NOT fail the run. It does hold the
35
+ // PACK at `incomplete`, because a record nobody hashed is a record nobody
36
+ // checked, and exit 3 is how this command says "undetermined".
17
37
 
18
38
  import { readFile, readdir, stat, writeFile } from "node:fs/promises";
19
39
  import { realpathSync } from "node:fs";
@@ -23,17 +43,26 @@ import { fileURLToPath } from "node:url";
23
43
  import { isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
24
44
  import type { AgentProofResult } from "./agent-proof.js";
25
45
  import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js";
46
+
47
+ // What this command holds after asking for a chain reading: the reading, or a
48
+ // named statement that one could not be produced. Three states, not two.
49
+ type ChainOutcome = ChainReading | { unreadable: string };
26
50
  import type { ChainReading } from "./chain.js";
27
51
  import { composeExport } from "./compose.js";
28
52
  import type { ComposeExportOptions } from "./compose.js";
29
- import { utf8 } from "./crypto.js";
53
+ import { isHexField, utf8 } from "./crypto.js";
30
54
  import { verifyEvidenceBundle } from "./evidence.js";
31
55
  import type { AttestationRecord, EvidenceBundle, EvidenceBundleResult, ExportResult } from "./evidence.js";
56
+ import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
57
+ import { completenessNotes, gradePack, renderCause } from "./grading.js";
58
+ import type { DisclosureAccount, PackGrade } from "./grading.js";
32
59
 
33
60
  const EXIT_VERIFIED = 0;
34
61
  const EXIT_FAILED = 1;
35
62
  const EXIT_MALFORMED = 2;
36
- const EXIT_GATEWAY_UNAVAILABLE = 3;
63
+ // One code, two undetermined outcomes: a gateway we could not reach, and a pack
64
+ // whose records nobody hashed. Neither is an accusation, and neither is a pass.
65
+ const EXIT_UNDETERMINED = 3;
37
66
 
38
67
  interface Cli {
39
68
  out: (s: string) => void;
@@ -174,8 +203,21 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
174
203
  // evidence about the records in front of us, not a verdict: they are printed
175
204
  // and they never move `exit`, because a chain is not a completeness proof and
176
205
  // an exit code is exactly the kind of thing people read as one.
177
- const chains = await readChains(parsed);
178
- const exit = reportEvidence(result, bundlePath, gateways, io, chains);
206
+ //
207
+ // A READING THAT COULD NOT BE PRODUCED SAYS SO. This was an unguarded call:
208
+ // a throw out of `readChains` would have escaped the command entirely and
209
+ // exited 1, which is the code for "failed" and would have named the
210
+ // customer's evidence tampered over a fault in this reader. The Python twin
211
+ // had the opposite half of the same defect and swallowed the throw into "no
212
+ // event chain was found to follow in this bundle" (the gate on proof#154).
213
+ // Neither kernel is entitled to either answer, so both now print the third.
214
+ let chains: ChainOutcome;
215
+ try {
216
+ chains = await readChains(parsed);
217
+ } catch (e) {
218
+ chains = { unreadable: errMsg(e) };
219
+ }
220
+ const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs);
179
221
  // Coverage is reported only once the bundle parsed far enough to have events
180
222
  // to compare against; over a MALFORMED bundle there is nothing to say.
181
223
  if (logs && result.status !== "malformed") reportLogsCoverage(logs, result, io);
@@ -627,12 +669,28 @@ function sample(names: string[], max = 3): string {
627
669
  }
628
670
 
629
671
  // The --logs MAP lane decides each disclosed value's encoding: a clean,
630
- // even-length, lowercase-hex string is hex bytes; anything else is utf8 text.
631
- // (The verifier itself only distinguishes "string ⇒ hex" from "Uint8Array ⇒
632
- // raw bytes" — the human-facing encoding choice is made here. The DIRECTORY
633
- // lane never reaches this: its bytes are already bytes.)
672
+ // even-length hex string is hex bytes; anything else is utf8 text. (The
673
+ // verifier itself only distinguishes "string ⇒ hex" from "Uint8Array ⇒ raw
674
+ // bytes", so the human-facing encoding choice is made here. The DIRECTORY lane
675
+ // never reaches this: its bytes are already bytes.)
676
+ //
677
+ // EITHER CASE, through the one charset rule (proof#146). This test was
678
+ // `/^[0-9a-f]+$/`, lower case only, and it was the last copy of the hex rule
679
+ // in either kernel that answered differently from the others: the Python CLI
680
+ // passes every string to the kernel, and the kernel decodes it with the
681
+ // case-open `hexToBytes` on BOTH sides. So a disclosure map written in
682
+ // upper-case hex was read as 4 bytes of evidence there and as 8 bytes of TEXT
683
+ // here, and this command told the auditor their genuine, correctly disclosed
684
+ // evidence had FAILED: `proof verify --logs map.json` exited 3 in Python and
685
+ // 1 here, on one pack and one map. Telling an auditor their evidence is forged
686
+ // is the worst direction to be wrong in, which is the argument the Python
687
+ // twin's own docstring already makes about this exact lane.
688
+ //
689
+ // Pinned by `cross-kernel/run_cli.sh`'s `logs/hex-map-upper`, beside the
690
+ // lowercase lane that existed before and only ever exercised the one spelling
691
+ // the two commands happened to agree on.
634
692
  function decodeLogValue(v: string): Uint8Array | string {
635
- if (v.length > 0 && v.length % 2 === 0 && /^[0-9a-f]+$/.test(v)) return v; // hex passthrough
693
+ if (v.length > 0 && v.length % 2 === 0 && isHexField(v, v.length)) return v; // hex passthrough
636
694
  return utf8(v); // raw utf8 bytes
637
695
  }
638
696
 
@@ -666,7 +724,49 @@ function printUsage(io: Cli): void {
666
724
  io.out(" and takes precedence. An event with no disclosed");
667
725
  io.out(" bytes is undetermined (~), never a failure.");
668
726
  io.out("");
669
- io.out("Exit codes: 0 verified · 1 failed · 2 malformed · 3 gateway-unavailable");
727
+ io.out("The headline is the PACK verdict, the same one the verify page prints:");
728
+ io.out("a record nobody hashed leaves the pack INCOMPLETE even when the signed");
729
+ io.out("bundle itself is verified. Both verdicts are printed when they differ.");
730
+ io.out("");
731
+ io.out("Exit codes: 0 verified · 1 failed · 2 malformed · 3 undetermined");
732
+ io.out(" (an unreachable gateway, or an incomplete pack)");
733
+ }
734
+
735
+ /**
736
+ * The disclosure account this command can honestly make, from what the VERIFIER
737
+ * determined rather than from what the loader happened to read.
738
+ *
739
+ * `accountDisclosure` is offered by `grading.ts` as a convenience and is
740
+ * explicitly "NOT THE ONLY WAY TO BUILD AN ACCOUNT". It reads the side map
741
+ * only, and this command has a second disclosure lane the side map cannot see:
742
+ * `events[].content`, carried INSIDE the bundle and bound by the verifier
743
+ * without any `--logs` at all. Accounting off the side map would file every
744
+ * in-body disclosure as `missing` and print a shortfall the pack does not have,
745
+ * which is the shape of manufactured accusation `grading.ts` keeps warning
746
+ * about.
747
+ *
748
+ * So `present` is "the verifier reached a determination for this record's
749
+ * content" and `missing` is "it could not", which is the same tri-state that
750
+ * decides the verdict, so the causes printed can never contradict the headline.
751
+ * `extra` still comes from the side input, because bytes that bound to nothing
752
+ * are a fact about the pack that only the loader knows.
753
+ */
754
+ function cliDisclosureAccount(
755
+ result: EvidenceBundleResult,
756
+ logs: LoadedLogs | undefined,
757
+ ): DisclosureAccount {
758
+ const expected = result.events.map((e) => e.eventId);
759
+ const known = new Set(expected);
760
+ return {
761
+ expected,
762
+ present: result.events.filter((e) => e.contentOk !== null).map((e) => e.eventId),
763
+ missing: result.events.filter((e) => e.contentOk === null).map((e) => e.eventId),
764
+ // The loader hard-stops (exit 2) on a file it selected and could not read,
765
+ // and NAMES everything it skipped as a note, so nothing reaches here as an
766
+ // unusable disclosure bound to a named record.
767
+ unreadable: [],
768
+ extra: (logs?.suppliedIds ?? []).filter((id) => !known.has(id)),
769
+ };
670
770
  }
671
771
 
672
772
  function reportEvidence(
@@ -674,7 +774,9 @@ function reportEvidence(
674
774
  path: string,
675
775
  gateways: string[],
676
776
  io: Cli,
677
- chains: ChainReading,
777
+ chains: ChainOutcome,
778
+ parsed: unknown,
779
+ logs: LoadedLogs | undefined,
678
780
  ): number {
679
781
  if (result.status === "malformed") {
680
782
  io.err(paint("MALFORMED", R) + ` ${path}`);
@@ -765,17 +867,55 @@ function reportEvidence(
765
867
  reportExport(result.export, io);
766
868
  }
767
869
 
870
+ // What the pack SAYS it holds, against what it does (evidence-bundle.md
871
+ // §5.4). A pack that deviates from its own declaration FAILS here and exits
872
+ // 1, which is the point: every other check in this command is a check on a
873
+ // record that is present, so a trimmed hand-over passes all of them.
874
+ //
875
+ // A pack with NO declaration exits exactly as it did before §5.4. The check
876
+ // answers `partial` and the line below says so in words, and pack grading
877
+ // keeps its own verdict: every pack built before §5.4 carries no
878
+ // declaration, and re-grading them all as undetermined would be a change to
879
+ // the pinned 0/1/2/3 contract rather than a new check. A pack that DOES
880
+ // carry a declaration this reader cannot read is a different case and exits
881
+ // 3: the holder chose that field, so UNKNOWN is the honest answer.
882
+ const declaration = checkDeclaration(parsed, logs?.content);
883
+ const grade = gradePack(
884
+ result,
885
+ cliDisclosureAccount(result, logs),
886
+ completenessNotes(parsed),
887
+ declaration,
888
+ );
889
+
768
890
  io.out("");
769
- printRollup(result.status, result.assertedStatus, gateways, result.onChainChecked, io);
891
+ printRollup(grade.verdict, result.status, result.assertedStatus, gateways, result.onChainChecked, io);
892
+ io.out(
893
+ paint(
894
+ ` declaration: ${renderDeclarationSummary(declaration)}`,
895
+ declaration.verdict === "failed" ? R : declaration.verdict === "verified" ? DIM : Y,
896
+ ),
897
+ );
898
+ for (const n of declaration.notes) io.out(paint(` note: ${renderNote(n)}`, Y));
770
899
  for (const e of result.errors) io.out(paint(` note: ${e}`, DIM));
771
-
772
- const disclosed = result.events.filter((e) => e.contentOk !== null);
773
- if (disclosed.length > 0) {
774
- const verified = disclosed.filter((e) => e.contentOk === true).length;
775
- io.out(paint(` logs: ${verified}/${disclosed.length} disclosed verified`, DIM));
900
+ // Every reason the pack is not verified, in the kernel's own words. An
901
+ // `incomplete` with no cause is a surface saying "unknown" and unable to say
902
+ // why, which `grading.ts` calls out by name as a defect of its own.
903
+ for (const c of grade.causes) io.out(paint(` - ${renderCause(c, "the pack")}`, Y));
904
+
905
+ // The fraction describes the PACK, not the disclosure. It used to divide by
906
+ // the number of records that happened to disclose bytes, so a pack that
907
+ // disclosed two of three records printed `logs: 2/2`, a full score for a
908
+ // partial hand-over, and the one line a reader skims for exactly this.
909
+ if (result.events.length > 0) {
910
+ io.out(
911
+ paint(
912
+ ` logs: ${grade.recordsVerified}/${result.events.length} record(s) content-verified`,
913
+ DIM,
914
+ ),
915
+ );
776
916
  }
777
917
 
778
- return mapStatusToExit(result.status, result.errors);
918
+ return mapGradeToExit(grade, result);
779
919
  }
780
920
 
781
921
  /**
@@ -797,7 +937,19 @@ function reportEvidence(
797
937
  * was. A dangling link is something for a person to look at, not a machine
798
938
  * decision this check is entitled to make.
799
939
  */
800
- function reportChains(reading: ChainReading, io: Cli): void {
940
+ function reportChains(reading: ChainOutcome, io: Cli): void {
941
+ if ("unreadable" in reading) {
942
+ io.out("");
943
+ io.out(
944
+ paint(
945
+ ` Event chains this kernel could not read the event chains in this bundle ` +
946
+ `(${reading.unreadable}), so the chain evidence is UNKNOWN here. That is not ` +
947
+ `the same as there being none to read.`,
948
+ Y,
949
+ ),
950
+ );
951
+ return;
952
+ }
801
953
  if (reading.chains.length === 0) return;
802
954
 
803
955
  io.out("");
@@ -859,14 +1011,17 @@ function reportAgentProof(
859
1011
  ` leaf index ${result.leafIndex} / ${result.leafCount ?? "?"} inclusion ${mark(result.inclusionOk)}${onChain}`,
860
1012
  );
861
1013
  io.out("");
862
- printRollup(result.status, null, gateways, result.onChainChecked, io);
1014
+ // An agent inclusion proof carries no records and no disclosed bytes, so
1015
+ // there is no pack to grade: the bundle status IS the headline here.
1016
+ printRollup(result.status, result.status, null, gateways, result.onChainChecked, io);
863
1017
  for (const e of result.errors) io.out(paint(` note: ${e}`, DIM));
864
1018
 
865
1019
  return mapStatusToExit(result.status, result.errors);
866
1020
  }
867
1021
 
868
1022
  function printRollup(
869
- status: string,
1023
+ headline: string,
1024
+ bundleStatus: string,
870
1025
  asserted: string | null,
871
1026
  gateways: string[],
872
1027
  onChainChecked: boolean,
@@ -876,30 +1031,59 @@ function printRollup(
876
1031
  ? `verified offline + on-chain against ${gateways.length} gateway(s)`
877
1032
  : "verified offline (no gateway re-fetch requested)";
878
1033
  let head: string;
879
- switch (status) {
1034
+ switch (headline) {
880
1035
  case "verified":
881
1036
  head = paint("VERIFIED", G);
882
1037
  break;
1038
+ // Undetermined, in both of its words. Neither is an accusation, so neither
1039
+ // is printed in the color this command uses for one.
883
1040
  case "partial":
884
- head = paint("PARTIAL", Y);
1041
+ case "pending":
1042
+ case "incomplete":
1043
+ head = paint(headline.toUpperCase(), Y);
885
1044
  break;
886
1045
  case "failed":
887
1046
  head = paint("FAILED", R);
888
1047
  break;
889
1048
  default:
890
- head = paint(status.toUpperCase(), R);
1049
+ head = paint(headline.toUpperCase(), R);
891
1050
  }
892
1051
  io.out(`${head} ${paint(scope, DIM)}`);
893
- if (asserted && asserted !== status) {
1052
+ // Both verdicts, whenever they differ, because they answer different
1053
+ // questions: §3 grades the signed bundle, the pack verdict grades the bundle
1054
+ // plus whatever bytes travelled beside it. A pack can be incomplete while the
1055
+ // bundle it carries is impeccable, and a reader told only the headline would
1056
+ // read a sound bundle as a broken one.
1057
+ if (headline !== bundleStatus) {
1058
+ io.out(
1059
+ paint(` (the signed bundle is "${bundleStatus}"; the pack around it is "${headline}")`, DIM),
1060
+ );
1061
+ }
1062
+ if (asserted && asserted !== bundleStatus) {
894
1063
  io.out(
895
1064
  paint(
896
- ` (producer asserted "${asserted}"; recomputed verdict is "${status}" — recomputed wins)`,
1065
+ ` (the asserting party said "${asserted}"; the recomputed verdict is "${bundleStatus}", ` +
1066
+ `and the recomputed verdict wins)`,
897
1067
  DIM,
898
1068
  ),
899
1069
  );
900
1070
  }
901
1071
  }
902
1072
 
1073
+ /**
1074
+ * The exit code follows the PACK verdict, then falls back to the bundle status.
1075
+ *
1076
+ * `incomplete` is 3 rather than 0: it is undetermined, which is what 3 already
1077
+ * meant for an unreachable gateway, and it is emphatically not 1: the bytes
1078
+ * being absent is never reported as tamper (`evidence-bundle.md` §5.1).
1079
+ */
1080
+ function mapGradeToExit(grade: PackGrade, result: EvidenceBundleResult): number {
1081
+ if (grade.verdict === "malformed") return EXIT_MALFORMED;
1082
+ if (grade.verdict === "failed") return EXIT_FAILED;
1083
+ if (grade.verdict === "incomplete") return EXIT_UNDETERMINED;
1084
+ return mapStatusToExit(result.status, result.errors);
1085
+ }
1086
+
903
1087
  function mapStatusToExit(status: string, errors: string[]): number {
904
1088
  if (status === "failed") return EXIT_FAILED;
905
1089
  if (status === "malformed") return EXIT_MALFORMED;
@@ -910,7 +1094,7 @@ function mapStatusToExit(status: string, errors: string[]): number {
910
1094
  status === "partial" &&
911
1095
  errors.some((e) => /unreachable|could not be re-fetched|unavailable offline/.test(e))
912
1096
  ) {
913
- return EXIT_GATEWAY_UNAVAILABLE;
1097
+ return EXIT_UNDETERMINED;
914
1098
  }
915
1099
  // "verified" → 0. "partial" from a withheld record (semantics-undetermined,
916
1100
  // not a failure) also exits 0 — the bundle is cryptographically sound.
package/src/crypto.ts CHANGED
@@ -25,6 +25,14 @@ export function bytesToHex(bytes: Uint8Array): string {
25
25
  }
26
26
 
27
27
  export function hexToBytes(hex: string): Uint8Array {
28
+ // The type says `string` and a bundle is not typed: every caller here reads
29
+ // a field a party wrote, so `undefined` and `null` arrive at runtime. Until
30
+ // gate 4 on proof#140 this line threw the HOST's `Cannot read properties of
31
+ // undefined (reading 'length')` where the Python kernel raises
32
+ // `hexToBytes: not a string`, and a finding that wraps it (`inclusion verify
33
+ // error: ...`) then read two ways. This kernel's own words, in the other
34
+ // kernel's wording, so a finding may quote them.
35
+ if (typeof hex !== "string") throw new Error("hexToBytes: not a string");
28
36
  if (hex.length % 2 !== 0) throw new Error("hexToBytes: odd-length string");
29
37
  // Full validation: parseInt() partially parses ("1g" -> 1), which would let
30
38
  // malformed hex through. A strict charset check closes that.
@@ -36,6 +44,26 @@ export function hexToBytes(hex: string): Uint8Array {
36
44
  return out;
37
45
  }
38
46
 
47
+ // The one hex-field test, beside the one hex reader above. The Python twin is
48
+ // `is_hex_field` in `src/sanning_proof/hex.py`, and every site in either kernel
49
+ // that asks whether a producer's hex is the shape a spec fixes goes through one
50
+ // of the two.
51
+ //
52
+ // The test runs BEFORE any decoder sees the value, so the failure a reader is
53
+ // handed names the field's FORM rather than whatever the decoder happened to
54
+ // say about it: `hexToBytes: non-hex characters` on a respelled key sends an
55
+ // auditor looking for a broken tool, and `Ed25519 signature verification
56
+ // failed` sends them looking for a forgery. Neither is what happened.
57
+ //
58
+ // CASE IS A PARAMETER AND NOT THE DEFAULT. proof#135 (option B, Phil
59
+ // 2026-09-22) pinned the hex charset and left the case open, so `lower` is
60
+ // passed only where a value is compared against a RECOMPUTED lowercase digest
61
+ // and no other spelling of it could ever be that digest.
62
+ export function isHexField(value: unknown, nibbles: number, lower = false): value is string {
63
+ if (typeof value !== "string" || value.length !== nibbles) return false;
64
+ return (lower ? /^[0-9a-f]*$/ : /^[0-9a-fA-F]*$/).test(value);
65
+ }
66
+
39
67
  export function utf8(s: string): Uint8Array {
40
68
  return new TextEncoder().encode(s);
41
69
  }