@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/README.md +7 -7
- package/dist/agent-proof.js +8 -2
- package/dist/case.d.ts +745 -0
- package/dist/case.js +1318 -0
- package/dist/chain.js +24 -5
- package/dist/cli.js +194 -37
- package/dist/compose.js +3 -3
- package/dist/crypto.d.ts +1 -0
- package/dist/crypto.js +30 -1
- package/dist/declaration.d.ts +284 -0
- package/dist/declaration.js +674 -0
- package/dist/evidence.d.ts +29 -54
- package/dist/evidence.js +622 -206
- package/dist/grading.d.ts +66 -5
- package/dist/grading.js +134 -9
- package/dist/handoff.d.ts +209 -0
- package/dist/handoff.js +598 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +56 -6
- package/dist/message-parts.d.ts +283 -0
- package/dist/message-parts.js +903 -0
- package/dist/verifier.d.ts +36 -0
- package/dist/verifier.js +157 -6
- package/package.json +2 -2
- package/src/agent-proof.ts +8 -2
- package/src/case.ts +1573 -0
- package/src/chain.ts +24 -5
- package/src/cli.ts +226 -39
- package/src/compose.ts +4 -4
- package/src/crypto.ts +29 -1
- package/src/declaration.ts +1005 -0
- package/src/evidence.ts +707 -259
- package/src/grading.ts +168 -11
- package/src/handoff.ts +674 -0
- package/src/index.ts +132 -8
- package/src/message-parts.ts +998 -0
- package/src/verifier.ts +157 -6
- package/vectors/README.md +37 -2
- package/vectors/chain-reading.json +4 -4
- package/vectors/jcs-reader.json +166 -0
- package/vectors/pack-grading.json +131 -3
- package/vectors/stamp-on-chain.json +3 -3
package/src/chain.ts
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/
|
|
6
|
-
// shipped read it. The envelope verifier hashes each event's `
|
|
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-
|
|
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.
|
|
@@ -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
|
-
|
|
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 {
|
|
@@ -273,7 +292,7 @@ async function groupChains(bundle: unknown): Promise<Group[]> {
|
|
|
273
292
|
// event id an adapter pointer has nothing to resolve against.
|
|
274
293
|
if (eventId === null || publicKey === null) continue;
|
|
275
294
|
|
|
276
|
-
const decoded = decodeRecord(entry?.
|
|
295
|
+
const decoded = decodeRecord(entry?.event_bytes);
|
|
277
296
|
if (decoded === null) continue;
|
|
278
297
|
const { record, bytes } = decoded;
|
|
279
298
|
|
package/src/cli.ts
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
|
|
6
|
-
//
|
|
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
|
|
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
|
|
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
|
|
|
17
38
|
import { readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
18
39
|
import { realpathSync } from "node:fs";
|
|
@@ -22,17 +43,26 @@ import { fileURLToPath } from "node:url";
|
|
|
22
43
|
import { isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
|
|
23
44
|
import type { AgentProofResult } from "./agent-proof.js";
|
|
24
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 };
|
|
25
50
|
import type { ChainReading } from "./chain.js";
|
|
26
51
|
import { composeExport } from "./compose.js";
|
|
27
52
|
import type { ComposeExportOptions } from "./compose.js";
|
|
28
|
-
import { utf8 } from "./crypto.js";
|
|
53
|
+
import { isHexField, utf8 } from "./crypto.js";
|
|
29
54
|
import { verifyEvidenceBundle } from "./evidence.js";
|
|
30
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";
|
|
31
59
|
|
|
32
60
|
const EXIT_VERIFIED = 0;
|
|
33
61
|
const EXIT_FAILED = 1;
|
|
34
62
|
const EXIT_MALFORMED = 2;
|
|
35
|
-
|
|
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;
|
|
36
66
|
|
|
37
67
|
interface Cli {
|
|
38
68
|
out: (s: string) => void;
|
|
@@ -173,8 +203,21 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
173
203
|
// evidence about the records in front of us, not a verdict: they are printed
|
|
174
204
|
// and they never move `exit`, because a chain is not a completeness proof and
|
|
175
205
|
// an exit code is exactly the kind of thing people read as one.
|
|
176
|
-
|
|
177
|
-
|
|
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);
|
|
178
221
|
// Coverage is reported only once the bundle parsed far enough to have events
|
|
179
222
|
// to compare against; over a MALFORMED bundle there is nothing to say.
|
|
180
223
|
if (logs && result.status !== "malformed") reportLogsCoverage(logs, result, io);
|
|
@@ -184,8 +227,8 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
184
227
|
// `proof bundle <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
|
|
185
228
|
// (`proof export` still reaches this, undocumented — see the dispatch above.)
|
|
186
229
|
//
|
|
187
|
-
// Compose a signed, offline-verifiable sanning.evidence.export/
|
|
188
|
-
// source sanning.anchor.trace/
|
|
230
|
+
// Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from a
|
|
231
|
+
// source sanning.anchor.trace/v3 bundle + operator attestation records + an exporter
|
|
189
232
|
// Ed25519 key (evidence-export.md §5, composer). Writes the export to `-o <out>`
|
|
190
233
|
// (or stdout when omitted, so it can be piped straight into `proof verify`).
|
|
191
234
|
//
|
|
@@ -307,8 +350,8 @@ function usageExport(io: Cli): number {
|
|
|
307
350
|
"Usage: npx @sanning/proof bundle <source-bundle.json> --attestations <file> --key <file> [-o <out.json>]",
|
|
308
351
|
);
|
|
309
352
|
io.out("");
|
|
310
|
-
io.out("Compose a signed, offline-verifiable sanning.evidence.export/
|
|
311
|
-
io.out("sanning.anchor.trace/
|
|
353
|
+
io.out("Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from an");
|
|
354
|
+
io.out("sanning.anchor.trace/v3 source bundle + operator attestation records + an exporter");
|
|
312
355
|
io.out("Ed25519 key. The output verifies with `proof verify <out.json>`.");
|
|
313
356
|
io.out("");
|
|
314
357
|
io.out("--attestations <file> JSON array of attestation records (or {attestations:[...]}).");
|
|
@@ -626,12 +669,28 @@ function sample(names: string[], max = 3): string {
|
|
|
626
669
|
}
|
|
627
670
|
|
|
628
671
|
// The --logs MAP lane decides each disclosed value's encoding: a clean,
|
|
629
|
-
// even-length
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
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.
|
|
633
692
|
function decodeLogValue(v: string): Uint8Array | string {
|
|
634
|
-
if (v.length > 0 && v.length % 2 === 0 &&
|
|
693
|
+
if (v.length > 0 && v.length % 2 === 0 && isHexField(v, v.length)) return v; // hex passthrough
|
|
635
694
|
return utf8(v); // raw utf8 bytes
|
|
636
695
|
}
|
|
637
696
|
|
|
@@ -642,10 +701,12 @@ function printUsage(io: Cli): void {
|
|
|
642
701
|
io.out(" verify <bundle.json> [gateways] [--logs <path>] Verify a bundle");
|
|
643
702
|
io.out(" bundle <source.json> --attestations <f> --key <f> [-o] Create an attested bundle");
|
|
644
703
|
io.out("");
|
|
645
|
-
io.out("Verify a sanning.evidence/v1 (sanning.anchor.trace/
|
|
646
|
-
io.out("sanning.
|
|
647
|
-
io.out("
|
|
648
|
-
io.out("to also re-fetch
|
|
704
|
+
io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
|
|
705
|
+
io.out("sanning.stamp.trace/v1, sanning.stamp.evidence/v2 or the attested export");
|
|
706
|
+
io.out("sanning.evidence.export/v2) or a sanning.agent.proof/v1 inclusion bundle,");
|
|
707
|
+
io.out("fully offline. Supply a comma-separated gateway list to also re-fetch");
|
|
708
|
+
io.out("each checkpoint and stamp on-chain. Only arweave witnesses are re-fetched;");
|
|
709
|
+
io.out("a witness of any other kind is not checked and the run exits 3.");
|
|
649
710
|
io.out("");
|
|
650
711
|
io.out("--logs <path> Bind disclosed raw logs to each event's committed");
|
|
651
712
|
io.out(" content_hash. Either shape works, and which one you");
|
|
@@ -663,7 +724,49 @@ function printUsage(io: Cli): void {
|
|
|
663
724
|
io.out(" and takes precedence. An event with no disclosed");
|
|
664
725
|
io.out(" bytes is undetermined (~), never a failure.");
|
|
665
726
|
io.out("");
|
|
666
|
-
io.out("
|
|
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
|
+
};
|
|
667
770
|
}
|
|
668
771
|
|
|
669
772
|
function reportEvidence(
|
|
@@ -671,7 +774,9 @@ function reportEvidence(
|
|
|
671
774
|
path: string,
|
|
672
775
|
gateways: string[],
|
|
673
776
|
io: Cli,
|
|
674
|
-
chains:
|
|
777
|
+
chains: ChainOutcome,
|
|
778
|
+
parsed: unknown,
|
|
779
|
+
logs: LoadedLogs | undefined,
|
|
675
780
|
): number {
|
|
676
781
|
if (result.status === "malformed") {
|
|
677
782
|
io.err(paint("MALFORMED", R) + ` ${path}`);
|
|
@@ -762,17 +867,55 @@ function reportEvidence(
|
|
|
762
867
|
reportExport(result.export, io);
|
|
763
868
|
}
|
|
764
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
|
+
|
|
765
890
|
io.out("");
|
|
766
|
-
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));
|
|
767
899
|
for (const e of result.errors) io.out(paint(` note: ${e}`, DIM));
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
+
);
|
|
773
916
|
}
|
|
774
917
|
|
|
775
|
-
return
|
|
918
|
+
return mapGradeToExit(grade, result);
|
|
776
919
|
}
|
|
777
920
|
|
|
778
921
|
/**
|
|
@@ -794,7 +937,19 @@ function reportEvidence(
|
|
|
794
937
|
* was. A dangling link is something for a person to look at, not a machine
|
|
795
938
|
* decision this check is entitled to make.
|
|
796
939
|
*/
|
|
797
|
-
function reportChains(reading:
|
|
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
|
+
}
|
|
798
953
|
if (reading.chains.length === 0) return;
|
|
799
954
|
|
|
800
955
|
io.out("");
|
|
@@ -856,14 +1011,17 @@ function reportAgentProof(
|
|
|
856
1011
|
` leaf index ${result.leafIndex} / ${result.leafCount ?? "?"} inclusion ${mark(result.inclusionOk)}${onChain}`,
|
|
857
1012
|
);
|
|
858
1013
|
io.out("");
|
|
859
|
-
|
|
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);
|
|
860
1017
|
for (const e of result.errors) io.out(paint(` note: ${e}`, DIM));
|
|
861
1018
|
|
|
862
1019
|
return mapStatusToExit(result.status, result.errors);
|
|
863
1020
|
}
|
|
864
1021
|
|
|
865
1022
|
function printRollup(
|
|
866
|
-
|
|
1023
|
+
headline: string,
|
|
1024
|
+
bundleStatus: string,
|
|
867
1025
|
asserted: string | null,
|
|
868
1026
|
gateways: string[],
|
|
869
1027
|
onChainChecked: boolean,
|
|
@@ -873,30 +1031,59 @@ function printRollup(
|
|
|
873
1031
|
? `verified offline + on-chain against ${gateways.length} gateway(s)`
|
|
874
1032
|
: "verified offline (no gateway re-fetch requested)";
|
|
875
1033
|
let head: string;
|
|
876
|
-
switch (
|
|
1034
|
+
switch (headline) {
|
|
877
1035
|
case "verified":
|
|
878
1036
|
head = paint("VERIFIED", G);
|
|
879
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.
|
|
880
1040
|
case "partial":
|
|
881
|
-
|
|
1041
|
+
case "pending":
|
|
1042
|
+
case "incomplete":
|
|
1043
|
+
head = paint(headline.toUpperCase(), Y);
|
|
882
1044
|
break;
|
|
883
1045
|
case "failed":
|
|
884
1046
|
head = paint("FAILED", R);
|
|
885
1047
|
break;
|
|
886
1048
|
default:
|
|
887
|
-
head = paint(
|
|
1049
|
+
head = paint(headline.toUpperCase(), R);
|
|
888
1050
|
}
|
|
889
1051
|
io.out(`${head} ${paint(scope, DIM)}`);
|
|
890
|
-
|
|
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) {
|
|
891
1063
|
io.out(
|
|
892
1064
|
paint(
|
|
893
|
-
` (
|
|
1065
|
+
` (the asserting party said "${asserted}"; the recomputed verdict is "${bundleStatus}", ` +
|
|
1066
|
+
`and the recomputed verdict wins)`,
|
|
894
1067
|
DIM,
|
|
895
1068
|
),
|
|
896
1069
|
);
|
|
897
1070
|
}
|
|
898
1071
|
}
|
|
899
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
|
+
|
|
900
1087
|
function mapStatusToExit(status: string, errors: string[]): number {
|
|
901
1088
|
if (status === "failed") return EXIT_FAILED;
|
|
902
1089
|
if (status === "malformed") return EXIT_MALFORMED;
|
|
@@ -907,7 +1094,7 @@ function mapStatusToExit(status: string, errors: string[]): number {
|
|
|
907
1094
|
status === "partial" &&
|
|
908
1095
|
errors.some((e) => /unreachable|could not be re-fetched|unavailable offline/.test(e))
|
|
909
1096
|
) {
|
|
910
|
-
return
|
|
1097
|
+
return EXIT_UNDETERMINED;
|
|
911
1098
|
}
|
|
912
1099
|
// "verified" → 0. "partial" from a withheld record (semantics-undetermined,
|
|
913
1100
|
// not a failure) also exits 0 — the bundle is cryptographically sound.
|
package/src/compose.ts
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/
|
|
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/
|
|
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
|
|
@@ -64,7 +64,7 @@ export interface ComposeExportOptions {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
export interface ComposeExportResult {
|
|
67
|
-
// The signed, offline-verifiable sanning.evidence.export/
|
|
67
|
+
// The signed, offline-verifiable sanning.evidence.export/v2 artifact.
|
|
68
68
|
bundle: EvidenceBundle;
|
|
69
69
|
// The export rollup the embedded kernel_verdict carries (verified|partial|failed).
|
|
70
70
|
status: EvidenceStatus;
|
|
@@ -75,7 +75,7 @@ export interface ComposeExportResult {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
const GENESIS = "GENESIS";
|
|
78
|
-
const EXPORT_SCHEMA =
|
|
78
|
+
const EXPORT_SCHEMA = EXPORT_BODY_TYPE;
|
|
79
79
|
|
|
80
80
|
// Transcode an attestation `signature` to the lowercase hex §2.2 mandates. The
|
|
81
81
|
// issuer emits either lowercase-hex or unpadded base64url; §2.2 stores hex, so
|
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
|
}
|
|
@@ -85,7 +113,7 @@ export async function ed25519PublicKey(privateKeyHex: string): Promise<string> {
|
|
|
85
113
|
}
|
|
86
114
|
|
|
87
115
|
// ---------------------------------------------------------------------------
|
|
88
|
-
// RSA-PSS operator-attestation crypto (sanning.evidence.export/
|
|
116
|
+
// RSA-PSS operator-attestation crypto (sanning.evidence.export/v2)
|
|
89
117
|
//
|
|
90
118
|
// The attested-evidence-export body embeds operator attestations signed with
|
|
91
119
|
// RSA-PSS-SHA-256 over the JCS-canonical attestation payload; the operator key
|