@sanning/proof 0.12.0 → 0.14.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.
Files changed (59) hide show
  1. package/README.md +25 -12
  2. package/dist/agent-proof.d.ts +2 -2
  3. package/dist/agent-proof.js +4 -4
  4. package/dist/case-file.d.ts +126 -0
  5. package/dist/case-file.js +353 -0
  6. package/dist/case.d.ts +217 -26
  7. package/dist/case.js +535 -124
  8. package/dist/cli.d.ts +64 -8
  9. package/dist/cli.js +799 -219
  10. package/dist/compose.js +6 -1
  11. package/dist/crypto.d.ts +1 -1
  12. package/dist/crypto.js +5 -1
  13. package/dist/declaration.d.ts +58 -52
  14. package/dist/declaration.js +256 -33
  15. package/dist/evidence.d.ts +38 -0
  16. package/dist/evidence.js +238 -22
  17. package/dist/exit-codes.d.ts +17 -0
  18. package/dist/exit-codes.js +50 -0
  19. package/dist/file-match.d.ts +105 -0
  20. package/dist/file-match.js +374 -0
  21. package/dist/grading.d.ts +28 -13
  22. package/dist/grading.js +90 -31
  23. package/dist/index.d.ts +14 -7
  24. package/dist/index.js +20 -6
  25. package/dist/message-parts.d.ts +18 -10
  26. package/dist/message-parts.js +25 -15
  27. package/dist/types.d.ts +1 -0
  28. package/dist/verifier.d.ts +63 -4
  29. package/dist/verifier.js +223 -15
  30. package/dist/witness-cache.d.ts +85 -0
  31. package/dist/witness-cache.js +264 -0
  32. package/dist/zip-reader.d.ts +50 -0
  33. package/dist/zip-reader.js +220 -0
  34. package/package.json +2 -2
  35. package/src/agent-proof.ts +4 -4
  36. package/src/case-file.ts +422 -0
  37. package/src/case.ts +671 -131
  38. package/src/cli.ts +932 -217
  39. package/src/compose.ts +8 -1
  40. package/src/crypto.ts +5 -1
  41. package/src/declaration.ts +248 -52
  42. package/src/evidence.ts +257 -23
  43. package/src/exit-codes.ts +54 -0
  44. package/src/file-match.ts +461 -0
  45. package/src/grading.ts +109 -52
  46. package/src/index.ts +101 -2
  47. package/src/message-parts.ts +25 -15
  48. package/src/types.ts +5 -0
  49. package/src/verifier.ts +225 -16
  50. package/src/witness-cache.ts +306 -0
  51. package/src/zip-reader.ts +229 -0
  52. package/vectors/README.md +66 -8
  53. package/vectors/case-file-index.json +668 -0
  54. package/vectors/case-split.json +218 -9
  55. package/vectors/file-match.json +1808 -0
  56. package/vectors/jcs-reader.json +82 -3
  57. package/vectors/pack-grading.json +68 -4
  58. package/vectors/stamp-on-chain.json +20 -5
  59. package/vectors/witness-cache.json +1304 -0
package/README.md CHANGED
@@ -43,7 +43,7 @@ import { sha256Hex, verifyEnvelope, contentHashes } from "@sanning/proof";
43
43
 
44
44
  const myHash = await sha256Hex(fileBytes); // 64-char lowercase hex
45
45
 
46
- const result = await verifyEnvelope(env, myHash);
46
+ const result = await verifyEnvelope(env, { expectedContentHash: myHash });
47
47
  result.contentHashOk; // true ⇔ the envelope commits to exactly these bytes
48
48
  result.contentRole; // how it commits: e.g. the registered baseline,
49
49
  // an observed (tampered) hash, …
@@ -53,17 +53,19 @@ contentHashes(env); // all content hashes an envelope commits to, by event typ
53
53
 
54
54
  ## Verify a whole trace bundle: one command
55
55
 
