@sanning/proof 0.8.0 → 0.8.2
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 +32 -32
- package/dist/cli.js +25 -0
- package/dist/evidence.d.ts +29 -0
- package/dist/evidence.js +183 -29
- package/package.json +3 -7
- package/src/cli.ts +29 -0
- package/src/evidence.ts +266 -32
- package/vectors/README.md +18 -0
- package/vectors/stamp-on-chain.json +229 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @sanning/proof
|
|
2
2
|
|
|
3
|
-
Verify Sanning evidence-plane provenance records
|
|
3
|
+
Verify Sanning evidence-plane provenance records, **with no Sanning code or service in the trust path**.
|
|
4
4
|
|
|
5
5
|
This is the TypeScript verification kernel for the *Verifiable Event Envelope* family: the signed JSON records that producers such as the verification agent daemon and the MLflow plugin anchor permanently to Arweave. Given an envelope (fetched from any Arweave gateway), this package answers: *is it authentic, and does it commit to the bytes I'm holding?*
|
|
6
6
|
|
|
@@ -29,10 +29,10 @@ const result = await verifyEnvelope(env);
|
|
|
29
29
|
result.ok; // spec_version + payload_hash + Ed25519 all passed
|
|
30
30
|
result.signatureOk; // Ed25519 over the signed scope, against env.public_key
|
|
31
31
|
result.payloadHashOk; // SHA-256(JCS(payload)) === env.payload_hash
|
|
32
|
-
result.errors; // [] when ok
|
|
32
|
+
result.errors; // [] when ok: machine-readable reasons otherwise
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
`verifyEnvelope` never throws on hostile input
|
|
35
|
+
`verifyEnvelope` never throws on hostile input: a malformed envelope (or a lying gateway) yields `ok: false` with reasons, not an exception.
|
|
36
36
|
|
|
37
37
|
## Bind an envelope to bytes you hold (reverse provenance)
|
|
38
38
|
|
|
@@ -51,9 +51,9 @@ result.contentRole; // how it commits: e.g. the registered baseline,
|
|
|
51
51
|
contentHashes(env); // all content hashes an envelope commits to, by event type
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
## Verify a whole trace bundle
|
|
54
|
+
## Verify a whole trace bundle: one command
|
|
55
55
|
|
|
56
|
-
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/v1`). Verify the entire bundle
|
|
56
|
+
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/v1`). Verify the entire bundle (every event's signature + payload binding + Merkle inclusion, all offline) with the bundled CLI:
|
|
57
57
|
|
|
58
58
|
```bash
|
|
59
59
|
npx @sanning/proof verify trace-bundle.json
|
|
@@ -61,9 +61,9 @@ npx @sanning/proof verify trace-bundle.json
|
|
|
61
61
|
npx @sanning/proof verify trace-bundle.json https://arweave.net,https://permagate.io
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
It prints a per-event + rollup verdict and exits on a pinned code
|
|
64
|
+
It prints a per-event + rollup verdict and exits on a pinned code: `0` verified · `1` a real failure (bad signature / tamper / broken inclusion) · `2` malformed bundle · `3` gateway-unavailable when an on-chain re-fetch was requested. 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
65
|
|
|
66
|
-
> The `verify` CLI is live in `@sanning/proof` ≥ 0.2.2
|
|
66
|
+
> The `verify` CLI is live in `@sanning/proof` ≥ 0.2.2, `npx @sanning/proof verify <bundle> [gateways]`. `verifyEvidenceBundle` is the programmatic API.
|
|
67
67
|
|
|
68
68
|
Programmatically:
|
|
69
69
|
|
|
@@ -80,13 +80,13 @@ result.checkpoints; // per-checkpoint envelope / merkle-root / on-chain results
|
|
|
80
80
|
result.events; // per-event signature / payload-binding / inclusion results
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
A withheld `record_bytes` (external commitment, record not disclosed) leaves that event's payload binding **undetermined
|
|
83
|
+
A withheld `record_bytes` (external commitment, record not disclosed) leaves that event's payload binding **undetermined**, surfaced, never failed (the bundle stays cryptographically sound).
|
|
84
84
|
|
|
85
85
|
### …including the raw logs, when the holder discloses them
|
|
86
86
|
|
|
87
87
|
The checks above prove the *stamps*. To also confirm a holder's **raw logs are the bytes whose hash was anchored**, the verifier closes the final `rawLog → content_hash` link: `SHA-256(disclosed bytes)` must equal the `content_hash` the event's record committed to.
|
|
88
88
|
|
|
89
|
-
A bundle a producer built with disclosure carries the bytes **in the signed body** (`events[].content`), so they're checked automatically
|
|
89
|
+
A bundle a producer built with disclosure carries the bytes **in the signed body** (`events[].content`), so they're checked automatically: no flag, and the disclosure is itself covered by the wrapper signature:
|
|
90
90
|
|
|
91
91
|
```bash
|
|
92
92
|
npx @sanning/proof verify trace-bundle.json # in-body content verified inline
|
|
@@ -94,15 +94,15 @@ npx @sanning/proof verify trace-bundle.json # in-body content verified
|
|
|
94
94
|
|
|
95
95
|
If the logs travel **alongside** a minimal bundle instead, pass them with `--logs`. It takes either shape below, and which one you passed is decided by `stat`, never by the extension.
|
|
96
96
|
|
|
97
|
-
**A materialized pack
|
|
97
|
+
**A materialized pack: a DIRECTORY.** `materializeBundle` (in [`@sanning/anchor`](https://www.npmjs.com/package/@sanning/anchor)) writes `bundle.json` + `logs/<event_id>.json`, and each file's **raw bytes are the disclosure**. Point `--logs` at that directory and the pack verifies with nothing of ours in the path:
|
|
98
98
|
|
|
99
99
|
```bash
|
|
100
100
|
npx @sanning/proof verify pack/bundle.json --logs pack/logs
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
-
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
|
|
103
|
+
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
104
|
|
|
105
|
-
**A side input
|
|
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:
|
|
106
106
|
|
|
107
107
|
```bash
|
|
108
108
|
npx @sanning/proof verify trace-bundle.json --logs logs.json
|
|
@@ -110,20 +110,20 @@ npx @sanning/proof verify trace-bundle.json --logs logs.json
|
|
|
110
110
|
|
|
111
111
|
Emit hex, not text, into that map: a UTF-8 decode is lossy for a non-text payload, and a log line that happens to *look* like hex would be decoded into something the producer never wrote. `logsSideInput` in `@sanning/anchor` builds the map correctly.
|
|
112
112
|
|
|
113
|
-
The directory form's edge cases, **none of which is a verification failure
|
|
113
|
+
The directory form's edge cases, **none of which is a verification failure**, an absent disclosure is not evidence of tampering:
|
|
114
114
|
|
|
115
115
|
| Situation | What happens |
|
|
116
116
|
|---|---|
|
|
117
117
|
| A record with no file | That event's content is **undetermined** (`~`) and reported as undisclosed. Exit code unchanged. |
|
|
118
118
|
| An empty directory | Valid. Discloses nothing, says so, exit code unchanged. |
|
|
119
|
-
| A file whose name is no event id | Read, binds to nothing, and is **named** in a note
|
|
119
|
+
| A file whose name is no event id | Read, binds to nothing, and is **named** in a note: a typo'd filename is otherwise indistinguishable from minimal disclosure. |
|
|
120
120
|
| A subdirectory | Skipped, never recursed: the layout is flat. Noted. |
|
|
121
121
|
| Anything not named `<event_id>.json`, or not a regular file | Skipped, noted. |
|
|
122
122
|
| A file we selected but cannot read | **Exit 2.** We were pointed at it, so a permissions or broken-link problem must not read as minimal disclosure. |
|
|
123
123
|
|
|
124
|
-
Either way each event gains a `contentOk`: **`true`** match · **`false`** mismatch (the event *and* the rollup fail, exit `1`) · **`null`** undetermined
|
|
124
|
+
Either way each event gains a `contentOk`: **`true`** match · **`false`** mismatch (the event *and* the rollup fail, exit `1`) · **`null`** undetermined, nothing disclosed, or no committed `content_hash` (a withheld record); never a failure, mirroring the payload-binding rule. The CLI prints a `logs ✓/✗/~` mark per event and a `logs: N/M disclosed verified` line.
|
|
125
125
|
|
|
126
|
-
Programmatically the same side input is `content`, keyed by `event_id
|
|
126
|
+
Programmatically the same side input is `content`, keyed by `event_id`: a `Uint8Array` is used as-is, a `string` is hex. **Prefer a `Map`:**
|
|
127
127
|
|
|
128
128
|
```ts
|
|
129
129
|
const result = await verifyEvidenceBundle(bundle, {
|
|
@@ -132,13 +132,13 @@ const result = await verifyEvidenceBundle(bundle, {
|
|
|
132
132
|
result.events[0].contentOk; // true | false | null
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
-
A plain object still works and is fully guarded on the way in. But **an `event_id` is producer-chosen**, and on a plain object the names that live on `Object.prototype` are not free: `content["__proto__"] = bytes` does not store anything
|
|
135
|
+
A plain object still works and is fully guarded on the way in. But **an `event_id` is producer-chosen**, and on a plain object the names that live on `Object.prototype` are not free: `content["__proto__"] = bytes` does not store anything: it replaces the object's prototype, and the bytes you meant to disclose are lost before the verifier ever sees them. If you are building the map from names you did not choose (a directory listing, a JSON key, an id off the wire) use a `Map` (or `Object.create(null)`). It is also what `disclosedContent` hands back, so its output feeds straight back in.
|
|
136
136
|
|
|
137
137
|
In-body `events[].content` (signed) takes precedence over the side input; if both are present for an event and disagree, the event fails.
|
|
138
138
|
|
|
139
139
|
### …and get those raw logs back out (ingest)
|
|
140
140
|
|
|
141
|
-
`events[].content` rides as **lowercase hex
|
|
141
|
+
`events[].content` rides as **lowercase hex**, JSON cannot carry arbitrary bytes. To *materialize* a received bundle's logs (serve them, write pack files, hand them on), use `disclosedContent` rather than decoding that field yourself:
|
|
142
142
|
|
|
143
143
|
```ts
|
|
144
144
|
import { disclosedContent } from "@sanning/proof";
|
|
@@ -146,26 +146,26 @@ import { disclosedContent } from "@sanning/proof";
|
|
|
146
146
|
const result = await disclosedContent(bundle); // verifies, then decodes
|
|
147
147
|
|
|
148
148
|
for (const [eventId, bytes] of result.content) {
|
|
149
|
-
await writeFile(`logs/${eventId}.json`, bytes); // Uint8Array
|
|
149
|
+
await writeFile(`logs/${eventId}.json`, bytes); // Uint8Array: write it as-is
|
|
150
150
|
}
|
|
151
151
|
```
|
|
152
152
|
|
|
153
|
-
**Verify-then-return:** `disclosedContent` runs the full `verifyEvidenceBundle` (same options) and hands back bytes **only** for events whose chain verified *and* whose disclosed bytes hash to the committed `content_hash`. There is no path through it that yields bytes that are not the committed ones
|
|
153
|
+
**Verify-then-return:** `disclosedContent` runs the full `verifyEvidenceBundle` (same options) and hands back bytes **only** for events whose chain verified *and* whose disclosed bytes hash to the committed `content_hash`. There is no path through it that yields bytes that are not the committed ones, and `result.verification` carries the whole `EvidenceBundleResult`, so you verify once and render the verdict from the same call.
|
|
154
154
|
|
|
155
155
|
Every event also appears in `result.events`, in body order, with a status that never lets "nothing was disclosed" pass for "empty":
|
|
156
156
|
|
|
157
157
|
| `status` | `bytes` | Meaning |
|
|
158
158
|
|---|---|---|
|
|
159
|
-
| `verified` | the bytes | Decoded **and** bound to the committed `content_hash
|
|
160
|
-
| `not_disclosed` | `null` | Minimal disclosure
|
|
159
|
+
| `verified` | the bytes | Decoded **and** bound to the committed `content_hash`: safe to write as-is. May be zero-length: an empty payload is real, verifiable content. |
|
|
160
|
+
| `not_disclosed` | `null` | Minimal disclosure: the hash was committed, the bytes were not shared. Nothing to write, and not an error. |
|
|
161
161
|
| `undetermined` | `null` | Bytes were disclosed but there is nothing to bind them to (withheld record, or no committed `content_hash`). Withheld rather than returned. |
|
|
162
162
|
| `failed` | `null` | The event's own proof chain failed, or the disclosure did not bind or did not decode. `errors` says which. |
|
|
163
163
|
|
|
164
|
-
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
|
|
164
|
+
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
165
|
|
|
166
166
|
## Verify a Merkle inclusion proof
|
|
167
167
|
|
|
168
|
-
Agents close daily checkpoints over per-cycle leaves (RFC 9162, §2.1 domain separation
|
|
168
|
+
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:
|
|
169
169
|
|
|
170
170
|
```ts
|
|
171
171
|
import { leafHash, verifyInclusion, hexToBytes } from "@sanning/proof";
|
|
@@ -185,10 +185,10 @@ const ok = await verifyInclusion(
|
|
|
185
185
|
|---|---|
|
|
186
186
|
| `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`. |
|
|
187
187
|
| `verifyEvidenceBundle(bundle, { gateways?, fetchImpl? })` | Verify a signed `sanning.evidence/v1` / `sanning.anchor.trace/v1` bundle: 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. |
|
|
188
|
-
| `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
|
|
189
|
-
| `gradePack(result, account, notes)` + `accountDisclosure(bundle, content)` / `completenessNotes(bundle)` / `renderCause(cause)` | **Pack grading:** what a SURFACE may claim about a pack
|
|
188
|
+
| `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`). |
|
|
189
|
+
| `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. |
|
|
190
190
|
| `verifyAgentProofBundle(bundle, { gateways?, fetchImpl? })` / `isAgentProofSpec(v)` | Verify the agent producer's `sanning.agent.proof/v1` inclusion bundle. |
|
|
191
|
-
| `contentHashes(env)` | The content hash(es) an envelope commits to, by event type
|
|
191
|
+
| `contentHashes(env)` | The content hash(es) an envelope commits to, by event type: the reverse-provenance join keys. |
|
|
192
192
|
| `specVersionSupported(v)` | Fail-closed accept-check: `sanning.agent/v1` and additive minors (`sanning.agent/v1.<n>`). Unknown majors/profiles are rejected. |
|
|
193
193
|
| `jcs(value)` | RFC 8785 canonical JSON bytes. |
|
|
194
194
|
| `sha256Hex(bytes)` / `sha256Bytes(bytes)` | SHA-256 via WebCrypto. |
|
|
@@ -199,19 +199,19 @@ const ok = await verifyInclusion(
|
|
|
199
199
|
|
|
200
200
|
### Signed scope
|
|
201
201
|
|
|
202
|
-
The primary signature covers `JCS(envelope minus signature minus co_signatures)` (
|
|
202
|
+
The primary signature covers `JCS(envelope minus signature minus co_signatures)` (`envelope-spec.md` §2/§7.1, in the `sanning-proof` package). The reserved `co_signatures` carve-out lets countersignatures be added later without invalidating the primary signature.
|
|
203
203
|
|
|
204
204
|
## Why you can trust it (without trusting us)
|
|
205
205
|
|
|
206
|
-
Three **independent implementations** of this kernel exist
|
|
206
|
+
Three **independent implementations** of this kernel exist: this package, the Python [`sanning-proof`](https://pypi.org/project/sanning-proof/) (PyPI), and and the MIT-licensed Go reference. The TypeScript and Python kernels are conformance-gated **byte-for-byte** in CI against one shared corpus, pinned per file by SHA-256 at cut tag `test-vectors-v3.2`, and a cross-kernel agreement gate re-runs the corpus plus adversarial negatives through both, asserting identical verdicts on identical bytes. The Go reference runs as a third leg of that same gate, built at the commit its vendoring pins.
|
|
207
207
|
|
|
208
|
-
That mutual gate is the point: anyone can verify these provenance records with **no Sanning code in the trust path
|
|
208
|
+
That mutual gate is the point: anyone can verify these provenance records with **no Sanning code in the trust path**, write your own verifier against the specs that ship inside the [`sanning-proof`](https://pypi.org/project/sanning-proof/) sdist, and the corpus will tell you if it is conformant.
|
|
209
209
|
|
|
210
210
|
## What this package deliberately does NOT do
|
|
211
211
|
|
|
212
212
|
- **No I/O, ever.** Including in `gradePack`: it takes an account of what was disclosed, it does not go and read a directory. How a pack reached you is yours.
|
|
213
213
|
- **Single-envelope scope.** Chain walking (`previous_hash`), checkpoint reconciliation, and gateway/transport logic are consumer-layer concerns composed above the kernel.
|
|
214
|
-
- **Provenance is history, not endorsement.** A verified envelope proves *these bytes have this signed, anchored history
|
|
214
|
+
- **Provenance is history, not endorsement.** A verified envelope proves *these bytes have this signed, anchored history*, never "safe," "approved," or "currently in production."
|
|
215
215
|
- **Key identity is out-of-band.** The kernel proves the signature matches `env.public_key`; binding that key to a real-world identity is yours to establish.
|
|
216
216
|
|
|
217
217
|
## Requirements
|
|
@@ -220,4 +220,4 @@ ESM-only. Needs WebCrypto (`globalThis.crypto.subtle`): any modern browser, Node
|
|
|
220
220
|
|
|
221
221
|
## License
|
|
222
222
|
|
|
223
|
-
MIT
|
|
223
|
+
MIT, verification must be open.
|
package/dist/cli.js
CHANGED
|
@@ -610,6 +610,31 @@ function reportEvidence(result, path, gateways, io, chains) {
|
|
|
610
610
|
io.out(paint(` - ${e}`, R));
|
|
611
611
|
}
|
|
612
612
|
}
|
|
613
|
+
// Stamps (evidence-bundle.md §5.2 / §5.3). Printed rather than summarised,
|
|
614
|
+
// because for a stamp body the WITNESS is the entire independent part
|
|
615
|
+
// (§5.2.4) — and until now this reporter showed nothing about it at all, so a
|
|
616
|
+
// §5.3 pack printed a rollup line claiming an on-chain scope with no visible
|
|
617
|
+
// on-chain result anywhere above it.
|
|
618
|
+
//
|
|
619
|
+
// The on-chain column distinguishes all four states in the same three
|
|
620
|
+
// characters the checkpoint block uses, plus its absence: `✓` confirmed,
|
|
621
|
+
// `✗` mismatched, `~` asked and unreachable, and NO COLUMN AT ALL when
|
|
622
|
+
// nothing was checked. A reader must never have to infer "not checked" from
|
|
623
|
+
// a symbol that also means "could not be reached".
|
|
624
|
+
if (result.stamps && result.stamps.length > 0) {
|
|
625
|
+
io.out("");
|
|
626
|
+
io.out(paint(" Stamps", DIM));
|
|
627
|
+
for (const st of result.stamps) {
|
|
628
|
+
const witness = st.anchorRef === null
|
|
629
|
+
? paint("~ no witness", Y)
|
|
630
|
+
: `witness ${st.anchorRef.kind}:${shortTx(st.anchorRef.id)}`;
|
|
631
|
+
const onChain = st.onChain === null ? "" : ` on-chain ${mark(st.onChainOk)} (${st.onChain.rollup})`;
|
|
632
|
+
io.out(` ${mark(st.ok)} ${shortTx(st.stampRef)} bytes ${mark(st.stampRefOk && st.bodyOk)}` +
|
|
633
|
+
` ranges ${mark(st.subtreesOk)} ${witness}${onChain}`);
|
|
634
|
+
for (const e of st.errors)
|
|
635
|
+
io.out(paint(` - ${e}`, R));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
613
638
|
if (result.events.length > 0) {
|
|
614
639
|
io.out("");
|
|
615
640
|
io.out(paint(" Events", DIM));
|
package/dist/evidence.d.ts
CHANGED
|
@@ -292,6 +292,35 @@ export interface StampResult {
|
|
|
292
292
|
* carries it.
|
|
293
293
|
*/
|
|
294
294
|
anchorRef: AnchorRef | null;
|
|
295
|
+
/**
|
|
296
|
+
* Per-gateway on-chain outcomes for this stamp's `anchor_ref` (§4.2's shape,
|
|
297
|
+
* `checkpoints[]`'s vocabulary), or `null` when NOTHING WAS CHECKED.
|
|
298
|
+
*
|
|
299
|
+
* ── The four states, and why none of them collapses ──
|
|
300
|
+
*
|
|
301
|
+
* `null` means not checked — no gateways were supplied, or this stamp names
|
|
302
|
+
* no witness to check. It is a statement about the RUN, never about the
|
|
303
|
+
* stamp. The other three are statements about the stamp and live in
|
|
304
|
+
* `onChain.rollup`: `confirm` (a gateway returned the witnessed bytes),
|
|
305
|
+
* `mismatch` (a gateway returned DIFFERENT bytes — a finding, and the only
|
|
306
|
+
* one of the four that fails anything), `unreachable` (we asked and nobody
|
|
307
|
+
* answered — an availability gap, never an accusation).
|
|
308
|
+
*
|
|
309
|
+
* Keeping "not checked" distinct from "unreachable" is the whole point. A
|
|
310
|
+
* surface handed a collapsed boolean cannot tell "no gateway could be
|
|
311
|
+
* reached" from "no gateway was tried", and reported the first when the
|
|
312
|
+
* second was true — a false accusation manufactured by a missing state.
|
|
313
|
+
*/
|
|
314
|
+
onChain: OnChainResult | null;
|
|
315
|
+
/**
|
|
316
|
+
* Derived collapse of `onChain`, mirroring `CheckpointResult.onChainOk` so a
|
|
317
|
+
* consumer reads one field the same way on both: `false` on mismatch, `true`
|
|
318
|
+
* on confirm, `null` on unreachable AND on not-checked.
|
|
319
|
+
*
|
|
320
|
+
* It is deliberately the LOSSY view — `null` is three-quarters of the state
|
|
321
|
+
* space here. Anything that needs to tell those apart reads `onChain`.
|
|
322
|
+
*/
|
|
323
|
+
onChainOk: boolean | null;
|
|
295
324
|
ok: boolean;
|
|
296
325
|
errors: string[];
|
|
297
326
|
}
|
package/dist/evidence.js
CHANGED
|
@@ -194,7 +194,7 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
|
|
|
194
194
|
// wrapper is authentic — and for this body that IS "Sanning signed it", since
|
|
195
195
|
// a stamp carries no second signature of its own inside the bundle.
|
|
196
196
|
if (bodyType === STAMP_TRACE_BODY_TYPE) {
|
|
197
|
-
return verifyStampTrace(b, specVersionOk, assertedStatus, errors);
|
|
197
|
+
return verifyStampTrace(b, specVersionOk, assertedStatus, errors, options);
|
|
198
198
|
}
|
|
199
199
|
// The DL-52 hand-over body (§5.3): stamps AND the customer's events, joined.
|
|
200
200
|
if (bodyType === STAMP_EVIDENCE_BODY_TYPE) {
|
|
@@ -768,18 +768,20 @@ async function verifyStampEntry(raw, seen) {
|
|
|
768
768
|
subtreesOk: false,
|
|
769
769
|
subtrees: [],
|
|
770
770
|
anchorRef: null,
|
|
771
|
+
onChain: null,
|
|
772
|
+
onChainOk: null,
|
|
771
773
|
ok: false,
|
|
772
774
|
errors,
|
|
773
775
|
};
|
|
774
776
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
775
777
|
errors.push("stamp entry is not an object");
|
|
776
|
-
return base;
|
|
778
|
+
return { result: base };
|
|
777
779
|
}
|
|
778
780
|
const e = raw;
|
|
779
781
|
// --- the witnessed bytes, which are the only authority in this body -------
|
|
780
782
|
if (!isLowerHex(e.stamp_bytes)) {
|
|
781
783
|
errors.push("stamp_bytes is missing or not lowercase hex");
|
|
782
|
-
return base;
|
|
784
|
+
return { result: base };
|
|
783
785
|
}
|
|
784
786
|
let bytes;
|
|
785
787
|
try {
|
|
@@ -787,8 +789,11 @@ async function verifyStampEntry(raw, seen) {
|
|
|
787
789
|
}
|
|
788
790
|
catch (err) {
|
|
789
791
|
errors.push(`stamp_bytes is not hex: ${stringifyErr(err)}`);
|
|
790
|
-
return base;
|
|
792
|
+
return { result: base };
|
|
791
793
|
}
|
|
794
|
+
// Gated by `isLowerHex` above, so this is already the exact lowercase hex of
|
|
795
|
+
// the exact witnessed bytes — §5.2.2's "one authority in this body".
|
|
796
|
+
const witnessedHex = e.stamp_bytes;
|
|
792
797
|
// --- the derived identity -------------------------------------------------
|
|
793
798
|
const derived = await sha256Hex(bytes);
|
|
794
799
|
if (typeof e.stamp_ref !== "string" || e.stamp_ref === "") {
|
|
@@ -818,7 +823,7 @@ async function verifyStampEntry(raw, seen) {
|
|
|
818
823
|
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
819
824
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
820
825
|
errors.push("stamp body is not a JSON object");
|
|
821
|
-
return base;
|
|
826
|
+
return { result: base, witnessedHex };
|
|
822
827
|
}
|
|
823
828
|
body = parsed;
|
|
824
829
|
// Re-canonicalize and compare. The plane replays these bytes verbatim on
|
|
@@ -827,12 +832,12 @@ async function verifyStampEntry(raw, seen) {
|
|
|
827
832
|
// of the stamp disagree with the one that was witnessed.
|
|
828
833
|
if (jcs(body) !== new TextDecoder().decode(bytes)) {
|
|
829
834
|
errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
|
|
830
|
-
return base;
|
|
835
|
+
return { result: base, witnessedHex };
|
|
831
836
|
}
|
|
832
837
|
}
|
|
833
838
|
catch (err) {
|
|
834
839
|
errors.push(`stamp body is not JSON: ${stringifyErr(err)}`);
|
|
835
|
-
return base;
|
|
840
|
+
return { result: base, witnessedHex };
|
|
836
841
|
}
|
|
837
842
|
if (body.stamp_version !== STAMP_BODY_VERSION) {
|
|
838
843
|
// A `sanning.stamp.trace/v1` body whose inner version is something else is
|
|
@@ -841,7 +846,7 @@ async function verifyStampEntry(raw, seen) {
|
|
|
841
846
|
// how a reader half-parses a shape it does not know.
|
|
842
847
|
errors.push(`unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
|
|
843
848
|
`${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`);
|
|
844
|
-
return base;
|
|
849
|
+
return { result: base, witnessedHex };
|
|
845
850
|
}
|
|
846
851
|
base.stampVersion = body.stamp_version;
|
|
847
852
|
base.bodyOk = true;
|
|
@@ -872,7 +877,7 @@ async function verifyStampEntry(raw, seen) {
|
|
|
872
877
|
base.anchorRef = parseAnchorRef(e.anchor_ref, errors);
|
|
873
878
|
base.ok =
|
|
874
879
|
base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
|
|
875
|
-
return base;
|
|
880
|
+
return { result: base, witnessedHex };
|
|
876
881
|
}
|
|
877
882
|
/**
|
|
878
883
|
* The range arithmetic (§5.2), and the reason it is a hard check.
|
|
@@ -948,16 +953,27 @@ function checkSubtrees(body, out, errors) {
|
|
|
948
953
|
}
|
|
949
954
|
return true;
|
|
950
955
|
}
|
|
951
|
-
async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
|
|
956
|
+
async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, options) {
|
|
952
957
|
const body = b.body;
|
|
953
958
|
if (!Array.isArray(body?.stamps)) {
|
|
954
959
|
return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true);
|
|
955
960
|
}
|
|
956
961
|
const seen = new Set();
|
|
957
962
|
const stamps = [];
|
|
963
|
+
const witnessed = [];
|
|
958
964
|
for (const entry of body.stamps) {
|
|
959
|
-
|
|
965
|
+
const outcome = await verifyStampEntry(entry, seen);
|
|
966
|
+
stamps.push(outcome.result);
|
|
967
|
+
witnessed.push(outcome.witnessedHex);
|
|
960
968
|
}
|
|
969
|
+
// --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
|
|
970
|
+
//
|
|
971
|
+
// The SAME stamps[] array §5.3 carries, so it gets the same capability. The
|
|
972
|
+
// alternative — a stamp that can be re-fetched inside one body and not the
|
|
973
|
+
// other — asks every consumer to remember which wrapper it is holding before
|
|
974
|
+
// it can trust `onChain: null`, and that is exactly the kind of per-body
|
|
975
|
+
// exception a reader eventually gets wrong.
|
|
976
|
+
const { onChainChecked, gatewayUnavailable } = await refetchStamps(stamps, witnessed, options, errors);
|
|
961
977
|
// --- Rollup ----------------------------------------------------------------
|
|
962
978
|
const anyHardFailure = stamps.some((s) => !s.ok);
|
|
963
979
|
const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
|
|
@@ -972,15 +988,23 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
|
|
|
972
988
|
status = "partial";
|
|
973
989
|
errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
|
|
974
990
|
}
|
|
975
|
-
else if (anyUnwitnessed) {
|
|
976
|
-
//
|
|
977
|
-
//
|
|
978
|
-
// our coverage and never an accusation about their evidence. It is also not
|
|
979
|
-
// `verified`, because for a stamp the witness is the entire independent
|
|
980
|
-
// part — unlike an anchor-trace checkpoint, which still carries a producer
|
|
981
|
-
// signature and an inclusion proof that stand on their own offline.
|
|
991
|
+
else if (gatewayUnavailable || anyUnwitnessed) {
|
|
992
|
+
// Both hold the bundle at `partial`, and both get said — see §5.3's rollup
|
|
993
|
+
// for why one note is not enough when there are two different gaps.
|
|
982
994
|
status = "partial";
|
|
983
|
-
|
|
995
|
+
if (gatewayUnavailable) {
|
|
996
|
+
errors.push("one or more stamps could not be re-fetched from the supplied gateways");
|
|
997
|
+
}
|
|
998
|
+
if (anyUnwitnessed) {
|
|
999
|
+
// The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
|
|
1000
|
+
// sealed and unwitnessed: `retained-by-sanning`, which is a statement
|
|
1001
|
+
// about our coverage and never an accusation about their evidence. It is
|
|
1002
|
+
// also not `verified`, because for a stamp the witness is the entire
|
|
1003
|
+
// independent part — unlike an anchor-trace checkpoint, which still
|
|
1004
|
+
// carries a producer signature and an inclusion proof that stand on
|
|
1005
|
+
// their own offline.
|
|
1006
|
+
errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
|
|
1007
|
+
}
|
|
984
1008
|
}
|
|
985
1009
|
else {
|
|
986
1010
|
status = "verified";
|
|
@@ -991,7 +1015,7 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors) {
|
|
|
991
1015
|
bodyHashOk: true,
|
|
992
1016
|
signatureOk: true,
|
|
993
1017
|
bodyType: STAMP_TRACE_BODY_TYPE,
|
|
994
|
-
onChainChecked
|
|
1018
|
+
onChainChecked,
|
|
995
1019
|
checkpoints: [],
|
|
996
1020
|
events: [],
|
|
997
1021
|
assertedStatus,
|
|
@@ -1022,8 +1046,12 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1022
1046
|
// --- The stamp half: §5.2's rules, §5.2's code ----------------------------
|
|
1023
1047
|
const seen = new Set();
|
|
1024
1048
|
const stamps = [];
|
|
1025
|
-
|
|
1026
|
-
|
|
1049
|
+
const witnessed = [];
|
|
1050
|
+
for (const entry of body.stamps) {
|
|
1051
|
+
const outcome = await verifyStampEntry(entry, seen);
|
|
1052
|
+
stamps.push(outcome.result);
|
|
1053
|
+
witnessed.push(outcome.witnessedHex);
|
|
1054
|
+
}
|
|
1027
1055
|
// The join table. ONLY a stamp whose derived ref and witnessed bytes both
|
|
1028
1056
|
// checked out is joinable: a root read out of bytes that failed their own
|
|
1029
1057
|
// verification is not a root anyone should walk a proof to, and admitting one
|
|
@@ -1153,6 +1181,8 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1153
1181
|
errors: evErrors,
|
|
1154
1182
|
});
|
|
1155
1183
|
}
|
|
1184
|
+
// --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
|
|
1185
|
+
const { onChainChecked, gatewayUnavailable } = await refetchStamps(stamps, witnessed, options, errors);
|
|
1156
1186
|
// --- Rollup ---------------------------------------------------------------
|
|
1157
1187
|
// Rule 4: a declared event MUST be checked. `events[]` present and unverified
|
|
1158
1188
|
// is the false green this body exists to prevent, so an event that did not
|
|
@@ -1167,11 +1197,21 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1167
1197
|
status = "partial";
|
|
1168
1198
|
errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
|
|
1169
1199
|
}
|
|
1170
|
-
else if (anyUnwitnessed) {
|
|
1171
|
-
//
|
|
1172
|
-
//
|
|
1200
|
+
else if (gatewayUnavailable || anyUnwitnessed) {
|
|
1201
|
+
// Both hold the bundle at `partial`, and BOTH get said. A stamp nobody
|
|
1202
|
+
// witnessed and a witness we could not reach are different gaps with
|
|
1203
|
+
// different fixes — one waits for the interval, the other for the network —
|
|
1204
|
+
// and an auditor told only about the second would read the first as fine.
|
|
1205
|
+
// The order is fixed so the two kernels emit the same list.
|
|
1173
1206
|
status = "partial";
|
|
1174
|
-
|
|
1207
|
+
if (gatewayUnavailable) {
|
|
1208
|
+
errors.push("one or more stamps could not be re-fetched from the supplied gateways");
|
|
1209
|
+
}
|
|
1210
|
+
if (anyUnwitnessed) {
|
|
1211
|
+
// §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
|
|
1212
|
+
// the independent part, so the events resting on it inherit `partial`.
|
|
1213
|
+
errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
|
|
1214
|
+
}
|
|
1175
1215
|
}
|
|
1176
1216
|
else {
|
|
1177
1217
|
status = "verified";
|
|
@@ -1182,7 +1222,7 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1182
1222
|
bodyHashOk: true,
|
|
1183
1223
|
signatureOk: true,
|
|
1184
1224
|
bodyType: STAMP_EVIDENCE_BODY_TYPE,
|
|
1185
|
-
onChainChecked
|
|
1225
|
+
onChainChecked,
|
|
1186
1226
|
checkpoints: [],
|
|
1187
1227
|
events: eventResults,
|
|
1188
1228
|
assertedStatus,
|
|
@@ -1190,6 +1230,78 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1190
1230
|
errors,
|
|
1191
1231
|
};
|
|
1192
1232
|
}
|
|
1233
|
+
/**
|
|
1234
|
+
* The optional on-chain re-fetch over `stamps[]`, shared by §5.2 and §5.3.
|
|
1235
|
+
*
|
|
1236
|
+
* ── Why the results land on `stamps[]` and `checkpoints` stays empty ──
|
|
1237
|
+
*
|
|
1238
|
+
* A stamp body carries no checkpoints. Synthesising `CheckpointResult`s to
|
|
1239
|
+
* carry these outcomes would make a stamp indistinguishable from a checkpoint
|
|
1240
|
+
* in every consumer that reads `checkpoints[]` — the exact conflation §5.2.1
|
|
1241
|
+
* and §5.3.1 refused twice, most sharply in "a Sanning attestation
|
|
1242
|
+
* indistinguishable on the wire from a producer's". They are different
|
|
1243
|
+
* artifacts with different signers and different meanings when absent, and a
|
|
1244
|
+
* reader must never have to guess which one it is holding. So the stamp
|
|
1245
|
+
* results go on the stamps, and `checkpoints: []` keeps saying the true thing:
|
|
1246
|
+
* this body has none.
|
|
1247
|
+
*
|
|
1248
|
+
* ── Why an unwitnessed stamp gets `onChain: null` and NOT all-unreachable ──
|
|
1249
|
+
*
|
|
1250
|
+
* The anchor-trace path synthesises an all-`unreachable` result for a
|
|
1251
|
+
* checkpoint with no `anchor_ref`. That is not copied here, for two reasons.
|
|
1252
|
+
*
|
|
1253
|
+
* It would be untrue: nothing was asked of any gateway, so reporting that
|
|
1254
|
+
* every gateway failed to answer invents a network event. "Not checked" and
|
|
1255
|
+
* "we asked and nobody answered" are different facts, and collapsing them is
|
|
1256
|
+
* precisely the defect that had a surface reporting an unreachable gateway
|
|
1257
|
+
* when it had contacted none.
|
|
1258
|
+
*
|
|
1259
|
+
* And it would be load-bearing in the wrong direction. Over there the
|
|
1260
|
+
* synthesis exists to drive `gatewayUnavailable`, which is what downgrades that
|
|
1261
|
+
* run to `partial`. Here §5.2.4 already downgrades an unwitnessed stamp to
|
|
1262
|
+
* `partial` on its own, with a better error. Copying the synthesis would add
|
|
1263
|
+
* nothing to the status and would silently move the CLI exit code from 0 to 3
|
|
1264
|
+
* for an unwitnessed stamp — changing what an auditor's script does, based on
|
|
1265
|
+
* nothing but whether the caller happened to pass a gateway list.
|
|
1266
|
+
*/
|
|
1267
|
+
async function refetchStamps(stamps, witnessed, options, errors) {
|
|
1268
|
+
const gateways = options.gateways ?? [];
|
|
1269
|
+
const onChainChecked = gateways.length > 0;
|
|
1270
|
+
if (!onChainChecked)
|
|
1271
|
+
return { onChainChecked, gatewayUnavailable: false };
|
|
1272
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1273
|
+
if (typeof fetchImpl !== "function") {
|
|
1274
|
+
errors.push("on-chain re-fetch requested but no fetch implementation is available");
|
|
1275
|
+
return { onChainChecked, gatewayUnavailable: true };
|
|
1276
|
+
}
|
|
1277
|
+
let gatewayUnavailable = false;
|
|
1278
|
+
for (let i = 0; i < stamps.length; i++) {
|
|
1279
|
+
const st = stamps[i];
|
|
1280
|
+
const witnessedHex = witnessed[i];
|
|
1281
|
+
// No witness named, or bytes we could not read: nothing to re-fetch, and
|
|
1282
|
+
// nothing a gateway could confirm or deny. Left as `onChain: null`.
|
|
1283
|
+
if (st.anchorRef === null || witnessedHex === undefined)
|
|
1284
|
+
continue;
|
|
1285
|
+
const outcome = await refetchStamp(witnessedHex, st.anchorRef, gateways, fetchImpl);
|
|
1286
|
+
st.onChain = outcome.onChain;
|
|
1287
|
+
st.onChainOk = outcome.onChain.onChainOk;
|
|
1288
|
+
if (outcome.unavailable)
|
|
1289
|
+
gatewayUnavailable = true;
|
|
1290
|
+
if (outcome.error)
|
|
1291
|
+
st.errors.push(outcome.error);
|
|
1292
|
+
// Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
|
|
1293
|
+
// (false) fails the stamp — the chain does not hold what the bundle says
|
|
1294
|
+
// was witnessed. UNREACHABLE (null) leaves an offline-sound stamp ok and
|
|
1295
|
+
// downgrades the run to `partial` via gatewayUnavailable. A confirm (true)
|
|
1296
|
+
// changes nothing that was not already true offline.
|
|
1297
|
+
//
|
|
1298
|
+
// `st.ok` is kept and narrowed rather than recomputed: its offline form
|
|
1299
|
+
// includes `errors.length === 0`, and re-evaluating that AFTER pushing an
|
|
1300
|
+
// unreachable note would fail a stamp for a gateway being down.
|
|
1301
|
+
st.ok = st.ok && st.onChainOk !== false;
|
|
1302
|
+
}
|
|
1303
|
+
return { onChainChecked, gatewayUnavailable };
|
|
1304
|
+
}
|
|
1193
1305
|
// How many `sanning.evidence.export/v1` wrappers deep the source bundle may be
|
|
1194
1306
|
// nested before this path gives up. verifyEvidenceBundle's canonicalization
|
|
1195
1307
|
// depth bound already rejects a pathological bundle, but the unwrap must be
|
|
@@ -1802,6 +1914,10 @@ function summarizeExport(status, source, attestations) {
|
|
|
1802
1914
|
async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
|
|
1803
1915
|
let expectedHex;
|
|
1804
1916
|
try {
|
|
1917
|
+
// A checkpoint's witnessed bytes are its envelope RE-CANONICALIZED. The
|
|
1918
|
+
// bundle carries the envelope as a JSON object, not as bytes, so what was
|
|
1919
|
+
// uploaded has to be recomputed from it. See `refetchStamp` for the case
|
|
1920
|
+
// where this step is not merely unnecessary but WRONG.
|
|
1805
1921
|
expectedHex = toHex(utf8(jcs(cp.envelope)));
|
|
1806
1922
|
}
|
|
1807
1923
|
catch {
|
|
@@ -1809,6 +1925,41 @@ async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
|
|
|
1809
1925
|
// (we can never confirm bytes we cannot compute).
|
|
1810
1926
|
expectedHex = undefined;
|
|
1811
1927
|
}
|
|
1928
|
+
return refetchAnchored("checkpoint", expectedHex, anchorRef, gateways, fetchImpl);
|
|
1929
|
+
}
|
|
1930
|
+
// Re-fetch a STAMP's witnessed bytes (evidence-bundle.md §5.2.2 / §5.3.2).
|
|
1931
|
+
//
|
|
1932
|
+
// ── The one step this path must NOT copy from the checkpoint path ──
|
|
1933
|
+
//
|
|
1934
|
+
// `refetchCheckpoint` recomputes `JCS(envelope)` because a checkpoint's
|
|
1935
|
+
// witnessed bytes are not in the bundle — only the object they were derived
|
|
1936
|
+
// from is. A stamp is the opposite case: §5.2.2 defines `stamp_bytes` as
|
|
1937
|
+
// "lowercase hex of the exact JCS bytes that were handed to the witness", and
|
|
1938
|
+
// §5.3.2 repeats it — *"the EXACT bytes that were witnessed, verbatim"*. They
|
|
1939
|
+
// are already the bytes, already hex, already lowercase (`isLowerHex` gated
|
|
1940
|
+
// them), and `stamp_ref` is their SHA-256.
|
|
1941
|
+
//
|
|
1942
|
+
// So the expected hex IS `stamp_bytes`, and re-canonicalizing would be a
|
|
1943
|
+
// defect, not a redundancy: it would compare the chain against bytes NOBODY
|
|
1944
|
+
// SIGNED. On a canonical body the two agree and the bug hides; on a body that
|
|
1945
|
+
// is not canonical — the exact case §5.2.2 makes the kernel check for, because
|
|
1946
|
+
// the plane replays these bytes as text precisely so a normalizing serializer
|
|
1947
|
+
// cannot alter them — a re-canonicalizing verifier reports `mismatch` against
|
|
1948
|
+
// a chain that faithfully holds what it was given. A false accusation, from
|
|
1949
|
+
// the verifier, about untampered evidence.
|
|
1950
|
+
//
|
|
1951
|
+
// The bytes handed here are the SAME value `verifyStampEntry` hashed to derive
|
|
1952
|
+
// `stamp_ref`, threaded through rather than re-read from the body, so the bytes
|
|
1953
|
+
// graded and the bytes compared cannot drift apart.
|
|
1954
|
+
async function refetchStamp(witnessedHex, anchorRef, gateways, fetchImpl) {
|
|
1955
|
+
return refetchAnchored("stamp", witnessedHex, anchorRef, gateways, fetchImpl);
|
|
1956
|
+
}
|
|
1957
|
+
// The part both paths share: probe every gateway, roll the outcomes up under
|
|
1958
|
+
// the ONE rule (`rollupOnChain`), and phrase the finding. Extracted rather than
|
|
1959
|
+
// copied — a second rollup rule is a second place for "mismatch beats confirm
|
|
1960
|
+
// beats unreachable" to drift, and the drifted copy is the one nobody reads.
|
|
1961
|
+
// The subject only ever selects wording; it never changes a verdict.
|
|
1962
|
+
async function refetchAnchored(subject, expectedHex, anchorRef, gateways, fetchImpl) {
|
|
1812
1963
|
const perGateway = [];
|
|
1813
1964
|
for (const gw of gateways) {
|
|
1814
1965
|
perGateway.push({
|
|
@@ -1819,10 +1970,13 @@ async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
|
|
|
1819
1970
|
const onChain = rollupOnChain(perGateway);
|
|
1820
1971
|
let error;
|
|
1821
1972
|
if (onChain.rollup === "mismatch") {
|
|
1822
|
-
error =
|
|
1973
|
+
error =
|
|
1974
|
+
subject === "checkpoint"
|
|
1975
|
+
? `on-chain bytes at ${anchorRef.id} do not match the bundle's checkpoint envelope`
|
|
1976
|
+
: `on-chain bytes at ${anchorRef.id} do not match the stamp's witnessed bytes`;
|
|
1823
1977
|
}
|
|
1824
1978
|
else if (onChain.rollup === "unreachable") {
|
|
1825
|
-
error =
|
|
1979
|
+
error = `${subject} ${anchorRef.id} unreachable on all gateways`;
|
|
1826
1980
|
}
|
|
1827
1981
|
return { onChain, unavailable: onChain.rollup === "unreachable", error };
|
|
1828
1982
|
}
|