@sanning/proof 0.8.1 → 0.8.3
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/evidence.js +23 -2
- package/dist/verifier.js +16 -1
- package/package.json +3 -7
- package/src/evidence.ts +23 -2
- package/src/verifier.ts +16 -1
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.3`, 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/evidence.js
CHANGED
|
@@ -393,9 +393,20 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
393
393
|
}
|
|
394
394
|
const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
|
|
395
395
|
cpErrors.push(...keyed.errors);
|
|
396
|
+
// A duplicate key is a HARD failure, not a note. `byKey` below keeps the
|
|
397
|
+
// FIRST occurrence, so a second checkpoint claiming the same key is
|
|
398
|
+
// verified against the first one's envelope and its own stated identity is
|
|
399
|
+
// never independently checked. Recording the finding and leaving `ok` true
|
|
400
|
+
// let the bundle roll up to `verified` while carrying a checkpoint nothing
|
|
401
|
+
// had verified: a false pass, which is what `evidence-bundle.md` §3
|
|
402
|
+
// principle 3 exists to prevent (the displayed verdict must be the
|
|
403
|
+
// recomputed one, and here the recomputation quietly dropped a finding it
|
|
404
|
+
// had made).
|
|
405
|
+
let duplicateKey = false;
|
|
396
406
|
if (keyed.key !== null) {
|
|
397
407
|
if (byKey.has(keyed.key)) {
|
|
398
408
|
cpErrors.push(`duplicate checkpoint ${isV1 ? "tx_id" : "checkpoint_ref"} ${keyed.key}`);
|
|
409
|
+
duplicateKey = true;
|
|
399
410
|
}
|
|
400
411
|
else {
|
|
401
412
|
byKey.set(keyed.key, cp);
|
|
@@ -448,7 +459,7 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
448
459
|
// standing rule is that malformed is a failure while absent is
|
|
449
460
|
// undetermined (the same rule disclosed `content` follows). Without
|
|
450
461
|
// this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
|
|
451
|
-
ok: envelopeOk && merkleRootOk && keyed.errors.length === 0,
|
|
462
|
+
ok: envelopeOk && merkleRootOk && keyed.errors.length === 0 && !duplicateKey,
|
|
452
463
|
errors: cpErrors,
|
|
453
464
|
});
|
|
454
465
|
}
|
|
@@ -590,7 +601,17 @@ async function verifyAnchorTrace(b, bodyType, specVersionOk, assertedStatus, err
|
|
|
590
601
|
// gateway (null) leaves the offline-sound checkpoint ok; the run
|
|
591
602
|
// downgrades to "partial" (exit 3) via gatewayUnavailable, not a hard
|
|
592
603
|
// failure. A match (true) keeps it ok.
|
|
593
|
-
|
|
604
|
+
// AND into the verdict already computed, never rebuild it from a
|
|
605
|
+
// hand-listed set of dimensions. Rebuilding silently dropped every
|
|
606
|
+
// term not in that list: the duplicate-key gate (proof#28) and the
|
|
607
|
+
// `keyed.errors` term with it, so supplying a gateway turned a hard
|
|
608
|
+
// reject back into `verified` and an UNREACHABLE gateway turned it
|
|
609
|
+
// into `partial` (exit 3), which reads as a network problem an
|
|
610
|
+
// operator retries. Neither is conditioned on a gateway by the spec:
|
|
611
|
+
// `evidence-bundle.md` says two checkpoints carrying one key is a
|
|
612
|
+
// hard reject, full stop. Written this way, a term added at
|
|
613
|
+
// construction survives here by construction.
|
|
614
|
+
cpr.ok = cpr.ok && cpr.onChainOk !== false;
|
|
594
615
|
}
|
|
595
616
|
}
|
|
596
617
|
}
|
package/dist/verifier.js
CHANGED
|
@@ -236,7 +236,22 @@ export async function verifyEnvelope(env, optionsOrContentHash) {
|
|
|
236
236
|
return {
|
|
237
237
|
ok: false,
|
|
238
238
|
specVersionOk: false,
|
|
239
|
-
|
|
239
|
+
// `null`, not `false` (proof#62). This field carries "we cannot tell"
|
|
240
|
+
// against "it is wrong", and that distinction is what the whole
|
|
241
|
+
// minimum-disclosure posture rests on: a withheld record is `null` BY
|
|
242
|
+
// DESIGN and must never read as a failure (`evidence-bundle.md` §3.1,
|
|
243
|
+
// §6.2). There is no envelope here, so there is nothing to bind and
|
|
244
|
+
// nothing was determined. `false` asserted a binding had been checked
|
|
245
|
+
// and had failed, which is a claim this branch cannot support.
|
|
246
|
+
//
|
|
247
|
+
// The Python kernel already answered `None`, so the two disagreed on
|
|
248
|
+
// the dimension `verify-page` renders: an auditor on one CLI saw a
|
|
249
|
+
// dimension that could not be determined, and on the other one that
|
|
250
|
+
// failed, for the same bytes. `ok` is `false` either way, so no rollup
|
|
251
|
+
// verdict changes; what changes is what we tell the reader.
|
|
252
|
+
//
|
|
253
|
+
// `contentHashOk` on the next line already follows this rule.
|
|
254
|
+
payloadHashOk: null,
|
|
240
255
|
signatureOk: false,
|
|
241
256
|
contentHashOk: expectedContentHash === undefined ? null : false,
|
|
242
257
|
contentRole: null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanning/proof",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "TypeScript kernel of the Sanning verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes. Full-family verifier (inline + external commitment); accepts sanning.agent/v1, sanning.mlflow/v1, sanning.events/v1. Conformance-gated byte-for-byte against the test-vectors corpus.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,11 +38,6 @@
|
|
|
38
38
|
"typescript": "^5.4.0",
|
|
39
39
|
"vitest": "^2.0.0"
|
|
40
40
|
},
|
|
41
|
-
"repository": {
|
|
42
|
-
"type": "git",
|
|
43
|
-
"url": "git+https://github.com/sanning-io/proof.git",
|
|
44
|
-
"directory": "ts"
|
|
45
|
-
},
|
|
46
41
|
"publishConfig": {
|
|
47
42
|
"access": "public"
|
|
48
43
|
},
|
|
@@ -56,5 +51,6 @@
|
|
|
56
51
|
"ed25519",
|
|
57
52
|
"merkle",
|
|
58
53
|
"rfc9162"
|
|
59
|
-
]
|
|
54
|
+
],
|
|
55
|
+
"homepage": "https://docs.sanning.io"
|
|
60
56
|
}
|
package/src/evidence.ts
CHANGED
|
@@ -1007,9 +1007,20 @@ async function verifyAnchorTrace(
|
|
|
1007
1007
|
|
|
1008
1008
|
const keyed = await resolveCheckpointKey(cp, recordBytes, isV1);
|
|
1009
1009
|
cpErrors.push(...keyed.errors);
|
|
1010
|
+
// A duplicate key is a HARD failure, not a note. `byKey` below keeps the
|
|
1011
|
+
// FIRST occurrence, so a second checkpoint claiming the same key is
|
|
1012
|
+
// verified against the first one's envelope and its own stated identity is
|
|
1013
|
+
// never independently checked. Recording the finding and leaving `ok` true
|
|
1014
|
+
// let the bundle roll up to `verified` while carrying a checkpoint nothing
|
|
1015
|
+
// had verified: a false pass, which is what `evidence-bundle.md` §3
|
|
1016
|
+
// principle 3 exists to prevent (the displayed verdict must be the
|
|
1017
|
+
// recomputed one, and here the recomputation quietly dropped a finding it
|
|
1018
|
+
// had made).
|
|
1019
|
+
let duplicateKey = false;
|
|
1010
1020
|
if (keyed.key !== null) {
|
|
1011
1021
|
if (byKey.has(keyed.key)) {
|
|
1012
1022
|
cpErrors.push(`duplicate checkpoint ${isV1 ? "tx_id" : "checkpoint_ref"} ${keyed.key}`);
|
|
1023
|
+
duplicateKey = true;
|
|
1013
1024
|
} else {
|
|
1014
1025
|
byKey.set(keyed.key, cp);
|
|
1015
1026
|
anchorByKey.set(keyed.key, keyed.anchorRef);
|
|
@@ -1066,7 +1077,7 @@ async function verifyAnchorTrace(
|
|
|
1066
1077
|
// standing rule is that malformed is a failure while absent is
|
|
1067
1078
|
// undetermined (the same rule disclosed `content` follows). Without
|
|
1068
1079
|
// this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
|
|
1069
|
-
ok: envelopeOk && merkleRootOk && keyed.errors.length === 0,
|
|
1080
|
+
ok: envelopeOk && merkleRootOk && keyed.errors.length === 0 && !duplicateKey,
|
|
1070
1081
|
errors: cpErrors,
|
|
1071
1082
|
});
|
|
1072
1083
|
}
|
|
@@ -1217,7 +1228,17 @@ async function verifyAnchorTrace(
|
|
|
1217
1228
|
// gateway (null) leaves the offline-sound checkpoint ok; the run
|
|
1218
1229
|
// downgrades to "partial" (exit 3) via gatewayUnavailable, not a hard
|
|
1219
1230
|
// failure. A match (true) keeps it ok.
|
|
1220
|
-
|
|
1231
|
+
// AND into the verdict already computed, never rebuild it from a
|
|
1232
|
+
// hand-listed set of dimensions. Rebuilding silently dropped every
|
|
1233
|
+
// term not in that list: the duplicate-key gate (proof#28) and the
|
|
1234
|
+
// `keyed.errors` term with it, so supplying a gateway turned a hard
|
|
1235
|
+
// reject back into `verified` and an UNREACHABLE gateway turned it
|
|
1236
|
+
// into `partial` (exit 3), which reads as a network problem an
|
|
1237
|
+
// operator retries. Neither is conditioned on a gateway by the spec:
|
|
1238
|
+
// `evidence-bundle.md` says two checkpoints carrying one key is a
|
|
1239
|
+
// hard reject, full stop. Written this way, a term added at
|
|
1240
|
+
// construction survives here by construction.
|
|
1241
|
+
cpr.ok = cpr.ok && cpr.onChainOk !== false;
|
|
1221
1242
|
}
|
|
1222
1243
|
}
|
|
1223
1244
|
}
|
package/src/verifier.ts
CHANGED
|
@@ -252,7 +252,22 @@ export async function verifyEnvelope(
|
|
|
252
252
|
return {
|
|
253
253
|
ok: false,
|
|
254
254
|
specVersionOk: false,
|
|
255
|
-
|
|
255
|
+
// `null`, not `false` (proof#62). This field carries "we cannot tell"
|
|
256
|
+
// against "it is wrong", and that distinction is what the whole
|
|
257
|
+
// minimum-disclosure posture rests on: a withheld record is `null` BY
|
|
258
|
+
// DESIGN and must never read as a failure (`evidence-bundle.md` §3.1,
|
|
259
|
+
// §6.2). There is no envelope here, so there is nothing to bind and
|
|
260
|
+
// nothing was determined. `false` asserted a binding had been checked
|
|
261
|
+
// and had failed, which is a claim this branch cannot support.
|
|
262
|
+
//
|
|
263
|
+
// The Python kernel already answered `None`, so the two disagreed on
|
|
264
|
+
// the dimension `verify-page` renders: an auditor on one CLI saw a
|
|
265
|
+
// dimension that could not be determined, and on the other one that
|
|
266
|
+
// failed, for the same bytes. `ok` is `false` either way, so no rollup
|
|
267
|
+
// verdict changes; what changes is what we tell the reader.
|
|
268
|
+
//
|
|
269
|
+
// `contentHashOk` on the next line already follows this rule.
|
|
270
|
+
payloadHashOk: null,
|
|
256
271
|
signatureOk: false,
|
|
257
272
|
contentHashOk: expectedContentHash === undefined ? null : false,
|
|
258
273
|
contentRole: null,
|