56
+ `npm install -g @sanning/proof` puts `sanning-proof` on your path, the same name `pip install sanning-proof` does (it was `proof` until 0.13.0). `npx @sanning/proof <command>` runs it without installing.
57
+
56
58
  The `@sanning/anchor` SDK hands a producer a set of `InclusionReceipt`s and can serialize them into one portable, self-verifying `sanning.evidence/v1` bundle (`body_type: sanning.anchor.trace/v3`). Verify the entire bundle (every event's signature + payload binding + Merkle inclusion, all offline) with the bundled CLI:
57
59
 
58
60
  ```bash
59
61
  npx @sanning/proof verify trace-bundle.json
60
62
  # also re-fetch each checkpoint on-chain to confirm it's anchored:
61
- npx @sanning/proof verify trace-bundle.json https://arweave.net,https://permagate.io
63
+ npx @sanning/proof verify trace-bundle.json --gateways https://arweave.net,https://permagate.io
62
64
  ```
63
65
 
64
- It prints a per-event verdict, then the PACK verdict, and exits on a pinned code: `0` verified · `1` a real failure (bad signature / tamper / broken inclusion) · `2` malformed bundle · `3` undetermined, which is either a gateway that could not be reached on an on-chain re-fetch or a pack the grading rule calls `incomplete`. The headline is the pack verdict, the same one `gradePack` gives the browser verify page: a record nobody hashed leaves the pack incomplete even when the signed bundle itself is `verified`, and both verdicts are printed when they differ. Only `arweave` witnesses are re-fetched: a checkpoint or stamp whose `anchor_ref.kind` is anything else is not checked, and with gateways passed the run answers `partial`, exit `3`. The producer's asserted `verdict` is shown but **never trusted**, the displayed verdict is always recomputed from the body. The same CLI also verifies the agent's `sanning.agent.proof/v1` inclusion bundle (it sniffs `spec_version`), so one command covers anchor + agent.
66
+ It prints a per-event verdict, then the PACK verdict, and exits by the one table every verb uses: `0` verified · `1` a real failure (bad signature / tamper / broken inclusion) · `2` usage or malformed · `3` undetermined (`partial`, `pending`, `incomplete` or `not_found`: a gateway that could not be reached, a stamp no witness published, or a pack the grading rule calls `incomplete`) · `4` mixed. Since 0.13.0 `partial` exits `3` whatever caused it; it exited `0` unless a gateway was unreachable. The headline is the pack verdict, the same one `gradePack` gives the browser verify page: a record nobody hashed leaves the pack incomplete even when the signed bundle itself is `verified`, and both verdicts are printed when they differ. Only `arweave` witnesses are re-fetched: a checkpoint or stamp whose `anchor_ref.kind` is anything else is not checked, and with gateways passed the run answers `partial`, exit `3`. The producer's asserted `verdict` is shown but **never trusted**, the displayed verdict is always recomputed from the body. The same CLI also verifies the agent's `sanning.agent.proof/v1` inclusion bundle (it sniffs `spec_version`), so one command covers anchor + agent.
65
67
 
66
- > The `verify` CLI is live in `@sanning/proof` ≥ 0.2.2, `npx @sanning/proof verify <bundle> [gateways]`. `verifyEvidenceBundle` is the programmatic API.
68
+ > The `verify` CLI is live in `@sanning/proof` ≥ 0.2.2, `npx @sanning/proof verify <bundle> [--gateways <url,url>]` (the gateway list was a second positional argument until 0.13.0). `verifyEvidenceBundle` is the programmatic API.
67
69
 
68
70
  Programmatically:
69
71
 
@@ -102,7 +104,7 @@ npx @sanning/proof verify pack/bundle.json --logs pack/logs
102
104
 
103
105
  Nothing is decoded, re-parsed or re-serialised on the way in: the bytes on disk are the bytes hashed against the committed `content_hash`: the same bytes a verify page hashes from the same files. That is the whole point of the directory form. The alternative (reading the directory and hex-encoding it into a map yourself) is a step that has been got wrong twice in shipped code, once by writing the hex *text* into the log files, so `SHA-256(file)` was the hash of the encoding rather than of the bytes and genuine, untampered evidence failed.
104
106
 
105
- **A side input: a JSON FILE** mapping `event_id` → bytes, where an even-length lowercase-hex value is read as hex and anything else as UTF-8 text:
107
+ **A side input: a JSON FILE** mapping `event_id` → bytes, where an even-length hex value, in either case, is read as hex and anything else as UTF-8 text:
106
108
 
107
109
  ```bash
108
110
  npx @sanning/proof verify trace-bundle.json --logs logs.json
@@ -163,28 +165,39 @@ Every event also appears in `result.events`, in body order, with a status that n
163
165
 
164
166
  Bytes come back as `Uint8Array`, never a string: the spec commits to **raw bytes**, and a UTF-8 decode is lossy for any payload that is not valid UTF-8, re-encoding such a string would change the bytes and break the hash. Decode to text yourself if you know your payload is text.
165
167
 
166
- ## Check a case: several packs, one verdict
168
+ ## Read several packs together: a case reading
167
169
 
168
170
  When a case spans more than one pack (two agents, two organizations, one hand-off between them), check the packs together. This is `readCase` from a terminal, with no page in the way:
169
171
 
170
172
  ```bash
171
173
  npx @sanning/proof case assessor-pack/ challenger-pack/
172
- unzip decision-file.zip -d case/ && npx @sanning/proof case case/ # a decision file
174
+ unzip packs.zip -d case/ && npx @sanning/proof case case/ # a zip of several packs
173
175
  npx @sanning/proof case case/ --json # the full reading, for a script
174
176
  ```
175
177
 
176
- Each argument is a folder: one pack (`bundle.json` plus `logs/`), or a folder that holds several packs, such as an unpacked decision file. `case` does not open archives: given a `.zip`, it exits `2` and says to unzip it first and pass the folder. Packs are found by the verify page's splitting rule, so the page and this command apply the same rule to the same unpacked tree: every `bundle.json` is a pack, a file belongs to the deepest pack whose `bundle.json` or `logs/` it sits in, and every other file is listed as outside every pack and never opened. A `.zip` inside the folder is a file like any other. Several folders are read as one folder holding them, each under its own name. Each `bundle.json` is read with `verify`'s own reader (`readBundleBytes`), so the two commands classify one bundle the same way.
178
+ Each argument is a folder: one pack (`bundle.json` plus `logs/`), or a folder that holds several packs, such as an unzipped folder of packs. `case` does not open archives: given a `.zip`, it exits `2` and says to unzip it first and pass the folder. Packs are found by the verify page's splitting rule, so the page and this command apply the same rule to the same unpacked tree: every `bundle.json` is a pack, a file belongs to the deepest pack whose `bundle.json` or `logs/` it sits in, and every other file is listed as outside every pack and never opened. A `.zip` inside the folder is a file like any other. Several folders are read as one folder holding them, each under its own name. Each `bundle.json` is read with `verify`'s own reader (`readBundleBytes`), so the two commands classify one bundle the same way.
177
179
 
178
180
  The walk is bounded and follows no link. A symbolic link is never followed: it is listed as outside every pack and not read, even under a pack's `logs/`, and a link named on the command line exits `2`. A folder nested more than 32 levels deep, more than 50,000 files and folders in all, or a name that is not valid UTF-8 exits `2`; a bad name is refused rather than escaped, because two different names would then read as one. A `bundle.json` nested more than 512 levels deep exits `2`, from `case` and from `verify` alike. The rules are pinned for both kernels by `vectors/case-split.json`, shipped in this tarball, as directory trees.
179
181
 
180
182
  Known differences from the verify page: the page opens a dropped `.zip`; the page drops a leading byte-order mark from `bundle.json`, which `verify`'s reader refuses; the page reads whatever the browser hands it for a link, a bad name or a very deep tree; and the page loses a record named `__proto__`, which this command keeps.
181
183
 
182
- The first line is the case verdict, then the kernel's summary and every exception through `renderException`, then the packs, the files outside them, and the on-chain half. Exit codes: `0` verified · `1` failed · `2` malformed, usage, an input that cannot be read as a case, or an unexpected error in the command · `3` incomplete or partial · `4` mixed (more than one outcome: read the exceptions). `verify` never exits `4`.
184
+ The first line is the case verdict, then the kernel's summary and every exception through `renderException`, then the packs, the files outside them, and the on-chain half. Exit codes: `0` verified · `1` failed · `2` malformed, usage, an input that cannot be read as a case, or an unexpected error in the command · `3` incomplete or partial · `4` mixed (more than one outcome: read the exceptions). This is the one table every verb uses (`EXIT_CODES`, exported from the package root), and `verify` exits `3` on `partial` since 0.13.0.
183
185
 
184
186
  A case reading establishes that every pack verified offline, that every record every pack supplied was read in one pile under envelope-spec §4.1's hand-off rules, and that a hand-off record with no counterpart is always named. It does **not** establish that the case is complete, that a party which presented no pack under the named key recorded nothing, that two organizations are independent, or that any witness was re-fetched on chain: the output says `on-chain NOT CHECKED` unless you pass `--gateways <url,url>`. With gateways, each pack's witnesses are re-fetched pack by pack and reported beside the reading, which they never change; a witness that does not match exits `1`, and an on-chain half that could not be settled turns `0` into `3`.
185
187
 
186
188
  The Python kernel ships the same command as `sanning-proof case`, and `cross-kernel/run_case_cli.sh` requires the two to print the same bytes, the same `--json` and the same exit code over every `case/` corpus vector and every tree in `vectors/case-split.json`.
187
189
 
190
+ ## Compose an attested export
191
+
192
+ An attested export (`sanning.evidence.export/v2`) carries operator attestations over a source bundle, signed with your own Ed25519 key. `composeExport` is the programmatic API:
193
+
194
+ ```bash
195
+ npx @sanning/proof export source.json --attestations attestations.json --key exporter.key --out export.json
196
+ npx @sanning/proof verify export.json
197
+ ```
198
+
199
+ `export` exits `0` when it wrote the export, `1` when the recomputed export is `failed` (it still writes it, so you can inspect it), and `2` on a bad flag or malformed input. The verb was `bundle` until 0.13.0; `bundle` is now the verb that builds a pack, in `@sanning/anchor`. The Python kernel ships the same command as `sanning-proof export`.
200
+
188
201
  ## Verify a Merkle inclusion proof
189
202
 
190
203
  Agents close daily checkpoints over per-cycle leaves (RFC 9162, §2.1 domain separation, not the Bitcoin duplicate-leaf variant). Verify a leaf's inclusion against an anchored root:
@@ -205,16 +218,16 @@ const ok = await verifyInclusion(
205
218
 
206
219
  | Export | What it does |
207
220
  |---|---|
208
- | `verifyEnvelope(env, optionsOrContentHash?)` | The three load-bearing checks (spec-version registry, payload-hash recompute, Ed25519 over the signed scope) + optional content bind. Pass `{ payloadBytes }` for external-commitment envelopes. Returns `VerificationResult`. |
221
+ | `verifyEnvelope(env, options?)` | The three load-bearing checks (spec-version registry, payload-hash recompute, Ed25519 over the signed scope) + optional content bind. Pass `{ payloadBytes }` for external-commitment envelopes, `{ expectedContentHash }` for the content bind (a bare string is refused since 0.13.0), and `{ acceptedSpecMajors }` to narrow the accept-set for one call. Returns `VerificationResult`. |
209
222
  | `verifyEvidenceBundle(bundle, { gateways?, fetchImpl? })` | Verify a signed `sanning.evidence/v1` bundle (`sanning.anchor.trace/v3`, `sanning.stamp.trace/v1`, `sanning.stamp.evidence/v2` or `sanning.evidence.export/v2`): wrapper signature + `body_hash` + every checkpoint + every event's inclusion, offline (optional on-chain re-fetch). Returns `EvidenceBundleResult`. Powers the `npx @sanning/proof verify` CLI. |
210
223
  | `disclosedContent(bundle, { gateways?, content?, fetchImpl? })` | **Ingest:** verify a bundle, then hand back the DECODED raw bytes (`Uint8Array`) of every event whose disclosure bound to its committed `content_hash`, so materializing a received pack never means re-implementing the hex convention. Returns `DisclosedContentResult` (`content` map + per-event `verified` / `not_disclosed` / `undetermined` / `failed`, plus the full `verification`). |
211
224
  | `gradePack(result, account, notes)` + `accountDisclosure(bundle, content)` / `completenessNotes(bundle)` / `renderCause(cause)` | **Pack grading:** what a SURFACE may claim about a pack: the bundle plus whatever disclosed bytes travelled with it. Three states, never two: `verified` only when every record's own bytes bound to its own commitment and nothing is missing, unreadable, surplus or omitted; `failed` on a proven mismatch; `incomplete` when a check could not be run. Absent evidence is UNKNOWN, never an accusation. Pure: no I/O; transport stays yours. Conformance table: `vectors/pack-grading.json`, shipped in this tarball. |
212
- | `verifyAgentProofBundle(bundle, { gateways?, fetchImpl? })` / `isAgentProofSpec(v)` | Verify the agent producer's `sanning.agent.proof/v1` inclusion bundle. |
225
+ | `verifyAgentProof(bundle, { gateways?, fetchImpl? })` / `isAgentProofSpec(v)` | Verify the agent producer's `sanning.agent.proof/v1` inclusion bundle. |
213
226
  | `contentHashes(env)` | The content hash(es) an envelope commits to, by event type: the reverse-provenance join keys. |
214
227
  | `specVersionSupported(v)` | Fail-closed accept-check: `sanning.agent/v1` and additive minors (`sanning.agent/v1.<n>`). Unknown majors/profiles are rejected. |
215
228
  | `jcs(value)` | RFC 8785 canonical JSON bytes. |
216
229
  | `sha256Hex(bytes)` / `sha256Bytes(bytes)` | SHA-256 via WebCrypto. |
217
- | `ed25519Verify(sig, msg, pubKey)` | Raw Ed25519 verification. |
230
+ | `ed25519Verify(message, signatureHex, publicKeyHex)` | Raw Ed25519 verification, strict RFC 8032 (`zip215: false`). The message comes first since 0.13.0, as in the Python `ed25519_verify`. |
218
231
  | `leafHash` / `nodeHash` / `merkleRoot` / `auditPath` / `verifyInclusion` / `EMPTY_TREE_ROOT_HEX` | RFC 9162 binary Merkle tree primitives. |
219
232
  | `utf8` / `bytesToHex` / `hexToBytes` | Encoding helpers. |
220
233
  | `Envelope` / `VerificationResult` / `ContentRole` / `Subject` | Types. |
@@ -1,5 +1,5 @@
1
1
  import type { Envelope } from "./types.js";
2
- export declare const AGENT_PROOF_SPEC_PREFIX = "sanning.agent.proof/v1";
2
+ export declare const AGENT_PROOF_SPEC_VERSION = "sanning.agent.proof/v1";
3
3
  export interface AgentProofBundle {
4
4
  spec_version: string;
5
5
  checkpoint_envelope: Envelope;
@@ -25,4 +25,4 @@ export interface VerifyAgentProofOptions {
25
25
  fetchImpl?: typeof fetch;
26
26
  }
27
27
  export declare function isAgentProofSpec(specVersion: unknown): boolean;
28
- export declare function verifyAgentProofBundle(bundle: unknown, options?: VerifyAgentProofOptions): Promise<AgentProofResult>;
28
+ export declare function verifyAgentProof(bundle: unknown, options?: VerifyAgentProofOptions): Promise<AgentProofResult>;
@@ -1,4 +1,4 @@
1
- // Agent inclusion-proof bundle verification.
1
+ // Agent inclusion proof verification (`sanning.agent.proof/v1`).
2
2
  //
3
3
  // The `sanning.agent.proof/v1` bundle is the agent's ~1.4 KB self-contained
4
4
  // inclusion proof: a signed checkpoint envelope (sanning.agent/v1, INLINE payload)
@@ -16,7 +16,7 @@
16
16
  import { hexToBytes, utf8 } from "./crypto.js";
17
17
  import { leafHash, verifyInclusion } from "./merkle.js";
18
18
  import { MAX_CANONICAL_DEPTH, exceedsDepth, jcs, jcsParse, verifyEnvelope } from "./verifier.js";
19
- export const AGENT_PROOF_SPEC_PREFIX = "sanning.agent.proof/v1";
19
+ export const AGENT_PROOF_SPEC_VERSION = "sanning.agent.proof/v1";
20
20
  function stringifyErr(e) {
21
21
  return e instanceof Error ? e.message : String(e);
22
22
  }
@@ -29,9 +29,9 @@ function toHex(bytes) {
29
29
  export function isAgentProofSpec(specVersion) {
30
30
  if (typeof specVersion !== "string")
31
31
  return false;
32
- return specVersion === AGENT_PROOF_SPEC_PREFIX || specVersion.startsWith(`${AGENT_PROOF_SPEC_PREFIX}.`);
32
+ return specVersion === AGENT_PROOF_SPEC_VERSION || specVersion.startsWith(`${AGENT_PROOF_SPEC_VERSION}.`);
33
33
  }
34
- export async function verifyAgentProofBundle(bundle, options = {}) {
34
+ export async function verifyAgentProof(bundle, options = {}) {
35
35
  const fail = (error) => ({
36
36
  status: "malformed",
37
37
  checkpointOk: false,
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The case file's index, compared with the drop it describes (A25).
3
+ *
4
+ * The TypeScript half of `src/sanning_proof/case_file.py`. Both kernels are
5
+ * held to one table, `vectors/case-file-index.json`, so neither can pass by
6
+ * agreeing with the other, and `cross-kernel/run_case_cli.sh` holds the two
7
+ * commands to the same `case_file` output.
8
+ *
9
+ * WHAT A CASE FILE IS (evidence-bundle.md 5.6 v1.20). A ZIP archive holding one
10
+ * case's packs, each unpacked under `packs/<ref>/`, an index (`contents.json`),
11
+ * the one command that checks them (`HOW-TO-VERIFY.txt`) and an optional
12
+ * `report/` of files no record commits to. **Nobody signs it.** Each pack in it
13
+ * is byte for byte the pack its signer produced, and a reader reads the packs,
14
+ * never the file.
15
+ *
16
+ * WHAT THIS MODULE DOES, AND WHAT IT MUST NEVER DO. It compares the index with
17
+ * the files the reader was handed and returns findings with NO verdict
18
+ * contribution. **The index is never an input to the case reading**
19
+ * (`readCase`): adding, removing or editing `contents.json` moves no verdict,
20
+ * no exception, no note, no contribution and no exit code. An assembler can lie
21
+ * in every member of the index, and each lie is a finding here and nothing
22
+ * more; it can leave the index out, which is no finding at all.
23
+ *
24
+ * Pure: bytes and a file list in, a reading out. No filesystem, no zip, no
25
+ * network. How the files were listed and hashed is the caller's.
26
+ */
27
+ /** The index's dispatch key (5.6.2). An absent or unknown value makes the index unreadable. */
28
+ export declare const CASE_FILE_VERSION = "sanning.case-file/v1";
29
+ /** The index, at the root of the case file. */
30
+ export declare const CASE_FILE_INDEX_NAME = "contents.json";
31
+ /** The one command that checks the file, at its root (5.6.4). */
32
+ export declare const HOW_TO_VERIFY_NAME = "HOW-TO-VERIFY.txt";
33
+ /** The first line of `report/README.txt`, exactly (5.6.1). */
34
+ export declare const REPORT_README_FIRST_LINE = "Not evidence: no record in this case file commits to the files in this folder.";
35
+ /**
36
+ * How deep `[` and `{` may nest in the index before it is refused unread. The
37
+ * same limit `bundle.json` is read under, so a hostile index cannot make one
38
+ * kernel's parser recurse where the other's does not.
39
+ */
40
+ export declare const MAX_INDEX_DEPTH = 512;
41
+ /**
42
+ * Every finding this comparison can report, in the order it reports them
43
+ * (5.6.3). None of them is a verdict word and none contributes to one.
44
+ */
45
+ export declare const CASE_FILE_FINDING_KINDS: readonly ["case_index_unreadable", "case_index_pack_missing", "case_index_pack_unlisted", "case_index_file_missing", "case_index_file_unlisted", "case_index_file_differs", "case_index_names_absent"];
46
+ export type CaseFileFindingKind = (typeof CASE_FILE_FINDING_KINDS)[number];
47
+ /**
48
+ * Whether `ref` is a legal pack folder name in a case file. For a producer; a
49
+ * reader takes whatever folders it finds and names them.
50
+ */
51
+ export declare function isPackRef(ref: unknown): boolean;
52
+ /**
53
+ * `HOW-TO-VERIFY.txt`, exactly (5.6.4): ASCII, LF line endings, with `name` the
54
+ * case file's name without `.zip`.
55
+ */
56
+ export declare function howToVerifyText(name: string): string;
57
+ /**
58
+ * One way the index and the drop disagree, as data. What the index SAYS
59
+ * against what the folder HOLDS, and never a statement about any party.
60
+ */
61
+ export interface CaseFileFinding {
62
+ kind: CaseFileFindingKind;
63
+ /** The pack folder name, for the two pack kinds. */
64
+ ref?: string;
65
+ /** The file, from the case file's root, for the three file kinds. */
66
+ path?: string;
67
+ /** What the index lists for the file, for `case_index_file_differs`. */
68
+ listedSha256?: string;
69
+ /** What the file in the folder hashes to, for `case_index_file_differs`. */
70
+ sha256?: string;
71
+ /**
72
+ * Why the index is unreadable, or the assembler's reason a pack is absent,
73
+ * for `case_index_unreadable` and `case_index_names_absent`.
74
+ */
75
+ reason?: string;
76
+ /**
77
+ * The key the assembler says the absent pack would be signed by, as the index
78
+ * writes it, for `case_index_names_absent`.
79
+ */
80
+ publicKey?: string;
81
+ }
82
+ export interface CaseFileIndexReading {
83
+ /** `absent` (no index was handed in), `unreadable` or `read`. */
84
+ index: "absent" | "unreadable" | "read";
85
+ /**
86
+ * The index's `case_id`, a label the assembler wrote and nothing is compared
87
+ * with, or `null` where the index was not read.
88
+ */
89
+ caseId: string | null;
90
+ findings: CaseFileFinding[];
91
+ }
92
+ /**
93
+ * Whether `[` and `{` outside strings ever nest deeper than `limit`. One pass,
94
+ * no recursion; the same scan as `json_depth_exceeds` in the Python twin, so
95
+ * both kernels refuse the same text. Given bytes, it reads each byte as the
96
+ * character of that code, which is the same answer for UTF-8: the six
97
+ * characters it reads are single bytes and no multi-byte sequence holds one.
98
+ * The CLI's `bundle.json` reader uses it too.
99
+ */
100
+ export declare function jsonDepthExceeds(text: string | Uint8Array, limit: number): boolean;
101
+ /**
102
+ * Compare a case file's index with the folder it sits at the root of.
103
+ *
104
+ * `index` is the bytes of `contents.json`, or `null` where the folder holds
105
+ * none. `files` is every regular file in the folder, as `{ path, sha256 }`
106
+ * with `path` from the folder's root, `/`-separated; only the files under
107
+ * `packs/` and `report/` are compared, so a caller may list the rest or not. A
108
+ * folder with no index is a folder of packs, with no finding.
109
+ *
110
+ * Findings come in the order of `CASE_FILE_FINDING_KINDS`, and within a kind in
111
+ * the order of their `ref` or `path` by UTF-8 bytes; `case_index_names_absent`
112
+ * is in the index's own order, one per `absent[]` entry. An unreadable index is
113
+ * one finding and no other comparison runs.
114
+ *
115
+ * Nothing here is a verdict, and nothing here is ever an input to `readCase`.
116
+ */
117
+ export declare function readCaseFileIndex(index: Uint8Array | null, files: readonly {
118
+ path: string;
119
+ sha256: string;
120
+ }[]): CaseFileIndexReading;
121
+ /**
122
+ * The house wording for one finding: what the index says against what the
123
+ * folder holds, and the assembler's own statement for an absent pack. Never a
124
+ * sentence about a party. Both kernels return this text.
125
+ */
126
+ export declare function renderCaseFileFinding(finding: CaseFileFinding): string;
@@ -0,0 +1,353 @@
1
+ /**
2
+ * The case file's index, compared with the drop it describes (A25).
3
+ *
4
+ * The TypeScript half of `src/sanning_proof/case_file.py`. Both kernels are
5
+ * held to one table, `vectors/case-file-index.json`, so neither can pass by
6
+ * agreeing with the other, and `cross-kernel/run_case_cli.sh` holds the two
7
+ * commands to the same `case_file` output.
8
+ *
9
+ * WHAT A CASE FILE IS (evidence-bundle.md 5.6 v1.20). A ZIP archive holding one
10
+ * case's packs, each unpacked under `packs/<ref>/`, an index (`contents.json`),
11
+ * the one command that checks them (`HOW-TO-VERIFY.txt`) and an optional
12
+ * `report/` of files no record commits to. **Nobody signs it.** Each pack in it
13
+ * is byte for byte the pack its signer produced, and a reader reads the packs,
14
+ * never the file.
15
+ *
16
+ * WHAT THIS MODULE DOES, AND WHAT IT MUST NEVER DO. It compares the index with
17
+ * the files the reader was handed and returns findings with NO verdict
18
+ * contribution. **The index is never an input to the case reading**
19
+ * (`readCase`): adding, removing or editing `contents.json` moves no verdict,
20
+ * no exception, no note, no contribution and no exit code. An assembler can lie
21
+ * in every member of the index, and each lie is a finding here and nothing
22
+ * more; it can leave the index out, which is no finding at all.
23
+ *
24
+ * Pure: bytes and a file list in, a reading out. No filesystem, no zip, no
25
+ * network. How the files were listed and hashed is the caller's.
26
+ */
27
+ import { DuplicateKeyError, jcsParse } from "./verifier.js";
28
+ /** The index's dispatch key (5.6.2). An absent or unknown value makes the index unreadable. */
29
+ export const CASE_FILE_VERSION = "sanning.case-file/v1";
30
+ /** The index, at the root of the case file. */
31
+ export const CASE_FILE_INDEX_NAME = "contents.json";
32
+ /** The one command that checks the file, at its root (5.6.4). */
33
+ export const HOW_TO_VERIFY_NAME = "HOW-TO-VERIFY.txt";
34
+ /** The first line of `report/README.txt`, exactly (5.6.1). */
35
+ export const REPORT_README_FIRST_LINE = "Not evidence: no record in this case file commits to the files in this folder.";
36
+ /**
37
+ * How deep `[` and `{` may nest in the index before it is refused unread. The
38
+ * same limit `bundle.json` is read under, so a hostile index cannot make one
39
+ * kernel's parser recurse where the other's does not.
40
+ */
41
+ export const MAX_INDEX_DEPTH = 512;
42
+ /**
43
+ * Every finding this comparison can report, in the order it reports them
44
+ * (5.6.3). None of them is a verdict word and none contributes to one.
45
+ */
46
+ export const CASE_FILE_FINDING_KINDS = [
47
+ "case_index_unreadable",
48
+ "case_index_pack_missing",
49
+ "case_index_pack_unlisted",
50
+ "case_index_file_missing",
51
+ "case_index_file_unlisted",
52
+ "case_index_file_differs",
53
+ "case_index_names_absent",
54
+ ];
55
+ // `<ref>`: 1 to 128 characters from A-Z, a-z, 0-9, `.`, `_` and `-`, and not
56
+ // `.` or `..` (5.6.1). The class holds no newline, so `$` cannot match before
57
+ // one here.
58
+ const REF = /^[A-Za-z0-9._-]{1,128}$/;
59
+ /**
60
+ * Whether `ref` is a legal pack folder name in a case file. For a producer; a
61
+ * reader takes whatever folders it finds and names them.
62
+ */
63
+ export function isPackRef(ref) {
64
+ return typeof ref === "string" && REF.test(ref) && ref !== "." && ref !== "..";
65
+ }
66
+ /**
67
+ * `HOW-TO-VERIFY.txt`, exactly (5.6.4): ASCII, LF line endings, with `name` the
68
+ * case file's name without `.zip`.
69
+ */
70
+ export function howToVerifyText(name) {
71
+ return ("This case file holds evidence packs. Each pack is signed by its own key.\n" +
72
+ "Nobody signed this file, contents.json, or anything under report/.\n" +
73
+ "\n" +
74
+ "To check it offline, on your own machine:\n" +
75
+ "\n" +
76
+ ` unzip ${name}.zip -d ${name}\n` +
77
+ ` npx @sanning/proof case ${name}\n` +
78
+ "\n" +
79
+ "or, with the Python kernel (pip install sanning-proof):\n" +
80
+ "\n" +
81
+ ` sanning-proof case ${name}\n` +
82
+ "\n" +
83
+ "Exit codes: 0 verified, 1 failed, 2 not readable or a usage error,\n" +
84
+ "3 could not be settled (partial, pending, incomplete, not found), 4 mixed.\n" +
85
+ "\n" +
86
+ "To also look each witness up on the public record, add --gateways <url,url>.\n" +
87
+ "The lookup is reported on its own line and never changes the verdict word.\n" +
88
+ "With it, a witness that does not match exits 1.\n");
89
+ }
90
+ /**
91
+ * Whether `[` and `{` outside strings ever nest deeper than `limit`. One pass,
92
+ * no recursion; the same scan as `json_depth_exceeds` in the Python twin, so
93
+ * both kernels refuse the same text. Given bytes, it reads each byte as the
94
+ * character of that code, which is the same answer for UTF-8: the six
95
+ * characters it reads are single bytes and no multi-byte sequence holds one.
96
+ * The CLI's `bundle.json` reader uses it too.
97
+ */
98
+ export function jsonDepthExceeds(text, limit) {
99
+ let depth = 0;
100
+ let inString = false;
101
+ let escaped = false;
102
+ const n = text.length;
103
+ for (let i = 0; i < n; i++) {
104
+ const ch = typeof text === "string" ? text.charCodeAt(i) : text[i];
105
+ if (inString) {
106
+ if (escaped)
107
+ escaped = false;
108
+ else if (ch === 0x5c)
109
+ escaped = true;
110
+ else if (ch === 0x22)
111
+ inString = false;
112
+ }
113
+ else if (ch === 0x22) {
114
+ inString = true;
115
+ }
116
+ else if (ch === 0x5b || ch === 0x7b) {
117
+ depth++;
118
+ if (depth > limit)
119
+ return true;
120
+ }
121
+ else if (ch === 0x5d || ch === 0x7d) {
122
+ depth--;
123
+ }
124
+ }
125
+ return false;
126
+ }
127
+ const utf8 = new TextEncoder();
128
+ /** Sorted by the UTF-8 bytes of the value (5.6.2), never by UTF-16 code unit. */
129
+ function byUtf8(a, b) {
130
+ const x = utf8.encode(a);
131
+ const y = utf8.encode(b);
132
+ const n = Math.min(x.length, y.length);
133
+ for (let i = 0; i < n; i++) {
134
+ if (x[i] !== y[i])
135
+ return x[i] - y[i];
136
+ }
137
+ return x.length - y.length;
138
+ }
139
+ class Unreadable extends Error {
140
+ reason;
141
+ constructor(reason) {
142
+ super(reason);
143
+ this.reason = reason;
144
+ }
145
+ }
146
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
147
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
148
+ /** An own member, and never one the prototype chain answers for. */
149
+ const own = (o, k) => (has(o, k) ? o[k] : undefined);
150
+ function fileList(value, where) {
151
+ if (!Array.isArray(value))
152
+ throw new Unreadable(`${where} is not an array`);
153
+ const out = [];
154
+ value.forEach((entry, i) => {
155
+ if (!(isObject(entry) &&
156
+ typeof own(entry, "path") === "string" &&
157
+ typeof own(entry, "sha256") === "string")) {
158
+ throw new Unreadable(`${where}[${i}] is not an object with a string path and sha256`);
159
+ }
160
+ out.push([entry["path"], entry["sha256"]]);
161
+ });
162
+ return out;
163
+ }
164
+ /**
165
+ * The index, read under the JSON rule 5.4.2 rule 7 pins, and checked for every
166
+ * REQUIRED member and its type. JCS is not required and an unknown member is
167
+ * ignored (5.6.2). The FIRST problem is the reason.
168
+ */
169
+ function readIndex(raw) {
170
+ // Scanned as BYTES, before any decode, so both kernels refuse the same index
171
+ // in the same order whatever it holds.
172
+ if (jsonDepthExceeds(raw, MAX_INDEX_DEPTH)) {
173
+ throw new Unreadable(`nests deeper than ${MAX_INDEX_DEPTH} levels`);
174
+ }
175
+ let value;
176
+ try {
177
+ value = jcsParse(raw);
178
+ }
179
+ catch (e) {
180
+ // Named, because an index that lists one pack twice under one member name
181
+ // reads as whichever copy came last (I-JSON, proof#144).
182
+ if (e instanceof DuplicateKeyError)
183
+ throw new Unreadable(e.message);
184
+ throw new Unreadable("not readable as JSON");
185
+ }
186
+ if (!isObject(value))
187
+ throw new Unreadable("not a JSON object");
188
+ if (own(value, "contents_version") !== CASE_FILE_VERSION) {
189
+ throw new Unreadable(`contents_version is not ${CASE_FILE_VERSION}`);
190
+ }
191
+ const caseId = own(value, "case_id");
192
+ if (typeof caseId !== "string")
193
+ throw new Unreadable("case_id is not a string");
194
+ const packs = own(value, "packs");
195
+ if (!Array.isArray(packs))
196
+ throw new Unreadable("packs is not an array");
197
+ const listedPacks = [];
198
+ packs.forEach((entry, i) => {
199
+ if (!(isObject(entry) && typeof own(entry, "ref") === "string")) {
200
+ throw new Unreadable(`packs[${i}] is not an object with a string ref`);
201
+ }
202
+ listedPacks.push([entry["ref"], fileList(own(entry, "files"), `packs[${i}].files`)]);
203
+ });
204
+ const absent = own(value, "absent");
205
+ if (!Array.isArray(absent))
206
+ throw new Unreadable("absent is not an array");
207
+ const listedAbsent = [];
208
+ absent.forEach((entry, i) => {
209
+ if (!(isObject(entry) && typeof own(entry, "reason") === "string")) {
210
+ throw new Unreadable(`absent[${i}] is not an object with a string reason`);
211
+ }
212
+ const key = own(entry, "public_key");
213
+ if (has(entry, "public_key") && typeof key !== "string") {
214
+ throw new Unreadable(`absent[${i}].public_key is not a string`);
215
+ }
216
+ listedAbsent.push([entry["reason"], has(entry, "public_key") ? key : null]);
217
+ });
218
+ const report = fileList(own(value, "report"), "report");
219
+ return { caseId, packs: listedPacks, absent: listedAbsent, report };
220
+ }
221
+ /**
222
+ * Compare a case file's index with the folder it sits at the root of.
223
+ *
224
+ * `index` is the bytes of `contents.json`, or `null` where the folder holds
225
+ * none. `files` is every regular file in the folder, as `{ path, sha256 }`
226
+ * with `path` from the folder's root, `/`-separated; only the files under
227
+ * `packs/` and `report/` are compared, so a caller may list the rest or not. A
228
+ * folder with no index is a folder of packs, with no finding.
229
+ *
230
+ * Findings come in the order of `CASE_FILE_FINDING_KINDS`, and within a kind in
231
+ * the order of their `ref` or `path` by UTF-8 bytes; `case_index_names_absent`
232
+ * is in the index's own order, one per `absent[]` entry. An unreadable index is
233
+ * one finding and no other comparison runs.
234
+ *
235
+ * Nothing here is a verdict, and nothing here is ever an input to `readCase`.
236
+ */
237
+ export function readCaseFileIndex(index, files) {
238
+ if (index === null || index === undefined) {
239
+ return { index: "absent", caseId: null, findings: [] };
240
+ }
241
+ let read;
242
+ try {
243
+ read = readIndex(index);
244
+ }
245
+ catch (e) {
246
+ if (!(e instanceof Unreadable))
247
+ throw e;
248
+ return {
249
+ index: "unreadable",
250
+ caseId: null,
251
+ findings: [{ kind: "case_index_unreadable", reason: e.reason }],
252
+ };
253
+ }
254
+ const here = new Map();
255
+ for (const entry of files) {
256
+ const path = isObject(entry) ? own(entry, "path") : undefined;
257
+ const digest = isObject(entry) ? own(entry, "sha256") : undefined;
258
+ if (typeof path === "string" && typeof digest === "string" && !here.has(path)) {
259
+ here.set(path, digest);
260
+ }
261
+ }
262
+ const listedRefs = new Set(read.packs.map(([ref]) => ref));
263
+ // The first listing of a path wins, in index order: packs in order, each
264
+ // pack's files in order, then report/. A path listed twice is compared once.
265
+ const listed = new Map();
266
+ for (const [, packFiles] of read.packs) {
267
+ for (const [path, digest] of packFiles)
268
+ if (!listed.has(path))
269
+ listed.set(path, digest);
270
+ }
271
+ for (const [path, digest] of read.report)
272
+ if (!listed.has(path))
273
+ listed.set(path, digest);
274
+ const findings = [];
275
+ for (const ref of [...listedRefs].sort(byUtf8)) {
276
+ if (!here.has(`packs/${ref}/bundle.json`)) {
277
+ findings.push({ kind: "case_index_pack_missing", ref });
278
+ }
279
+ }
280
+ const presentRefs = new Set();
281
+ for (const path of here.keys()) {
282
+ const parts = path.split("/");
283
+ if (parts.length === 3 && parts[0] === "packs" && parts[2] === "bundle.json") {
284
+ presentRefs.add(parts[1]);
285
+ }
286
+ }
287
+ for (const ref of [...presentRefs].filter((r) => !listedRefs.has(r)).sort(byUtf8)) {
288
+ findings.push({ kind: "case_index_pack_unlisted", ref });
289
+ }
290
+ const listedPaths = [...listed.keys()].sort(byUtf8);
291
+ for (const path of listedPaths) {
292
+ if (!here.has(path))
293
+ findings.push({ kind: "case_index_file_missing", path });
294
+ }
295
+ const compared = (path) => {
296
+ if (path.startsWith("report/"))
297
+ return true;
298
+ const parts = path.split("/");
299
+ return parts.length >= 3 && parts[0] === "packs" && listedRefs.has(parts[1]);
300
+ };
301
+ for (const path of [...here.keys()].sort(byUtf8)) {
302
+ if (!listed.has(path) && compared(path)) {
303
+ findings.push({ kind: "case_index_file_unlisted", path });
304
+ }
305
+ }
306
+ for (const path of listedPaths) {
307
+ const actual = here.get(path);
308
+ if (actual !== undefined && actual !== listed.get(path)) {
309
+ findings.push({
310
+ kind: "case_index_file_differs",
311
+ path,
312
+ listedSha256: listed.get(path),
313
+ sha256: actual,
314
+ });
315
+ }
316
+ }
317
+ for (const [reason, key] of read.absent) {
318
+ findings.push(key === null
319
+ ? { kind: "case_index_names_absent", reason }
320
+ : { kind: "case_index_names_absent", reason, publicKey: key });
321
+ }
322
+ return { index: "read", caseId: read.caseId, findings };
323
+ }
324
+ /**
325
+ * The house wording for one finding: what the index says against what the
326
+ * folder holds, and the assembler's own statement for an absent pack. Never a
327
+ * sentence about a party. Both kernels return this text.
328
+ */
329
+ export function renderCaseFileFinding(finding) {
330
+ switch (finding.kind) {
331
+ case "case_index_unreadable":
332
+ return `contents.json is not an index this reader can read: ${finding.reason}`;
333
+ case "case_index_pack_missing":
334
+ return `the index lists pack ${finding.ref}; it is not here`;
335
+ case "case_index_pack_unlisted":
336
+ return `pack ${finding.ref} is here; the index does not list it`;
337
+ case "case_index_file_missing":
338
+ return `the index lists ${finding.path}; it is not here`;
339
+ case "case_index_file_unlisted":
340
+ return `${finding.path} is here; the index does not list it`;
341
+ case "case_index_file_differs":
342
+ return (`the index lists ${finding.path} with SHA-256 ${finding.listedSha256}; ` +
343
+ `the file here hashes to ${finding.sha256}`);
344
+ case "case_index_names_absent":
345
+ if (finding.publicKey !== undefined) {
346
+ return ("the assembler says a pack signed by key " +
347
+ `${finding.publicKey} is absent: ${finding.reason}`);
348
+ }
349
+ return `the assembler says a pack is absent: ${finding.reason}`;
350
+ default:
351
+ return finding.kind;
352
+ }
353
+ }