@sanning/proof 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/chain.js CHANGED
@@ -2,8 +2,8 @@
2
2
  // event before it, and reporting what does not line up.
3
3
  //
4
4
  // WHY THIS LIVES IN THE KERNEL, BESIDE PACK GRADING.
5
- // `sanning.events/v1` records carry `previous_hash`, and until now nothing we
6
- // shipped read it. The envelope verifier hashes each event's `record_bytes`
5
+ // `sanning.events/v2` records carry `previous_hash`, and until now nothing we
6
+ // shipped read it. The envelope verifier hashes each event's `event_bytes`
7
7
  // and stops — `evidence.ts` says following the link is "consumer-layer logic
8
8
  // above it", and it was right that a SINGLE-envelope verifier should not do
9
9
  // it. But the consumer layer never did it either, so a bundle with an event
@@ -18,7 +18,7 @@
18
18
  // ---- WHAT THIS IS NOT ------------------------------------------------------
19
19
  //
20
20
  // It is not a completeness statement and it must never be read as one. The
21
- // profile says so in its own words (`anchor/docs/profile-sanning.events-v1.md`
21
+ // profile says so in its own words (`anchor/docs/profile-sanning.events-v2.md`
22
22
  // §5): chain semantics are "deletion/reorder-evidence for the events present
23
23
  // ... not a completeness proof." Drop the last N events of a chain and nothing
24
24
  // here dangles; the record that would have pointed at them was never written.
@@ -54,6 +54,7 @@
54
54
  // against `anchor`); when it lands, the namespace table below stops mattering
55
55
  // and can go.
56
56
  import { hexToBytes, sha256Hex } from "./crypto.js";
57
+ import { jcsParse } from "./verifier.js";
57
58
  import { resolveTraceEvents } from "./evidence.js";
58
59
  /**
59
60
  * The one sentence a surface should print beside these findings, so the reader
@@ -109,7 +110,25 @@ function decodeRecord(hex) {
109
110
  return null;
110
111
  try {
111
112
  const bytes = hexToBytes(hex);
112
- const record = JSON.parse(new TextDecoder().decode(bytes));
113
+ // PINNED STRICT via `jcsParse`, and the twin at
114
+ // `src/sanning_proof/chain.py` is `jcs_loads` (proof#149). This was the
115
+ // LAST lenient decode pair in either kernel, and it was lenient on BOTH
116
+ // sides in different directions, so it agreed nowhere while looking like
117
+ // it agreed everywhere:
118
+ //
119
+ // * A bare `new TextDecoder()` here eats a leading byte-order mark and
120
+ // substitutes U+FFFD for every invalid sequence, so `BOM + JCS(r)`, a
121
+ // raw `0x80`, a CESU-8 surrogate pair and a truncated UTF-8 sequence
122
+ // each joined a chain HERE and joined none there.
123
+ // * Python's `json.loads` accepted `NaN`, `Infinity` and `-Infinity`,
124
+ // which RFC 8785 cannot emit and this kernel's `JSON.parse` rejects,
125
+ // so a record carrying one joined a chain THERE and none here.
126
+ //
127
+ // Five split readings on one pair, in two directions, and no test in
128
+ // either kernel could see them: each kernel agreed with itself. That is
129
+ // why agreement between two lenient readers is not evidence of anything,
130
+ // and why the comparison has to run on the same bytes.
131
+ const record = jcsParse(bytes);
113
132
  if (record === null || typeof record !== "object")
114
133
  return null;
115
134
  return { record, bytes };
@@ -178,7 +197,7 @@ async function groupChains(bundle) {
178
197
  // event id an adapter pointer has nothing to resolve against.
179
198
  if (eventId === null || publicKey === null)
180
199
  continue;
181
- const decoded = decodeRecord(entry?.record_bytes);
200
+ const decoded = decodeRecord(entry?.event_bytes);
182
201
  if (decoded === null)
183
202
  continue;
184
203
  const { record, bytes } = decoded;
package/dist/cli.js CHANGED
@@ -2,17 +2,38 @@
2
2
  // npx @sanning/proof verify <bundle.json> [comma,sep,gateways]
3
3
  //
4
4
  // One turnkey verifier for both producer bundles in the stack:
5
- // - sanning.evidence/v1 (body sanning.anchor.trace/v1) — the @sanning/anchor SDK trace
6
- // - sanning.agent.proof/v1 — the agent inclusion proof
5
+ // - sanning.evidence/v1: bodies sanning.anchor.trace/v3, sanning.stamp.trace/v1,
6
+ // sanning.stamp.evidence/v2 and sanning.evidence.export/v2
7
+ // - sanning.agent.proof/v1: the agent inclusion proof
7
8
  // Dispatch is by sniffed spec_version; no flag tells the CLI which it is.
8
9
  //
9
10
  // Pinned exit codes (mirror `sanningd verify-status`):
10
11
  // 0 verified
11
12
  // 1 a real verification failure (bad signature / tamper / broken inclusion)
12
13
  // 2 malformed bundle (unparseable, unknown spec major, body_hash mismatch)
13
- // 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
+ //
14
33
  // `payloadHashOk === null` (a withheld record) is semantics-undetermined and
15
- // 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".
16
37
  import { readFile, readdir, stat, writeFile } from "node:fs/promises";
17
38
  import { realpathSync } from "node:fs";
18
39
  import { join } from "node:path";
@@ -20,12 +41,16 @@ import { fileURLToPath } from "node:url";
20
41
  import { isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
21
42
  import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js";
22
43
  import { composeExport } from "./compose.js";
23
- import { utf8 } from "./crypto.js";
44
+ import { isHexField, utf8 } from "./crypto.js";
24
45
  import { verifyEvidenceBundle } from "./evidence.js";
46
+ import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
47
+ import { completenessNotes, gradePack, renderCause } from "./grading.js";
25
48
  const EXIT_VERIFIED = 0;
26
49
  const EXIT_FAILED = 1;
27
50
  const EXIT_MALFORMED = 2;
28
- const EXIT_GATEWAY_UNAVAILABLE = 3;
51
+ // One code, two undetermined outcomes: a gateway we could not reach, and a pack
52
+ // whose records nobody hashed. Neither is an accusation, and neither is a pass.
53
+ const EXIT_UNDETERMINED = 3;
29
54
  const G = "\x1b[32m";
30
55
  const R = "\x1b[31m";
31
56
  const Y = "\x1b[33m";
@@ -152,8 +177,22 @@ export async function runCli(argv, io) {
152
177
  // evidence about the records in front of us, not a verdict: they are printed
153
178
  // and they never move `exit`, because a chain is not a completeness proof and
154
179
  // an exit code is exactly the kind of thing people read as one.
155
- const chains = await readChains(parsed);
156
- const exit = reportEvidence(result, bundlePath, gateways, io, chains);
180
+ //
181
+ // A READING THAT COULD NOT BE PRODUCED SAYS SO. This was an unguarded call:
182
+ // a throw out of `readChains` would have escaped the command entirely and
183
+ // exited 1, which is the code for "failed" and would have named the
184
+ // customer's evidence tampered over a fault in this reader. The Python twin
185
+ // had the opposite half of the same defect and swallowed the throw into "no
186
+ // event chain was found to follow in this bundle" (the gate on proof#154).
187
+ // Neither kernel is entitled to either answer, so both now print the third.
188
+ let chains;
189
+ try {
190
+ chains = await readChains(parsed);
191
+ }
192
+ catch (e) {
193
+ chains = { unreadable: errMsg(e) };
194
+ }
195
+ const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs);
157
196
  // Coverage is reported only once the bundle parsed far enough to have events
158
197
  // to compare against; over a MALFORMED bundle there is nothing to say.
159
198
  if (logs && result.status !== "malformed")
@@ -163,8 +202,8 @@ export async function runCli(argv, io) {
163
202
  // `proof bundle <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
164
203
  // (`proof export` still reaches this, undocumented — see the dispatch above.)
165
204
  //
166
- // Compose a signed, offline-verifiable sanning.evidence.export/v1 artifact from a
167
- // source sanning.anchor.trace/v1 bundle + operator attestation records + an exporter
205
+ // Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from a
206
+ // source sanning.anchor.trace/v3 bundle + operator attestation records + an exporter
168
207
  // Ed25519 key (evidence-export.md §5, composer). Writes the export to `-o <out>`
169
208
  // (or stdout when omitted, so it can be piped straight into `proof verify`).
170
209
  //
@@ -295,8 +334,8 @@ async function runBundle(rest, io) {
295
334
  function usageExport(io) {
296
335
  io.out("Usage: npx @sanning/proof bundle <source-bundle.json> --attestations <file> --key <file> [-o <out.json>]");
297
336
  io.out("");
298
- io.out("Compose a signed, offline-verifiable sanning.evidence.export/v1 artifact from an");
299
- io.out("sanning.anchor.trace/v1 source bundle + operator attestation records + an exporter");
337
+ io.out("Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from an");
338
+ io.out("sanning.anchor.trace/v3 source bundle + operator attestation records + an exporter");
300
339
  io.out("Ed25519 key. The output verifies with `proof verify <out.json>`.");
301
340
  io.out("");
302
341
  io.out("--attestations <file> JSON array of attestation records (or {attestations:[...]}).");
@@ -551,12 +590,28 @@ function sample(names, max = 3) {
551
590
  return names.length > max ? `${shown}, +${names.length - max} more` : shown;
552
591
  }
553
592
  // The --logs MAP lane decides each disclosed value's encoding: a clean,
554
- // even-length, lowercase-hex string is hex bytes; anything else is utf8 text.
555
- // (The verifier itself only distinguishes "string ⇒ hex" from "Uint8Array ⇒
556
- // raw bytes" — the human-facing encoding choice is made here. The DIRECTORY
557
- // lane never reaches this: its bytes are already bytes.)
593
+ // even-length hex string is hex bytes; anything else is utf8 text. (The
594
+ // verifier itself only distinguishes "string ⇒ hex" from "Uint8Array ⇒ raw
595
+ // bytes", so the human-facing encoding choice is made here. The DIRECTORY lane
596
+ // never reaches this: its bytes are already bytes.)
597
+ //
598
+ // EITHER CASE, through the one charset rule (proof#146). This test was
599
+ // `/^[0-9a-f]+$/`, lower case only, and it was the last copy of the hex rule
600
+ // in either kernel that answered differently from the others: the Python CLI
601
+ // passes every string to the kernel, and the kernel decodes it with the
602
+ // case-open `hexToBytes` on BOTH sides. So a disclosure map written in
603
+ // upper-case hex was read as 4 bytes of evidence there and as 8 bytes of TEXT
604
+ // here, and this command told the auditor their genuine, correctly disclosed
605
+ // evidence had FAILED: `proof verify --logs map.json` exited 3 in Python and
606
+ // 1 here, on one pack and one map. Telling an auditor their evidence is forged
607
+ // is the worst direction to be wrong in, which is the argument the Python
608
+ // twin's own docstring already makes about this exact lane.
609
+ //
610
+ // Pinned by `cross-kernel/run_cli.sh`'s `logs/hex-map-upper`, beside the
611
+ // lowercase lane that existed before and only ever exercised the one spelling
612
+ // the two commands happened to agree on.
558
613
  function decodeLogValue(v) {
559
- if (v.length > 0 && v.length % 2 === 0 && /^[0-9a-f]+$/.test(v))
614
+ if (v.length > 0 && v.length % 2 === 0 && isHexField(v, v.length))
560
615
  return v; // hex passthrough
561
616
  return utf8(v); // raw utf8 bytes
562
617
  }
@@ -567,10 +622,12 @@ function printUsage(io) {
567
622
  io.out(" verify <bundle.json> [gateways] [--logs <path>] Verify a bundle");
568
623
  io.out(" bundle <source.json> --attestations <f> --key <f> [-o] Create an attested bundle");
569
624
  io.out("");
570
- io.out("Verify a sanning.evidence/v1 (sanning.anchor.trace/v1) bundle, an");
571
- io.out("sanning.evidence.export/v1 attested export, or a sanning.agent.proof/v1");
572
- io.out("inclusion bundle, fully offline. Supply a comma-separated gateway list");
573
- io.out("to also re-fetch each checkpoint on-chain.");
625
+ io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
626
+ io.out("sanning.stamp.trace/v1, sanning.stamp.evidence/v2 or the attested export");
627
+ io.out("sanning.evidence.export/v2) or a sanning.agent.proof/v1 inclusion bundle,");
628
+ io.out("fully offline. Supply a comma-separated gateway list to also re-fetch");
629
+ io.out("each checkpoint and stamp on-chain. Only arweave witnesses are re-fetched;");
630
+ io.out("a witness of any other kind is not checked and the run exits 3.");
574
631
  io.out("");
575
632
  io.out("--logs <path> Bind disclosed raw logs to each event's committed");
576
633
  io.out(" content_hash. Either shape works, and which one you");
@@ -588,9 +645,47 @@ function printUsage(io) {
588
645
  io.out(" and takes precedence. An event with no disclosed");
589
646
  io.out(" bytes is undetermined (~), never a failure.");
590
647
  io.out("");
591
- io.out("Exit codes: 0 verified · 1 failed · 2 malformed · 3 gateway-unavailable");
648
+ io.out("The headline is the PACK verdict, the same one the verify page prints:");
649
+ io.out("a record nobody hashed leaves the pack INCOMPLETE even when the signed");
650
+ io.out("bundle itself is verified. Both verdicts are printed when they differ.");
651
+ io.out("");
652
+ io.out("Exit codes: 0 verified · 1 failed · 2 malformed · 3 undetermined");
653
+ io.out(" (an unreachable gateway, or an incomplete pack)");
654
+ }
655
+ /**
656
+ * The disclosure account this command can honestly make, from what the VERIFIER
657
+ * determined rather than from what the loader happened to read.
658
+ *
659
+ * `accountDisclosure` is offered by `grading.ts` as a convenience and is
660
+ * explicitly "NOT THE ONLY WAY TO BUILD AN ACCOUNT". It reads the side map
661
+ * only, and this command has a second disclosure lane the side map cannot see:
662
+ * `events[].content`, carried INSIDE the bundle and bound by the verifier
663
+ * without any `--logs` at all. Accounting off the side map would file every
664
+ * in-body disclosure as `missing` and print a shortfall the pack does not have,
665
+ * which is the shape of manufactured accusation `grading.ts` keeps warning
666
+ * about.
667
+ *
668
+ * So `present` is "the verifier reached a determination for this record's
669
+ * content" and `missing` is "it could not", which is the same tri-state that
670
+ * decides the verdict, so the causes printed can never contradict the headline.
671
+ * `extra` still comes from the side input, because bytes that bound to nothing
672
+ * are a fact about the pack that only the loader knows.
673
+ */
674
+ function cliDisclosureAccount(result, logs) {
675
+ const expected = result.events.map((e) => e.eventId);
676
+ const known = new Set(expected);
677
+ return {
678
+ expected,
679
+ present: result.events.filter((e) => e.contentOk !== null).map((e) => e.eventId),
680
+ missing: result.events.filter((e) => e.contentOk === null).map((e) => e.eventId),
681
+ // The loader hard-stops (exit 2) on a file it selected and could not read,
682
+ // and NAMES everything it skipped as a note, so nothing reaches here as an
683
+ // unusable disclosure bound to a named record.
684
+ unreadable: [],
685
+ extra: (logs?.suppliedIds ?? []).filter((id) => !known.has(id)),
686
+ };
592
687
  }
593
- function reportEvidence(result, path, gateways, io, chains) {
688
+ function reportEvidence(result, path, gateways, io, chains, parsed, logs) {
594
689
  if (result.status === "malformed") {
595
690
  io.err(paint("MALFORMED", R) + ` ${path}`);
596
691
  for (const e of result.errors)
@@ -666,16 +761,40 @@ function reportEvidence(result, path, gateways, io, chains) {
666
761
  if (result.export) {
667
762
  reportExport(result.export, io);
668
763
  }
764
+ // What the pack SAYS it holds, against what it does (evidence-bundle.md
765
+ // §5.4). A pack that deviates from its own declaration FAILS here and exits
766
+ // 1, which is the point: every other check in this command is a check on a
767
+ // record that is present, so a trimmed hand-over passes all of them.
768
+ //
769
+ // A pack with NO declaration exits exactly as it did before §5.4. The check
770
+ // answers `partial` and the line below says so in words, and pack grading
771
+ // keeps its own verdict: every pack built before §5.4 carries no
772
+ // declaration, and re-grading them all as undetermined would be a change to
773
+ // the pinned 0/1/2/3 contract rather than a new check. A pack that DOES
774
+ // carry a declaration this reader cannot read is a different case and exits
775
+ // 3: the holder chose that field, so UNKNOWN is the honest answer.
776
+ const declaration = checkDeclaration(parsed, logs?.content);
777
+ const grade = gradePack(result, cliDisclosureAccount(result, logs), completenessNotes(parsed), declaration);
669
778
  io.out("");
670
- printRollup(result.status, result.assertedStatus, gateways, result.onChainChecked, io);
779
+ printRollup(grade.verdict, result.status, result.assertedStatus, gateways, result.onChainChecked, io);
780
+ io.out(paint(` declaration: ${renderDeclarationSummary(declaration)}`, declaration.verdict === "failed" ? R : declaration.verdict === "verified" ? DIM : Y));
781
+ for (const n of declaration.notes)
782
+ io.out(paint(` note: ${renderNote(n)}`, Y));
671
783
  for (const e of result.errors)
672
784
  io.out(paint(` note: ${e}`, DIM));
673
- const disclosed = result.events.filter((e) => e.contentOk !== null);
674
- if (disclosed.length > 0) {
675
- const verified = disclosed.filter((e) => e.contentOk === true).length;
676
- io.out(paint(` logs: ${verified}/${disclosed.length} disclosed verified`, DIM));
785
+ // Every reason the pack is not verified, in the kernel's own words. An
786
+ // `incomplete` with no cause is a surface saying "unknown" and unable to say
787
+ // why, which `grading.ts` calls out by name as a defect of its own.
788
+ for (const c of grade.causes)
789
+ io.out(paint(` - ${renderCause(c, "the pack")}`, Y));
790
+ // The fraction describes the PACK, not the disclosure. It used to divide by
791
+ // the number of records that happened to disclose bytes, so a pack that
792
+ // disclosed two of three records printed `logs: 2/2`, a full score for a
793
+ // partial hand-over, and the one line a reader skims for exactly this.
794
+ if (result.events.length > 0) {
795
+ io.out(paint(` logs: ${grade.recordsVerified}/${result.events.length} record(s) content-verified`, DIM));
677
796
  }
678
- return mapStatusToExit(result.status, result.errors);
797
+ return mapGradeToExit(grade, result);
679
798
  }
680
799
  /**
681
800
  * The chain block: what following each record's pointer to the record before it
@@ -697,6 +816,13 @@ function reportEvidence(result, path, gateways, io, chains) {
697
816
  * decision this check is entitled to make.
698
817
  */
699
818
  function reportChains(reading, io) {
819
+ if ("unreadable" in reading) {
820
+ io.out("");
821
+ io.out(paint(` Event chains this kernel could not read the event chains in this bundle ` +
822
+ `(${reading.unreadable}), so the chain evidence is UNKNOWN here. That is not ` +
823
+ `the same as there being none to read.`, Y));
824
+ return;
825
+ }
700
826
  if (reading.chains.length === 0)
701
827
  return;
702
828
  io.out("");
@@ -741,34 +867,65 @@ function reportAgentProof(result, path, gateways, io) {
741
867
  const onChain = result.onChainOk === null ? "" : ` on-chain ${mark(result.onChainOk)}`;
742
868
  io.out(` leaf index ${result.leafIndex} / ${result.leafCount ?? "?"} inclusion ${mark(result.inclusionOk)}${onChain}`);
743
869
  io.out("");
744
- printRollup(result.status, null, gateways, result.onChainChecked, io);
870
+ // An agent inclusion proof carries no records and no disclosed bytes, so
871
+ // there is no pack to grade: the bundle status IS the headline here.
872
+ printRollup(result.status, result.status, null, gateways, result.onChainChecked, io);
745
873
  for (const e of result.errors)
746
874
  io.out(paint(` note: ${e}`, DIM));
747
875
  return mapStatusToExit(result.status, result.errors);
748
876
  }
749
- function printRollup(status, asserted, gateways, onChainChecked, io) {
877
+ function printRollup(headline, bundleStatus, asserted, gateways, onChainChecked, io) {
750
878
  const scope = onChainChecked
751
879
  ? `verified offline + on-chain against ${gateways.length} gateway(s)`
752
880
  : "verified offline (no gateway re-fetch requested)";
753
881
  let head;
754
- switch (status) {
882
+ switch (headline) {
755
883
  case "verified":
756
884
  head = paint("VERIFIED", G);
757
885
  break;
886
+ // Undetermined, in both of its words. Neither is an accusation, so neither
887
+ // is printed in the color this command uses for one.
758
888
  case "partial":
759
- head = paint("PARTIAL", Y);
889
+ case "pending":
890
+ case "incomplete":
891
+ head = paint(headline.toUpperCase(), Y);
760
892
  break;
761
893
  case "failed":
762
894
  head = paint("FAILED", R);
763
895
  break;
764
896
  default:
765
- head = paint(status.toUpperCase(), R);
897
+ head = paint(headline.toUpperCase(), R);
766
898
  }
767
899
  io.out(`${head} ${paint(scope, DIM)}`);
768
- if (asserted && asserted !== status) {
769
- io.out(paint(` (producer asserted "${asserted}"; recomputed verdict is "${status}" — recomputed wins)`, DIM));
900
+ // Both verdicts, whenever they differ, because they answer different
901
+ // questions: §3 grades the signed bundle, the pack verdict grades the bundle
902
+ // plus whatever bytes travelled beside it. A pack can be incomplete while the
903
+ // bundle it carries is impeccable, and a reader told only the headline would
904
+ // read a sound bundle as a broken one.
905
+ if (headline !== bundleStatus) {
906
+ io.out(paint(` (the signed bundle is "${bundleStatus}"; the pack around it is "${headline}")`, DIM));
907
+ }
908
+ if (asserted && asserted !== bundleStatus) {
909
+ io.out(paint(` (the asserting party said "${asserted}"; the recomputed verdict is "${bundleStatus}", ` +
910
+ `and the recomputed verdict wins)`, DIM));
770
911
  }
771
912
  }
913
+ /**
914
+ * The exit code follows the PACK verdict, then falls back to the bundle status.
915
+ *
916
+ * `incomplete` is 3 rather than 0: it is undetermined, which is what 3 already
917
+ * meant for an unreachable gateway, and it is emphatically not 1: the bytes
918
+ * being absent is never reported as tamper (`evidence-bundle.md` §5.1).
919
+ */
920
+ function mapGradeToExit(grade, result) {
921
+ if (grade.verdict === "malformed")
922
+ return EXIT_MALFORMED;
923
+ if (grade.verdict === "failed")
924
+ return EXIT_FAILED;
925
+ if (grade.verdict === "incomplete")
926
+ return EXIT_UNDETERMINED;
927
+ return mapStatusToExit(result.status, result.errors);
928
+ }
772
929
  function mapStatusToExit(status, errors) {
773
930
  if (status === "failed")
774
931
  return EXIT_FAILED;
@@ -779,7 +936,7 @@ function mapStatusToExit(status, errors) {
779
936
  // §5 step 3) — maps to exit 3 (undetermined, network-dependent).
780
937
  if (status === "partial" &&
781
938
  errors.some((e) => /unreachable|could not be re-fetched|unavailable offline/.test(e))) {
782
- return EXIT_GATEWAY_UNAVAILABLE;
939
+ return EXIT_UNDETERMINED;
783
940
  }
784
941
  // "verified" → 0. "partial" from a withheld record (semantics-undetermined,
785
942
  // not a failure) also exits 0 — the bundle is cryptographically sound.
package/dist/compose.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // Attested-evidence-export composer (`proof export`, evidence-export.md §5,
2
2
  // phase 2). The last-mile issuer/runtime job the kernel does NOT own (§5.1
3
- // "Out of the kernel"): take a source `sanning.anchor.trace/v1` bundle + a set of
3
+ // "Out of the kernel"): take a source `sanning.anchor.trace/v3` bundle + a set of
4
4
  // operator attestation records + an exporter Ed25519 key, and PRODUCE one signed,
5
- // offline-verifiable `sanning.evidence.export/v1` artifact — exactly the bytes the
5
+ // offline-verifiable `sanning.evidence.export/v2` artifact, exactly the bytes the
6
6
  // kernel's verifyExportBody round-trip-verifies.
7
7
  //
8
8
  // This is the inverse of evidence.ts's verifyExportBody, and it is deliberately
@@ -24,7 +24,7 @@ import { EXPORT_BODY_TYPE, recomputeExportVerdict, } from "./evidence.js";
24
24
  import { base64UrlToBytes, bytesToHex, ed25519PublicKey, ed25519Sign, sha256Hex, utf8, } from "./crypto.js";
25
25
  import { jcs } from "./verifier.js";
26
26
  const GENESIS = "GENESIS";
27
- const EXPORT_SCHEMA = "sanning.evidence.export/v1";
27
+ const EXPORT_SCHEMA = EXPORT_BODY_TYPE;
28
28
  // Transcode an attestation `signature` to the lowercase hex §2.2 mandates. The
29
29
  // issuer emits either lowercase-hex or unpadded base64url; §2.2 stores hex, so
30
30
  // this is the composer's boundary normalization. Heuristic mirrors the repo's
package/dist/crypto.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export declare function bytesToHex(bytes: Uint8Array): string;
2
2
  export declare function hexToBytes(hex: string): Uint8Array;
3
+ export declare function isHexField(value: unknown, nibbles: number, lower?: boolean): value is string;
3
4
  export declare function utf8(s: string): Uint8Array;
4
5
  export declare function sha256Bytes(bytes: Uint8Array): Promise<Uint8Array>;
5
6
  export declare function sha256Hex(bytes: Uint8Array): Promise<string>;
package/dist/crypto.js CHANGED
@@ -20,6 +20,15 @@ export function bytesToHex(bytes) {
20
20
  return out;
21
21
  }
22
22
  export function hexToBytes(hex) {
23
+ // The type says `string` and a bundle is not typed: every caller here reads
24
+ // a field a party wrote, so `undefined` and `null` arrive at runtime. Until
25
+ // gate 4 on proof#140 this line threw the HOST's `Cannot read properties of
26
+ // undefined (reading 'length')` where the Python kernel raises
27
+ // `hexToBytes: not a string`, and a finding that wraps it (`inclusion verify
28
+ // error: ...`) then read two ways. This kernel's own words, in the other
29
+ // kernel's wording, so a finding may quote them.
30
+ if (typeof hex !== "string")
31
+ throw new Error("hexToBytes: not a string");
23
32
  if (hex.length % 2 !== 0)
24
33
  throw new Error("hexToBytes: odd-length string");
25
34
  // Full validation: parseInt() partially parses ("1g" -> 1), which would let
@@ -32,6 +41,26 @@ export function hexToBytes(hex) {
32
41
  }
33
42
  return out;
34
43
  }
44
+ // The one hex-field test, beside the one hex reader above. The Python twin is
45
+ // `is_hex_field` in `src/sanning_proof/hex.py`, and every site in either kernel
46
+ // that asks whether a producer's hex is the shape a spec fixes goes through one
47
+ // of the two.
48
+ //
49
+ // The test runs BEFORE any decoder sees the value, so the failure a reader is
50
+ // handed names the field's FORM rather than whatever the decoder happened to
51
+ // say about it: `hexToBytes: non-hex characters` on a respelled key sends an
52
+ // auditor looking for a broken tool, and `Ed25519 signature verification
53
+ // failed` sends them looking for a forgery. Neither is what happened.
54
+ //
55
+ // CASE IS A PARAMETER AND NOT THE DEFAULT. proof#135 (option B, Phil
56
+ // 2026-09-22) pinned the hex charset and left the case open, so `lower` is
57
+ // passed only where a value is compared against a RECOMPUTED lowercase digest
58
+ // and no other spelling of it could ever be that digest.
59
+ export function isHexField(value, nibbles, lower = false) {
60
+ if (typeof value !== "string" || value.length !== nibbles)
61
+ return false;
62
+ return (lower ? /^[0-9a-f]*$/ : /^[0-9a-fA-F]*$/).test(value);
63
+ }
35
64
  export function utf8(s) {
36
65
  return new TextEncoder().encode(s);
37
66
  }
@@ -72,7 +101,7 @@ export async function ed25519PublicKey(privateKeyHex) {
72
101
  return bytesToHex(await ed.getPublicKeyAsync(hexToBytes(privateKeyHex)));
73
102
  }
74
103
  // ---------------------------------------------------------------------------
75
- // RSA-PSS operator-attestation crypto (sanning.evidence.export/v1)
104
+ // RSA-PSS operator-attestation crypto (sanning.evidence.export/v2)
76
105
  //
77
106
  // The attested-evidence-export body embeds operator attestations signed with
78
107
  // RSA-PSS-SHA-256 over the JCS-canonical attestation payload; the operator key