@sanning/proof 0.19.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -29
- package/dist/agent-record.d.ts +62 -0
- package/dist/agent-record.js +527 -0
- package/dist/agent-schemas.generated.d.ts +15 -0
- package/dist/agent-schemas.generated.js +5759 -0
- package/dist/case-file.d.ts +32 -89
- package/dist/case-file.js +41 -93
- package/dist/case.d.ts +339 -822
- package/dist/case.js +667 -1160
- package/dist/chain.d.ts +19 -47
- package/dist/chain.js +78 -205
- package/dist/claims.d.ts +211 -0
- package/dist/claims.js +1081 -0
- package/dist/cli.d.ts +45 -68
- package/dist/cli.js +618 -1022
- package/dist/compose.js +23 -65
- package/dist/crypto.d.ts +7 -8
- package/dist/crypto.js +60 -147
- package/dist/declaration.d.ts +68 -194
- package/dist/declaration.js +175 -518
- package/dist/environment.d.ts +9 -30
- package/dist/environment.js +17 -66
- package/dist/evidence.d.ts +119 -160
- package/dist/evidence.js +744 -1103
- package/dist/exit-codes.d.ts +11 -27
- package/dist/exit-codes.js +24 -59
- package/dist/file-match.d.ts +33 -79
- package/dist/file-match.js +33 -69
- package/dist/grading.d.ts +98 -135
- package/dist/grading.js +224 -380
- package/dist/handoff.d.ts +62 -160
- package/dist/handoff.js +153 -393
- package/dist/index.d.ts +14 -10
- package/dist/index.js +60 -167
- package/dist/json-schema.d.ts +29 -0
- package/dist/json-schema.js +298 -0
- package/dist/merkle.js +16 -28
- package/dist/message-parts.d.ts +56 -212
- package/dist/message-parts.js +142 -502
- package/dist/side-content.d.ts +11 -22
- package/dist/side-content.js +22 -65
- package/dist/types.d.ts +17 -0
- package/dist/types.js +19 -5
- package/dist/verifier.d.ts +40 -114
- package/dist/verifier.js +196 -535
- package/dist/witness-cache.d.ts +24 -45
- package/dist/witness-cache.js +38 -81
- package/dist/witness-lookup.d.ts +36 -57
- package/dist/witness-lookup.js +89 -139
- package/dist/zip-reader.d.ts +18 -33
- package/dist/zip-reader.js +19 -34
- package/package.json +11 -3
- package/src/agent-record.ts +608 -0
- package/src/agent-schemas.generated.ts +5764 -0
- package/src/case-file.ts +45 -109
- package/src/case.ts +814 -1328
- package/src/chain.ts +85 -224
- package/src/claims.ts +1253 -0
- package/src/cli.ts +714 -1176
- package/src/compose.ts +33 -79
- package/src/crypto.ts +60 -147
- package/src/declaration.ts +219 -656
- package/src/environment.ts +17 -65
- package/src/evidence.ts +958 -1381
- package/src/exit-codes.ts +24 -63
- package/src/file-match.ts +40 -89
- package/src/grading.ts +287 -421
- package/src/handoff.ts +175 -427
- package/src/index.ts +131 -177
- package/src/json-schema.ts +295 -0
- package/src/merkle.ts +16 -28
- package/src/message-parts.ts +155 -533
- package/src/side-content.ts +25 -68
- package/src/types.ts +55 -36
- package/src/verifier.ts +202 -526
- package/src/witness-cache.ts +45 -94
- package/src/witness-lookup.ts +107 -177
- package/src/zip-reader.ts +20 -38
- package/vectors/README.md +52 -34
- package/vectors/agent-record.json +2175 -0
- package/vectors/case-file-index.json +1 -1
- package/vectors/case-split.json +2 -2
- package/vectors/chain-reading.json +3 -3
- package/vectors/file-match.json +285 -148
- package/vectors/jcs-reader.json +6 -6
- package/vectors/pack-grading.json +102 -25
- package/vectors/sandbox-evidence.json +573 -581
- package/vectors/stamp-on-chain.json +18 -18
- package/vectors/witness-cache.json +49 -49
- package/dist/agent-proof.d.ts +0 -28
- package/dist/agent-proof.js +0 -226
- package/dist/run-adapters.d.ts +0 -10
- package/dist/run-adapters.js +0 -35
- package/dist/run.d.ts +0 -209
- package/dist/run.js +0 -1405
- package/src/agent-proof.ts +0 -264
- package/src/run-adapters.ts +0 -44
- package/src/run.ts +0 -1598
package/dist/evidence.js
CHANGED
|
@@ -1,62 +1,36 @@
|
|
|
1
|
-
// Evidence-bundle verification (
|
|
1
|
+
// Evidence-bundle verification (specs/evidence-bundle.md).
|
|
2
2
|
//
|
|
3
|
-
// A `sanning.evidence/v1` bundle is a
|
|
4
|
-
// `body`.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
// reject-unknown-major. No Sanning service is in the trust path: the bundle is
|
|
13
|
-
// self-verifying offline against the embedded `public_key`; the optional
|
|
14
|
-
// gateway re-fetch only confirms the checkpoints are anchored on-chain.
|
|
15
|
-
//
|
|
16
|
-
// Scope: this verifies a SINGLE evidence bundle. Chain walking over
|
|
17
|
-
// `previous_hash` across successive exports is consumer-layer logic above it.
|
|
18
|
-
import { deriveOperatorAddress, ed25519Verify, hexToBytes,
|
|
19
|
-
// The one hex-field test, which lives in `crypto.ts` beside the one hex
|
|
20
|
-
// reader (proof#146). It was a second copy here until then, with the same
|
|
21
|
-
// body: two copies of one rule is how two kernels drift apart one edit at a
|
|
22
|
-
// time, which is the whole defect class this fix is about.
|
|
23
|
-
isHexField, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
|
|
3
|
+
// A `sanning.evidence/v1` bundle is a signed wrapper around a producer-specific
|
|
4
|
+
// `body`. Every claim is re-derived from `body` with the same JCS, SHA-256 and
|
|
5
|
+
// Ed25519 as the envelope layer, never read off `verdict.status`, and an
|
|
6
|
+
// unknown major is rejected. The bundle verifies offline against its embedded
|
|
7
|
+
// `public_key`; the optional gateway re-fetch only confirms checkpoints are
|
|
8
|
+
// witnessed on-chain.
|
|
9
|
+
import { readAgentRecords } from "./agent-record.js";
|
|
10
|
+
import { isObject } from "./json-schema.js";
|
|
11
|
+
import { deriveOperatorAddress, ed25519Verify, hexToBytes, isHexField, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
|
|
24
12
|
import { safeInteger, signedLeafCount } from "./grading.js";
|
|
25
13
|
import { isConformantHandoffRecord, isHandoffNamespace } from "./handoff.js";
|
|
26
14
|
import { leafHash, verifyInclusion } from "./merkle.js";
|
|
27
|
-
import {
|
|
15
|
+
import { MAX_CANONICAL_DEPTH, contentHashes, exceedsDepth, isRfc3339UtcInstant, jcs, jcsParse, specVersionSupported, verifyEnvelope, } from "./verifier.js";
|
|
28
16
|
import { inBodyContentOffered, sideContentEntry, } from "./side-content.js";
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
/** The accepted `sanning.evidence/v1` wrapper majors; exported in both kernels (P-01, 0.13.0). */
|
|
17
|
+
import { ENVELOPE_FAULTS } from "./types.js";
|
|
18
|
+
// Fail-closed, like ACCEPTED_SPEC_MAJORS: a new major is a one-entry addition here.
|
|
19
|
+
/** The accepted evidence-bundle wrapper majors. */
|
|
33
20
|
export const ACCEPTED_EVIDENCE_MAJORS = Object.freeze(["sanning.evidence/v1"]);
|
|
34
|
-
// The
|
|
35
|
-
// body_type
|
|
36
|
-
//
|
|
37
|
-
// "undetermined" rather than "verified".
|
|
38
|
-
//
|
|
39
|
-
// The producer-to-agent cut (W-3, DL-73) renamed `record_bytes` to
|
|
40
|
-
// `event_bytes` and moved every body that carries it to a new major. This
|
|
41
|
-
// kernel reads ONLY those majors: `sanning.anchor.trace/v1` and `/v2`,
|
|
42
|
-
// `sanning.stamp.evidence/v1` and `sanning.evidence.export/v1` are unknown body
|
|
43
|
-
// types here and answer `partial`. DL-73 releases evidence made before the cut
|
|
44
|
-
// from any obligation to keep verifying, so there is no second field name to
|
|
45
|
-
// read and no dual-accept path.
|
|
21
|
+
// The anchor-trace body this module verifies structurally. An unknown
|
|
22
|
+
// body_type does not fail the wrapper, but its body cannot be re-derived, so
|
|
23
|
+
// it reads `incomplete`. Bodies before the `event_bytes` majors are unknown here.
|
|
46
24
|
export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v3";
|
|
47
25
|
/**
|
|
48
|
-
* The name `event_bytes` replaced.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* checked, the fail-open the new majors exist to prevent (governance.md §8,
|
|
52
|
-
* W-3).
|
|
26
|
+
* The field name `event_bytes` replaced. A body entry still carrying it is a
|
|
27
|
+
* hard reject: ignored, it would read as a withheld record and verify with
|
|
28
|
+
* nothing checked.
|
|
53
29
|
*/
|
|
54
30
|
export const LEGACY_EVENT_BYTES_FIELD = "record_bytes";
|
|
55
31
|
/**
|
|
56
|
-
* Name an envelope the accept-set refused
|
|
57
|
-
*
|
|
58
|
-
* writing `sanning.events/v1`) fails with no reason at all. Spelled with
|
|
59
|
-
* `JSON.stringify` so both kernels write the same sentence.
|
|
32
|
+
* Name an envelope the accept-set refused, so a retired profile major fails
|
|
33
|
+
* with a reason. The same sentence in both kernels.
|
|
60
34
|
*/
|
|
61
35
|
function specVersionFinding(what, envelope, res) {
|
|
62
36
|
if (!isJsonObject(envelope) || res.specVersionOk)
|
|
@@ -67,68 +41,39 @@ function specVersionFinding(what, envelope, res) {
|
|
|
67
41
|
const sv = envelope.spec_version;
|
|
68
42
|
return `${what} envelope spec_version ${JSON.stringify(sv)} is not accepted by this kernel`;
|
|
69
43
|
}
|
|
70
|
-
/**
|
|
71
|
-
* The reject for an entry that still carries `record_bytes`. One sentence,
|
|
72
|
-
* spelled the same in both kernels, so a negative vector can assert it.
|
|
73
|
-
*/
|
|
44
|
+
/** The reject for an entry that still carries `record_bytes`. The same sentence in both kernels. */
|
|
74
45
|
function legacyEventBytesError(entry, what, bodyType) {
|
|
75
46
|
if (!Object.prototype.hasOwnProperty.call(entry, LEGACY_EVENT_BYTES_FIELD))
|
|
76
47
|
return null;
|
|
77
48
|
return (`${what} carries ${LEGACY_EVENT_BYTES_FIELD} in a ${bodyType} body ` +
|
|
78
49
|
"(renamed event_bytes) -- one bundle speaks one vocabulary");
|
|
79
50
|
}
|
|
80
|
-
// The
|
|
81
|
-
//
|
|
82
|
-
// carries a cached verdict + the inline source bundle + embedded RSA-PSS
|
|
83
|
-
// operator attestations. verifyEvidenceBundle dispatches on it to
|
|
84
|
-
// verifyExportBody (§5).
|
|
51
|
+
// The attested-evidence-export body (specs/evidence-export.md §2): a cached
|
|
52
|
+
// verdict, the inline source bundle and RSA-PSS operator attestations.
|
|
85
53
|
export const EXPORT_BODY_TYPE = "sanning.evidence.export/v2";
|
|
86
54
|
/**
|
|
87
|
-
* The Sanning-signed interval stamp body (evidence-bundle.md §5.2
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* is the whole decision: an anchor-trace checkpoint carries a PRODUCER-signed
|
|
91
|
-
* `sanning.events/v2` envelope, and a stamp has no producer signature at all:
|
|
92
|
-
* Sanning places it, under DL-52, for a customer who holds no chain identity.
|
|
93
|
-
* The two rejected options were making `envelope` optional and synthesising an
|
|
94
|
-
* envelope that Sanning signs; the second would have made a Sanning
|
|
95
|
-
* attestation indistinguishable ON THE WIRE from a producer's, which is
|
|
96
|
-
* unrecoverable once witnessed.
|
|
55
|
+
* The Sanning-signed interval stamp body (evidence-bundle.md §5.2). Its own
|
|
56
|
+
* body type, because a stamp has no producer signature: a Sanning attestation
|
|
57
|
+
* must never be indistinguishable on the wire from a producer's.
|
|
97
58
|
*/
|
|
98
59
|
export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
|
|
99
60
|
export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v2";
|
|
100
61
|
/**
|
|
101
|
-
* The version string
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* `sanning.stamp.trace/v1` discriminates the BUNDLE BODY: it is the reader's
|
|
105
|
-
* dispatch key. `sanning.stamp/v1` is the control plane's own version of the
|
|
106
|
-
* bytes it hands a witness (`control-plane/src/stamp-tree.ts`,
|
|
107
|
-
* `STAMP_BODY_VERSION`). A `sanning.stamp.trace/v1` body pins the inner
|
|
108
|
-
* version to exactly this value: two answers to one question inside one
|
|
109
|
-
* signature is the §5.1.2 mixed-vocabulary reject, one layer down.
|
|
62
|
+
* The version string inside a witnessed stamp body, distinct from the
|
|
63
|
+
* `body_type` above (the dispatch key). A stamp trace body pins it to exactly
|
|
64
|
+
* this value (§5.1.2).
|
|
110
65
|
*/
|
|
111
66
|
export const STAMP_BODY_VERSION = "sanning.stamp/v1";
|
|
112
67
|
/**
|
|
113
|
-
* A
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* signed scope (`bundleForSig` below), so anyone holding a pack's bytes can
|
|
117
|
-
* append it with no key at all. This is the one sentence both kernels attach
|
|
118
|
-
* as a note, never a verdict, never an exit code.
|
|
68
|
+
* A wrapper's `co_signatures` is not evidence (evidence-bundle.md §2): nothing
|
|
69
|
+
* verifies it, and it sits outside the signed scope, so anyone can append it.
|
|
70
|
+
* Attached as a note, never a verdict or exit code.
|
|
119
71
|
*/
|
|
120
72
|
export const CO_SIGNATURES_NOTE = "unverified co-signatures are present in this pack's wrapper and are not part of the evidence";
|
|
121
73
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* Absence and an empty array read the same way here: the field asserts
|
|
126
|
-
* nothing about a second signer until it holds one, and evidence-bundle.md's
|
|
127
|
-
* own carve-out language for the analogous envelope field treats "absent"
|
|
128
|
-
* and "an empty array" as one thing (envelope-spec.md §7.1). A non-array
|
|
129
|
-
* value (malformed shape, but the field is reserved, not yet specified)
|
|
130
|
-
* still counts as present: it is not the well-formed absence either rule
|
|
131
|
-
* describes.
|
|
74
|
+
* Whether the wrapper's reserved `co_signatures` carries at least one entry.
|
|
75
|
+
* Absent and empty read the same (envelope-spec.md §7.1); a non-array value
|
|
76
|
+
* counts as present.
|
|
132
77
|
*/
|
|
133
78
|
export function coSignaturesPresent(b) {
|
|
134
79
|
if (b === null || typeof b !== "object" || Array.isArray(b))
|
|
@@ -156,22 +101,18 @@ function isEvidenceMajorAccepted(specVersion) {
|
|
|
156
101
|
return minor.length > 0 && /^[0-9]+$/.test(minor);
|
|
157
102
|
});
|
|
158
103
|
}
|
|
104
|
+
/** evidence-bundle.md §2. Byte-identical to the Python kernel's. */
|
|
105
|
+
export const GENERATED_AT_MALFORMED = "malformed generated_at: not an RFC 3339 UTC date-time with Z naming a real date and time (evidence-bundle section 2)";
|
|
159
106
|
/**
|
|
160
|
-
* A bundle this kernel cannot read.
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* as held, because it did: `signatureOk` and `bodyHashOk` are statements about
|
|
165
|
-
* the wrapper, and a body this kernel cannot parse does not unsign it. The
|
|
166
|
-
* case reader decides whose pack a bundle is on exactly those two fields
|
|
167
|
-
* (`evidence-bundle.md` 5.5.1), so reporting `false` here let a holder who
|
|
168
|
-
* broke its own body shape read as a forger.
|
|
107
|
+
* A bundle this kernel cannot read. `wrapperOk` is true only where the wrapper
|
|
108
|
+
* signature and `body_hash` were checked and held: an unparseable body does not
|
|
109
|
+
* unsign the wrapper, and the case reader decides whose pack it is on those
|
|
110
|
+
* fields (§5.5.1).
|
|
169
111
|
*/
|
|
170
|
-
/** evidence-bundle §2 (A31). Byte-identical to the Python kernel's. */
|
|
171
|
-
export const GENERATED_AT_MALFORMED = "malformed generated_at: not an RFC 3339 UTC date-time with Z naming a real date and time (evidence-bundle §2)";
|
|
172
112
|
function malformed(error, specVersionOk = false, wrapperOk = false) {
|
|
173
113
|
return {
|
|
174
|
-
status: "
|
|
114
|
+
status: "incomplete",
|
|
115
|
+
unreadable: true,
|
|
175
116
|
specVersionOk,
|
|
176
117
|
bodyHashOk: wrapperOk,
|
|
177
118
|
signatureOk: wrapperOk,
|
|
@@ -179,93 +120,136 @@ function malformed(error, specVersionOk = false, wrapperOk = false) {
|
|
|
179
120
|
onChainChecked: false,
|
|
180
121
|
checkpoints: [],
|
|
181
122
|
events: [],
|
|
182
|
-
assertedStatus: null,
|
|
183
123
|
errors: [error],
|
|
184
124
|
};
|
|
185
125
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
* the Python kernel writes too. Anything outside this set comes from the host:
|
|
189
|
-
* a JSON parser, a codec, a crypto library. See `thrownDetail`.
|
|
190
|
-
*/
|
|
191
|
-
// `duplicate key ` is `DuplicateKeyError` in `verifier.ts`, which
|
|
192
|
-
// `DuplicateKey` in the Python twin's `canonicalize.py` words identically.
|
|
126
|
+
// The prefixes of exception text this kernel writes itself, identically in
|
|
127
|
+
// Python. Anything else comes from the host. See `thrownDetail`.
|
|
193
128
|
const KERNEL_WORDING = ["hexToBytes: ", "duplicate key "];
|
|
194
129
|
/**
|
|
195
|
-
* The
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* kernel wrote that text. A finding travels into a case reading through `case`
|
|
199
|
-
* `reasons`, and both kernels promise one reading and one rendered sentence for
|
|
200
|
-
* one set of bytes; a host JSON parser says `Unexpected token 'h', "hello" is
|
|
201
|
-
* not valid JSON` here and `Expecting value: line 1 column 1 (char 0)` there,
|
|
202
|
-
* for one pack anyone can build (gate 4 on proof#140, finding 1).
|
|
203
|
-
*
|
|
204
|
-
* Returns `": <text>"` for this kernel's own wording and the empty string for
|
|
205
|
-
* anything else. The label at the call site already names the check that
|
|
206
|
-
* failed and both kernels agree on it character for character; what is dropped
|
|
207
|
-
* is the host's parse offset, which no reader can act on and which was never
|
|
208
|
-
* the same in the two kernels.
|
|
130
|
+
* The record as the agent reader holds it, or `null` where it takes no part:
|
|
131
|
+
* only a `sanning.events/v2` record whose envelope verified and whose bytes
|
|
132
|
+
* bound to it (section 10).
|
|
209
133
|
*/
|
|
134
|
+
function agentItem(ev, eventBytes, bound) {
|
|
135
|
+
if (!bound || eventBytes === undefined)
|
|
136
|
+
return null;
|
|
137
|
+
const envelope = ev.envelope;
|
|
138
|
+
if (!isObject(envelope) || !specVersionSupported(envelope.spec_version, AGENT_RECORD_PROFILES)) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
let record;
|
|
142
|
+
try {
|
|
143
|
+
record = jcsParse(eventBytes);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
event_id: envelope.event_id,
|
|
150
|
+
public_key: envelope.public_key,
|
|
151
|
+
record,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// The profile whose records the agent vocabulary governs (agent-events.md section 3).
|
|
155
|
+
const AGENT_RECORD_PROFILES = ["sanning.events/v2"];
|
|
156
|
+
/** Read the bundle's records as one pile and put each agent record's reading on its result. */
|
|
157
|
+
async function attachAgentReadings(results, items) {
|
|
158
|
+
const pile = items.filter((item) => item !== null);
|
|
159
|
+
const readings = await readAgentRecords(pile);
|
|
160
|
+
let next = 0;
|
|
161
|
+
for (const [i, item] of items.entries()) {
|
|
162
|
+
if (item !== null)
|
|
163
|
+
results[i].agent = readings[next++];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
210
166
|
/**
|
|
211
|
-
* The
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* about a record whose bytes it had read. A record that repeats a name is
|
|
218
|
-
* a defect in the artifact, and a defect this kernel can see fails, the rule
|
|
219
|
-
* `event_bytes` that are not hex already follow.
|
|
167
|
+
* The finding for record bytes a producer signed that are not JSON, else
|
|
168
|
+
* `undefined` (`_record_not_json` in the Python twin): `event record is not
|
|
169
|
+
* JSON`, with the kernel's `duplicate key "<name>"` where the bytes repeat a
|
|
170
|
+
* member name. The bytes are signed, so the defect is the producer's and
|
|
171
|
+
* visible, and the record fails (evidence-bundle.md section 4), never reads as
|
|
172
|
+
* withheld.
|
|
220
173
|
*/
|
|
221
|
-
function
|
|
174
|
+
function recordNotJson(eventBytes) {
|
|
222
175
|
if (eventBytes === undefined)
|
|
223
176
|
return undefined;
|
|
224
177
|
try {
|
|
225
178
|
jcsParse(eventBytes);
|
|
226
179
|
}
|
|
227
180
|
catch (e) {
|
|
228
|
-
|
|
229
|
-
return e.message;
|
|
181
|
+
return `event record is not JSON${thrownDetail(e)}`;
|
|
230
182
|
}
|
|
231
183
|
return undefined;
|
|
232
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* The detail a finding may append about a caught exception: `": <text>"` for
|
|
187
|
+
* this kernel's own wording, `""` for anything else. Host parser messages
|
|
188
|
+
* differ by kernel, and both kernels must render one sentence for one set of
|
|
189
|
+
* bytes.
|
|
190
|
+
*/
|
|
233
191
|
function thrownDetail(e) {
|
|
234
192
|
const text = e instanceof Error ? e.message : String(e);
|
|
235
193
|
return KERNEL_WORDING.some((w) => text.startsWith(w)) ? `: ${text}` : "";
|
|
236
194
|
}
|
|
237
195
|
/**
|
|
238
|
-
* Decode `bytes` as UTF-8
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
* ONE CALLER LEFT, and it is not a parse. `jcsParse` (`verifier.ts`) reads
|
|
243
|
-
* every set of signed bytes in this kernel now; this function survives only for
|
|
244
|
-
* the canonicality comparison on the stamp path, which needs the DECODED TEXT
|
|
245
|
-
* of bytes `jcsParse` has already accepted, to compare against `jcs(body)`. It
|
|
246
|
-
* performs exactly the decode `jcsParse` performs, so the comparison cannot
|
|
247
|
-
* read the bytes differently from the parse that produced `body`.
|
|
248
|
-
*
|
|
249
|
-
* `new TextDecoder()` alone is neither strict nor BOM-preserving: it replaces
|
|
250
|
-
* every invalid sequence with U+FFFD and strips a BOM. A stamp body carrying
|
|
251
|
-
* one raw `0x80` inside a string value read `verified` here and `failed` there
|
|
252
|
-
* because of it (gate 4 on proof#140). The symmetric claim the first version of
|
|
253
|
-
* this comment made, that "Python's `json.loads` raises on both", is FALSE and
|
|
254
|
-
* is what hid gate 5's defect for a round: `json.loads(<bytes>)` strips a BOM
|
|
255
|
-
* too. Python's strict reader is `jcs_loads`, and the rule both kernels hold to
|
|
256
|
-
* is stated once, in `jcsParse`.
|
|
196
|
+
* Decode `bytes` as strict UTF-8, keeping a leading BOM: the decode `jcsParse`
|
|
197
|
+
* performs, for the stamp path's canonicality comparison on bytes `jcsParse`
|
|
198
|
+
* already accepted.
|
|
257
199
|
*/
|
|
258
200
|
function decodeUtf8Strict(bytes) {
|
|
259
201
|
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
260
202
|
}
|
|
261
203
|
/**
|
|
262
204
|
* Name an envelope `public_key` or `signature` spelled outside the hex charset
|
|
263
|
-
* or at the wrong length
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
|
|
267
|
-
|
|
205
|
+
* or at the wrong length, in the same words as Python, so both kernels give one
|
|
206
|
+
* reading. Only a string is checked; a missing one is the envelope layer's to
|
|
207
|
+
* fail.
|
|
208
|
+
*/
|
|
209
|
+
/**
|
|
210
|
+
* The envelope faults that leave a claim not proven rather than disproven: the record's
|
|
211
|
+
* envelope or key cannot be read, or its version is one this verifier does not know.
|
|
212
|
+
*/
|
|
213
|
+
export const UNPROVEN_ENVELOPE_FAULTS = [
|
|
214
|
+
"not_an_envelope",
|
|
215
|
+
"key_unreadable",
|
|
216
|
+
"spec_version_unknown",
|
|
217
|
+
];
|
|
218
|
+
/**
|
|
219
|
+
* A record that fails only on something not proven: a stamp or checkpoint the pack does not
|
|
220
|
+
* hold, an envelope or key that cannot be read, or a version this verifier does not know,
|
|
221
|
+
* while every check that runs without it holds (the binding, the inclusion proof and the
|
|
222
|
+
* content). It holds the bundle at `incomplete`, never `failed`.
|
|
223
|
+
*/
|
|
224
|
+
function unproven(e) {
|
|
225
|
+
const envelopeUnproven = e.envelopeFault !== undefined && e.envelopeFault !== null && UNPROVEN_ENVELOPE_FAULTS.includes(e.envelopeFault);
|
|
226
|
+
const stampAbsent = e.stampAbsent === true;
|
|
227
|
+
return ((stampAbsent || envelopeUnproven) &&
|
|
228
|
+
(e.envelopeOk || envelopeUnproven) &&
|
|
229
|
+
(envelopeUnproven || e.ownFormFailed !== true) &&
|
|
230
|
+
e.payloadBindingOk !== false &&
|
|
231
|
+
e.contentOk !== false &&
|
|
232
|
+
(stampAbsent || (e.inclusionOk && e.checkpointBound)));
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Why a record's envelope does not verify, worst first: the envelope layer's own faults,
|
|
236
|
+
* and, where only this kernel's strict hex reading refuses a field, a key that cannot be read
|
|
237
|
+
* or a signature that does not hold. A field's form is not judged under either.
|
|
268
238
|
*/
|
|
239
|
+
function eventEnvelopeFaults(faults, hexError) {
|
|
240
|
+
const found = new Set(faults);
|
|
241
|
+
if (hexError !== null) {
|
|
242
|
+
found.delete("nonconforming");
|
|
243
|
+
if (hexError.includes("public_key")) {
|
|
244
|
+
found.delete("signature_failed");
|
|
245
|
+
found.add("key_unreadable");
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
found.add("signature_failed");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return ENVELOPE_FAULTS.filter((f) => found.has(f));
|
|
252
|
+
}
|
|
269
253
|
function envelopeHexError(what, envelope) {
|
|
270
254
|
if (!isJsonObject(envelope))
|
|
271
255
|
return null;
|
|
@@ -281,30 +265,20 @@ function envelopeHexError(what, envelope) {
|
|
|
281
265
|
return null;
|
|
282
266
|
}
|
|
283
267
|
/**
|
|
284
|
-
* Spell a value inside a finding
|
|
285
|
-
*
|
|
286
|
-
* `JSON.stringify(undefined)` is `undefined` (the value, not a string), so an
|
|
287
|
-
* absent field interpolates as the word `undefined` where Python writes `null`.
|
|
288
|
-
* One word, and a real difference in what an auditor reads: someone comparing
|
|
289
|
-
* the two CLIs on one bundle sees two different sentences for one fact. Both
|
|
290
|
-
* kernels route every quoted value through this spelling, and the cross-kernel
|
|
291
|
-
* anchor-trace leg byte-compares them.
|
|
268
|
+
* Spell a value inside a finding as Python does: an absent value reads `null`,
|
|
269
|
+
* never `undefined`.
|
|
292
270
|
*/
|
|
293
271
|
function jsonStr(value) {
|
|
294
272
|
return JSON.stringify(value === undefined ? null : value) ?? "null";
|
|
295
273
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
// function recomputes (evidence-bundle §1 principle 3 / §4 step 4).
|
|
274
|
+
/**
|
|
275
|
+
* Verify a `sanning.evidence/v1` bundle, dispatching on its `body_type`. The
|
|
276
|
+
* returned `status` is recomputed from the body; a wrapper `verdict` member,
|
|
277
|
+
* where a producer still writes one, is never read (evidence-bundle.md section 4).
|
|
278
|
+
*/
|
|
302
279
|
export async function verifyEvidenceBundle(bundle, options = {}) {
|
|
303
|
-
// The
|
|
304
|
-
//
|
|
305
|
-
// it, however the wrapper otherwise verifies. Never touches `status` or
|
|
306
|
-
// the exit code: a bundle that would otherwise verify still verifies, note
|
|
307
|
-
// or not (proof#148).
|
|
280
|
+
// The one place a present `co_signatures` becomes a note, so `case.ts` gets
|
|
281
|
+
// it too. Never touches `status`.
|
|
308
282
|
const result = await verifyBundle(bundle, options, false);
|
|
309
283
|
const note = coSignaturesNote(bundle);
|
|
310
284
|
if (note !== undefined) {
|
|
@@ -313,31 +287,49 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
|
|
|
313
287
|
return result;
|
|
314
288
|
}
|
|
315
289
|
// `exportSource`: read the bundle as an export's source (evidence-export.md
|
|
316
|
-
// §2.4), where
|
|
290
|
+
// §2.4), where only `sanning.anchor.trace/v3` is recognised.
|
|
317
291
|
async function verifyBundle(bundle, options, exportSource) {
|
|
318
|
-
//
|
|
292
|
+
// Step 1: parse and reject an unknown spec_version major.
|
|
319
293
|
if (bundle === null || typeof bundle !== "object" || Array.isArray(bundle)) {
|
|
320
294
|
return malformed("evidence bundle is not a JSON object");
|
|
321
295
|
}
|
|
322
|
-
// Reject a pathologically deep bundle
|
|
323
|
-
//
|
|
324
|
-
// kernel rather than a stack-limit-dependent split verdict — see
|
|
325
|
-
// MAX_CANONICAL_DEPTH.
|
|
296
|
+
// Reject a pathologically deep bundle before canonicalization recurses (see
|
|
297
|
+
// MAX_CANONICAL_DEPTH).
|
|
326
298
|
if (exceedsDepth(bundle, MAX_CANONICAL_DEPTH)) {
|
|
327
299
|
return malformed(`evidence bundle nesting exceeds the ${MAX_CANONICAL_DEPTH}-level canonicalization depth bound`);
|
|
328
300
|
}
|
|
329
301
|
const b = bundle;
|
|
330
302
|
const specVersionOk = isEvidenceMajorAccepted(b.spec_version);
|
|
331
303
|
if (!specVersionOk) {
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
304
|
+
// An absent field is spelled `null`, as in Python.
|
|
305
|
+
const unknown = malformed(`unsupported evidence spec_version: ${JSON.stringify(b.spec_version ?? null)}`, false);
|
|
306
|
+
unknown.versionUnknown = typeof b.spec_version === "string";
|
|
307
|
+
if (unknown.versionUnknown && typeof b.public_key === "string" && typeof b.signature === "string") {
|
|
308
|
+
// A version this verifier does not know still carries a signature it can check: one
|
|
309
|
+
// that fails is a pack changed after it was signed.
|
|
310
|
+
const found = [];
|
|
311
|
+
const [keyFormOk, signatureOk] = await checkWrapperSignature(b, found);
|
|
312
|
+
if (keyFormOk && !signatureOk) {
|
|
313
|
+
return {
|
|
314
|
+
status: "failed",
|
|
315
|
+
specVersionOk: false,
|
|
316
|
+
// Not checked under this version, so not the disproof here.
|
|
317
|
+
bodyHashOk: true,
|
|
318
|
+
signatureOk: false,
|
|
319
|
+
bodyType: null,
|
|
320
|
+
onChainChecked: false,
|
|
321
|
+
checkpoints: [],
|
|
322
|
+
events: [],
|
|
323
|
+
errors: [...unknown.errors, ...found],
|
|
324
|
+
versionUnknown: true,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
unknown.signatureOk = signatureOk;
|
|
328
|
+
}
|
|
329
|
+
return unknown;
|
|
337
330
|
}
|
|
338
331
|
if (typeof b.signature_alg === "string" && b.signature_alg !== "ed25519") {
|
|
339
|
-
//
|
|
340
|
-
// decision #2); other algs are a deliberate future addition.
|
|
332
|
+
// Ed25519 only (evidence-bundle.md §2).
|
|
341
333
|
return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg ?? null)}`, true);
|
|
342
334
|
}
|
|
343
335
|
if (typeof b.public_key !== "string" || typeof b.signature !== "string") {
|
|
@@ -347,20 +339,16 @@ async function verifyBundle(bundle, options, exportSource) {
|
|
|
347
339
|
return malformed("evidence bundle is missing body_hash", true);
|
|
348
340
|
}
|
|
349
341
|
if (b.body === undefined || b.body === null) {
|
|
350
|
-
// body_ref (out-of-line body) is
|
|
351
|
-
// anchor-trace use case — refuse rather than silently pass.
|
|
342
|
+
// `body_ref` (out-of-line body) is refused rather than passed.
|
|
352
343
|
return malformed("evidence bundle has no inline body (body_ref unsupported here)", true);
|
|
353
344
|
}
|
|
354
|
-
//
|
|
345
|
+
// Step 2: verify the wrapper signature and recompute `body_hash`.
|
|
355
346
|
const errors = [];
|
|
356
|
-
// Every wrapper hex field is read
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
// `signature` are 64 and 128 hex characters in either case (proof#135,
|
|
362
|
-
// option B), and `body_hash` is 64 LOWER-case hex characters, the form it is
|
|
363
|
-
// recomputed in and compared against.
|
|
347
|
+
// Every wrapper hex field is read strictly, before any decoder sees it:
|
|
348
|
+
// `signature` is outside the signed scope, so a lenient decoder would let
|
|
349
|
+
// anyone flip a verdict by adding whitespace. `public_key` and `signature`
|
|
350
|
+
// are 64 and 128 hex characters in either case; `body_hash` is 64 lowercase,
|
|
351
|
+
// the form it is recomputed in.
|
|
364
352
|
let bodyHashOk = false;
|
|
365
353
|
if (!isHexField(b.body_hash, 64, true)) {
|
|
366
354
|
errors.push("wrapper body_hash is not 64 lowercase hex characters");
|
|
@@ -377,6 +365,69 @@ async function verifyBundle(bundle, options, exportSource) {
|
|
|
377
365
|
errors.push(`body canonicalization failed${thrownDetail(e)}`);
|
|
378
366
|
}
|
|
379
367
|
}
|
|
368
|
+
const [keyFormOk, signatureOk] = await checkWrapperSignature(b, errors);
|
|
369
|
+
// A key that cannot be read checks no signature: `signed` is not proven on that claim
|
|
370
|
+
// only. A `body_hash` that does not hold still fails, and a body that holds is still read,
|
|
371
|
+
// so a disproof inside it stands.
|
|
372
|
+
if (!keyFormOk && bodyHashOk) {
|
|
373
|
+
// Form is judged only under a signature that holds, so the wrapper's time is not.
|
|
374
|
+
const result = await readWrappedBody(b, specVersionOk, errors, options, exportSource, false);
|
|
375
|
+
result.signatureOk = false;
|
|
376
|
+
result.keyUnreadable = true;
|
|
377
|
+
if (result.status === "verified")
|
|
378
|
+
result.status = "incomplete";
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
if (!keyFormOk) {
|
|
382
|
+
return {
|
|
383
|
+
status: "failed",
|
|
384
|
+
specVersionOk,
|
|
385
|
+
bodyHashOk,
|
|
386
|
+
signatureOk: false,
|
|
387
|
+
bodyType: typeof b.body_type === "string" ? b.body_type : null,
|
|
388
|
+
onChainChecked: false,
|
|
389
|
+
checkpoints: [],
|
|
390
|
+
events: [],
|
|
391
|
+
errors,
|
|
392
|
+
keyUnreadable: true,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
// A broken wrapper is a hard failure: the body is untrustworthy.
|
|
396
|
+
if (!signatureOk || !bodyHashOk) {
|
|
397
|
+
return {
|
|
398
|
+
status: "failed",
|
|
399
|
+
specVersionOk,
|
|
400
|
+
bodyHashOk,
|
|
401
|
+
signatureOk,
|
|
402
|
+
bodyType: typeof b.body_type === "string" ? b.body_type : null,
|
|
403
|
+
onChainChecked: false,
|
|
404
|
+
checkpoints: [],
|
|
405
|
+
events: [],
|
|
406
|
+
errors,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
return readWrappedBody(b, specVersionOk, errors, options, exportSource);
|
|
410
|
+
}
|
|
411
|
+
/** Everything a bundle says past its wrapper: its time, then its body by type. */
|
|
412
|
+
async function readWrappedBody(b, specVersionOk, errors, options, exportSource, judgeTime = true) {
|
|
413
|
+
const result = await readBody(b, specVersionOk, errors, options, exportSource);
|
|
414
|
+
// The wrapper's `generated_at` (evidence-bundle.md §2) is printed beside every verdict
|
|
415
|
+
// and copied into `as_of`, so a present value that is not RFC 3339 UTC is a format the
|
|
416
|
+
// producer broke. The body is read first, so a disproof inside it stands beside this one.
|
|
417
|
+
if (judgeTime &&
|
|
418
|
+
Object.prototype.hasOwnProperty.call(b, "generated_at") &&
|
|
419
|
+
!isRfc3339UtcInstant(b.generated_at)) {
|
|
420
|
+
result.status = "failed";
|
|
421
|
+
result.generatedAtMalformed = true;
|
|
422
|
+
result.errors = [GENERATED_AT_MALFORMED, ...result.errors.filter((e) => e !== GENERATED_AT_MALFORMED)];
|
|
423
|
+
}
|
|
424
|
+
return result;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* `[keyFormOk, signatureOk]` for the wrapper's Ed25519 signature over every field but
|
|
428
|
+
* `signature` and `co_signatures`. `public_key` and `signature` are strings here.
|
|
429
|
+
*/
|
|
430
|
+
async function checkWrapperSignature(b, errors) {
|
|
380
431
|
let signatureOk = false;
|
|
381
432
|
const keyFormOk = isHexField(b.public_key, 64);
|
|
382
433
|
const signatureFormOk = isHexField(b.signature, 128);
|
|
@@ -395,108 +446,71 @@ async function verifyBundle(bundle, options, exportSource) {
|
|
|
395
446
|
errors.push(`wrapper signature verification error${thrownDetail(e)}`);
|
|
396
447
|
}
|
|
397
448
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
return {
|
|
403
|
-
status: "failed",
|
|
404
|
-
specVersionOk,
|
|
405
|
-
bodyHashOk,
|
|
406
|
-
signatureOk,
|
|
407
|
-
bodyType: typeof b.body_type === "string" ? b.body_type : null,
|
|
408
|
-
onChainChecked: false,
|
|
409
|
-
checkpoints: [],
|
|
410
|
-
events: [],
|
|
411
|
-
assertedStatus,
|
|
412
|
-
errors,
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
// --- The wrapper's own time (evidence-bundle §2, A31) ---------------------
|
|
416
|
-
// `generated_at` is copied into a recomputed verdict's `as_of` (the export
|
|
417
|
-
// reading) and printed beside every verdict, so a present value that is not
|
|
418
|
-
// the RFC 3339 form `signed_at` already pins is a wrapper this kernel cannot
|
|
419
|
-
// render, whatever its signature. It is read only once the wrapper holds: a
|
|
420
|
-
// broken wrapper is `failed` above, as it was. Absence is unchanged.
|
|
421
|
-
if (Object.prototype.hasOwnProperty.call(b, "generated_at") &&
|
|
422
|
-
!isRfc3339UtcInstant(b.generated_at)) {
|
|
423
|
-
return malformed(GENERATED_AT_MALFORMED, specVersionOk, true);
|
|
424
|
-
}
|
|
425
|
-
// --- Body dispatch ---------------------------------------------------------
|
|
449
|
+
return [keyFormOk, signatureOk];
|
|
450
|
+
}
|
|
451
|
+
/** A wrapped body, read by its type. */
|
|
452
|
+
async function readBody(b, specVersionOk, errors, options, exportSource) {
|
|
426
453
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
// verifies on its own, is undetermined here, after the wrapper checks above.
|
|
454
|
+
// As an export's source, only sanning.anchor.trace/v3 is recognised; any
|
|
455
|
+
// other body is undetermined.
|
|
430
456
|
if (exportSource && bodyType !== ANCHOR_TRACE_BODY_TYPE) {
|
|
431
457
|
errors.push(`body_type ${JSON.stringify(bodyType)} is not an export source ` +
|
|
432
|
-
`(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body
|
|
458
|
+
`(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body not checked`);
|
|
433
459
|
return {
|
|
434
|
-
status: "
|
|
460
|
+
status: "incomplete",
|
|
435
461
|
specVersionOk,
|
|
436
|
-
bodyHashOk,
|
|
437
|
-
signatureOk,
|
|
462
|
+
bodyHashOk: true,
|
|
463
|
+
signatureOk: true,
|
|
438
464
|
bodyType,
|
|
439
465
|
onChainChecked: false,
|
|
440
466
|
checkpoints: [],
|
|
441
467
|
events: [],
|
|
442
|
-
assertedStatus,
|
|
443
468
|
errors,
|
|
444
469
|
};
|
|
445
470
|
}
|
|
446
|
-
//
|
|
447
|
-
// authentic (Ed25519 issuer signature + body_hash verified above, §5 step 2);
|
|
448
|
-
// hand off to the export verifier for steps 3–9.
|
|
471
|
+
// The wrapper is authentic (§5 step 2); the export verifier runs steps 3 to 9.
|
|
449
472
|
if (bodyType === EXPORT_BODY_TYPE) {
|
|
450
|
-
return verifyExportBody(b, specVersionOk,
|
|
473
|
+
return verifyExportBody(b, specVersionOk, errors, options);
|
|
451
474
|
}
|
|
452
|
-
// The
|
|
453
|
-
//
|
|
454
|
-
// a stamp carries no second signature of its own inside the bundle.
|
|
475
|
+
// The stamp body (§5.2). For this body the wrapper signature is what
|
|
476
|
+
// "Sanning signed it" means: a stamp carries no second signature in the bundle.
|
|
455
477
|
if (bodyType === STAMP_TRACE_BODY_TYPE) {
|
|
456
|
-
return verifyStampTrace(b, specVersionOk,
|
|
478
|
+
return verifyStampTrace(b, specVersionOk, errors, options);
|
|
457
479
|
}
|
|
458
|
-
// The
|
|
480
|
+
// The hand-over body (§5.3): stamps and the customer's events, joined.
|
|
459
481
|
if (bodyType === STAMP_EVIDENCE_BODY_TYPE) {
|
|
460
|
-
return verifyStampEvidence(b, specVersionOk,
|
|
482
|
+
return verifyStampEvidence(b, specVersionOk, errors, options);
|
|
461
483
|
}
|
|
462
484
|
if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
|
|
463
|
-
//
|
|
464
|
-
// wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
|
|
465
|
-
// body verdict undetermined) — not a failure.
|
|
485
|
+
// An authentic wrapper over an unknown body: `incomplete`, not a failure.
|
|
466
486
|
errors.push(`body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
|
|
467
|
-
`(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body
|
|
487
|
+
`(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body not checked`);
|
|
468
488
|
return {
|
|
469
|
-
status: "
|
|
489
|
+
status: "incomplete",
|
|
470
490
|
specVersionOk,
|
|
471
|
-
bodyHashOk,
|
|
472
|
-
signatureOk,
|
|
491
|
+
bodyHashOk: true,
|
|
492
|
+
signatureOk: true,
|
|
473
493
|
bodyType,
|
|
474
494
|
onChainChecked: false,
|
|
475
495
|
checkpoints: [],
|
|
476
496
|
events: [],
|
|
477
|
-
assertedStatus,
|
|
478
497
|
errors,
|
|
479
498
|
};
|
|
480
499
|
}
|
|
481
|
-
return verifyAnchorTrace(b, specVersionOk,
|
|
500
|
+
return verifyAnchorTrace(b, specVersionOk, errors, options);
|
|
482
501
|
}
|
|
483
502
|
/**
|
|
484
|
-
* Recompute an export's source verdict (evidence-export.md §5 step 4).
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
* verifies on its own, such as a `sanning.stamp.evidence/v2` pack: §2.4 makes
|
|
489
|
-
* its source verdict undetermined, so the export cannot reach `verified`. The
|
|
490
|
-
* source wrapper is still checked first, because a forged source is a failure
|
|
491
|
-
* whatever its body.
|
|
503
|
+
* Recompute an export's source verdict (evidence-export.md §5 step 4). Only a
|
|
504
|
+
* `sanning.anchor.trace/v3` source is recognised (§2.4); any other reads
|
|
505
|
+
* undetermined, so the export cannot reach `verified`. The source wrapper is
|
|
506
|
+
* checked first, because a forged source fails whatever its body.
|
|
492
507
|
*/
|
|
493
508
|
async function verifyExportSource(source, options) {
|
|
494
509
|
return verifyBundle(source, options, true);
|
|
495
510
|
}
|
|
496
511
|
/**
|
|
497
|
-
* Index a source bundle's checkpoints by the key
|
|
498
|
-
*
|
|
499
|
-
* checkpoints an attestation can resolve (evidence-export.md §2.4).
|
|
512
|
+
* Index a source bundle's checkpoints by derived `checkpoint_ref`, the key
|
|
513
|
+
* attestations use (evidence-export.md §2.4).
|
|
500
514
|
*/
|
|
501
515
|
async function indexSourceCheckpoints(bundle) {
|
|
502
516
|
const out = new Map();
|
|
@@ -523,25 +537,14 @@ async function indexSourceCheckpoints(bundle) {
|
|
|
523
537
|
return out;
|
|
524
538
|
}
|
|
525
539
|
/**
|
|
526
|
-
* Resolve a checkpoint's join key and
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
* A checkpoint carrying a retired spelling beside the current one is two
|
|
531
|
-
* answers to one question inside one signature. A reader that silently prefers
|
|
532
|
-
* one gives a different answer from a reader that prefers the other, over the
|
|
533
|
-
* same signed bytes, and both look like they verified. So `tx_id` and
|
|
534
|
-
* `record_bytes` are rejected rather than resolved.
|
|
535
|
-
*
|
|
536
|
-
* ── Why the key is recomputed ──
|
|
537
|
-
*
|
|
538
|
-
* The key IS the bytes: relabelling a checkpoint and every event pointing at it
|
|
539
|
-
* fails here, because nothing is taken from the label.
|
|
540
|
+
* Resolve a checkpoint's join key and witness reference. The key is recomputed
|
|
541
|
+
* from the bytes, never taken from the label. A retired spelling (`tx_id`,
|
|
542
|
+
* `record_bytes`) beside the current one is two answers inside one signature,
|
|
543
|
+
* so it is rejected rather than resolved.
|
|
540
544
|
*/
|
|
541
545
|
async function resolveCheckpointKey(cp, eventBytes) {
|
|
542
546
|
const errors = [];
|
|
543
|
-
//
|
|
544
|
-
// nothing there is "malformed": it is unexamined, which is not the same claim.
|
|
547
|
+
// Early returns never look at `anchor_ref`, so it is unexamined, not malformed.
|
|
545
548
|
const unexamined = { key: null, anchorRef: null, anchorRefMalformed: false, errors };
|
|
546
549
|
if (cp.tx_id !== undefined) {
|
|
547
550
|
errors.push(`${ANCHOR_TRACE_BODY_TYPE} checkpoint carries legacy tx_id -- one bundle speaks one vocabulary`);
|
|
@@ -575,33 +578,12 @@ async function resolveCheckpointKey(cp, eventBytes) {
|
|
|
575
578
|
};
|
|
576
579
|
}
|
|
577
580
|
/**
|
|
578
|
-
* Validate an optional `anchor_ref` (§5.1.1).
|
|
579
|
-
*
|
|
580
|
-
* ── An unrecognised `kind` is NOT a failure, and that is the point ──
|
|
581
|
-
*
|
|
582
|
-
* The offline verdict — signatures, payload binding, Merkle inclusion — is
|
|
583
|
-
* computed with no witness access at all. A reader that cannot resolve a
|
|
584
|
-
* witness has therefore learned NOTHING about whether the evidence was
|
|
585
|
-
* altered, and must not say otherwise. The shipped issuer did the opposite:
|
|
586
|
-
* an unrecognised signature type returned `false` rather than `null`, mapped
|
|
587
|
-
* to `tampered`, and was cached as permanent — a false accusation, produced
|
|
588
|
-
* by a coverage gap, for a company whose product is telling people whether
|
|
589
|
-
* records were altered.
|
|
590
|
-
*
|
|
591
|
-
* So this validates the SHAPE and never the vocabulary. A malformed
|
|
592
|
-
* `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
|
|
593
|
-
* the reader, and only the first is the bundle's fault.
|
|
581
|
+
* Validate an optional `anchor_ref` (§5.1.1): its shape, never its vocabulary.
|
|
594
582
|
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
* asked in either case. For the ABSENT case that is at least the note
|
|
600
|
-
* `evidence-bundle.md` §5.1.1 reserves for "a checkpoint with no `anchor_ref`".
|
|
601
|
-
* For the MALFORMED case it is a manufactured outage: the bundle named a
|
|
602
|
-
* witness, the name was unreadable, and the reader was told the public record
|
|
603
|
-
* could not be reached. So the two come back as different facts, and `malformed`
|
|
604
|
-
* carries the difference.
|
|
583
|
+
* An unrecognised `kind` is not a failure: the offline verdict needs no witness,
|
|
584
|
+
* so a reader that cannot resolve one has learned nothing about alteration and
|
|
585
|
+
* must not accuse. A malformed `anchor_ref` is a defect in the bundle, and is
|
|
586
|
+
* returned as `malformed` so it is never reported as an unreachable witness.
|
|
605
587
|
*/
|
|
606
588
|
function parseAnchorRef(raw, errors) {
|
|
607
589
|
if (raw === undefined || raw === null) {
|
|
@@ -634,7 +616,7 @@ function parseAnchorRef(raw, errors) {
|
|
|
634
616
|
malformed: false,
|
|
635
617
|
};
|
|
636
618
|
}
|
|
637
|
-
async function verifyAnchorTrace(b, specVersionOk,
|
|
619
|
+
async function verifyAnchorTrace(b, specVersionOk, errors, options) {
|
|
638
620
|
const body = b.body;
|
|
639
621
|
const bodyType = ANCHOR_TRACE_BODY_TYPE;
|
|
640
622
|
if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
|
|
@@ -642,31 +624,17 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
642
624
|
}
|
|
643
625
|
const gateways = options.gateways ?? [];
|
|
644
626
|
const onChainChecked = gateways.length > 0;
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
// ── Why the join key is resolved BEFORE anything else ──
|
|
648
|
-
//
|
|
649
|
-
// `byKey` is what binds an event to the root its inclusion proof is checked
|
|
650
|
-
// against. Get it wrong and an event proves inclusion in a tree it was never
|
|
651
|
-
// in — the one failure in this file that produces a WRONG VERIFIED, not a
|
|
652
|
-
// failed verify. Everything below is downstream of it.
|
|
627
|
+
// Step 3: per checkpoint. `byKey` binds each event to the root its inclusion
|
|
628
|
+
// is checked against; a wrong key here would yield a wrong `verified`.
|
|
653
629
|
const byKey = new Map();
|
|
654
630
|
const anchorByKey = new Map();
|
|
655
631
|
const checkpointResults = [];
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
// `errors` already states, not a new dimension of the published verdict, and
|
|
659
|
-
// `CheckpointResult` is a shape downstream surfaces render.
|
|
632
|
+
// Kept beside the results: an unreadable witness name is an input defect the
|
|
633
|
+
// checkpoint's `errors` already states, not a verdict dimension.
|
|
660
634
|
const malformedAnchorRef = new Set();
|
|
661
635
|
for (const rawCp of body.checkpoints) {
|
|
662
|
-
// `checkpoints[]` is attacker-shaped
|
|
663
|
-
//
|
|
664
|
-
// caller a TypeError instead of a verdict, which an auditor cannot tell
|
|
665
|
-
// apart from a broken tool. Graded and reported as "not a checkpoint"
|
|
666
|
-
// rather than coerced into an empty one: probing the envelope layer with
|
|
667
|
-
// nothing asks it a question about an object that was never an envelope.
|
|
668
|
-
// Same treatment, same words, as the §5.3 path's `events[] entry is not an
|
|
669
|
-
// object`.
|
|
636
|
+
// `checkpoints[]` is attacker-shaped; a verifier never raises on it. A
|
|
637
|
+
// non-object entry is reported as not a checkpoint, never coerced.
|
|
670
638
|
if (rawCp === null || typeof rawCp !== "object" || Array.isArray(rawCp)) {
|
|
671
639
|
checkpointResults.push({
|
|
672
640
|
checkpointRef: "",
|
|
@@ -685,8 +653,7 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
685
653
|
let envelopeOk = false;
|
|
686
654
|
let merkleRootOk = false;
|
|
687
655
|
let leafCountOk = false;
|
|
688
|
-
//
|
|
689
|
-
// is an input to identity, not just to the payload binding below.
|
|
656
|
+
// The join key is derived from `event_bytes`, so decode them first.
|
|
690
657
|
let eventBytes;
|
|
691
658
|
try {
|
|
692
659
|
if (typeof cp.event_bytes === "string")
|
|
@@ -697,15 +664,9 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
697
664
|
}
|
|
698
665
|
const keyed = await resolveCheckpointKey(cp, eventBytes);
|
|
699
666
|
cpErrors.push(...keyed.errors);
|
|
700
|
-
// A duplicate key is a
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
// never independently checked. Recording the finding and leaving `ok` true
|
|
704
|
-
// let the bundle roll up to `verified` while carrying a checkpoint nothing
|
|
705
|
-
// had verified: a false pass, which is what `evidence-bundle.md` §3
|
|
706
|
-
// principle 3 exists to prevent (the displayed verdict must be the
|
|
707
|
-
// recomputed one, and here the recomputation quietly dropped a finding it
|
|
708
|
-
// had made).
|
|
667
|
+
// A duplicate key is a hard failure: `byKey` keeps the first, so a second
|
|
668
|
+
// checkpoint's identity would otherwise never be checked and the bundle
|
|
669
|
+
// could roll up `verified` over it.
|
|
709
670
|
let duplicateKey = false;
|
|
710
671
|
if (keyed.key !== null) {
|
|
711
672
|
if (byKey.has(keyed.key)) {
|
|
@@ -717,17 +678,14 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
717
678
|
anchorByKey.set(keyed.key, keyed.anchorRef);
|
|
718
679
|
}
|
|
719
680
|
}
|
|
720
|
-
//
|
|
721
|
-
// commitment — the record is the payload these external envelopes bind to).
|
|
681
|
+
// The record is the payload these external-commitment envelopes bind to.
|
|
722
682
|
try {
|
|
723
683
|
const res = await verifyEnvelope(cp.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
|
|
724
|
-
// A checkpoint must fully bind:
|
|
725
|
-
//
|
|
726
|
-
// anchor the events' inclusion claims to a trustworthy root.
|
|
684
|
+
// A checkpoint must fully bind: an undetermined binding cannot anchor the
|
|
685
|
+
// events' inclusion claims to a trustworthy root.
|
|
727
686
|
const hexError = envelopeHexError("checkpoint", cp.envelope);
|
|
728
687
|
envelopeOk = res.ok && res.payloadHashOk === true && hexError === null;
|
|
729
|
-
//
|
|
730
|
-
// not a failed check (proof#76).
|
|
688
|
+
// A degenerate envelope is malformed input, not a failed check.
|
|
731
689
|
if (!isJsonObject(cp.envelope)) {
|
|
732
690
|
cpErrors.push("checkpoint envelope is not a JSON object");
|
|
733
691
|
}
|
|
@@ -739,19 +697,15 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
739
697
|
}
|
|
740
698
|
if (res.payloadHashOk === false)
|
|
741
699
|
cpErrors.push("checkpoint record does not bind to payload_hash");
|
|
742
|
-
//
|
|
743
|
-
// signature and binding; name it rather than fail with no reason.
|
|
700
|
+
// Name a malformed skeleton field, which fails an otherwise valid envelope.
|
|
744
701
|
for (const m of res.errors)
|
|
745
702
|
if (m.startsWith("malformed "))
|
|
746
703
|
cpErrors.push(`checkpoint envelope ${m}`);
|
|
747
704
|
const specFinding = specVersionFinding("checkpoint", cp.envelope, res);
|
|
748
705
|
if (specFinding !== null)
|
|
749
706
|
cpErrors.push(specFinding);
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
// unbindable (a degenerate envelope, unreadable hex) is another, and
|
|
753
|
-
// naming the wrong cause sends a reader to look for a field that is
|
|
754
|
-
// sitting right there (proof#76).
|
|
707
|
+
// Name why the binding is undetermined: absent bytes and unbindable bytes
|
|
708
|
+
// are different causes.
|
|
755
709
|
if (res.payloadHashOk === null) {
|
|
756
710
|
cpErrors.push(eventBytes === undefined
|
|
757
711
|
? "checkpoint event_bytes missing -- root untrusted"
|
|
@@ -761,8 +715,7 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
761
715
|
catch (e) {
|
|
762
716
|
cpErrors.push(`checkpoint envelope verify error${thrownDetail(e)}`);
|
|
763
717
|
}
|
|
764
|
-
//
|
|
765
|
-
// merkle_root (recompute, don't trust the bundle's top-level claim).
|
|
718
|
+
// Recompute: the committed record's merkle_root must equal the claimed one.
|
|
766
719
|
if (eventBytes !== undefined) {
|
|
767
720
|
try {
|
|
768
721
|
const record = jcsParse(eventBytes);
|
|
@@ -772,12 +725,9 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
772
725
|
cpErrors.push(`checkpoint merkle_root mismatch: claimed=${jsonStr(cp.merkle_root)} ` +
|
|
773
726
|
`committed=${jsonStr(committedRoot)}`);
|
|
774
727
|
}
|
|
775
|
-
// §5.1
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
// the checkpoint, as a record that signs no merkle_root does. Read by
|
|
779
|
-
// value through the one reader the inclusion compare and the
|
|
780
|
-
// completeness notes share, so `3.0` and `3e0` are 3.
|
|
728
|
+
// Fail closed (§5.1): every inclusion is held to the count the record
|
|
729
|
+
// signs, so a record that signs no integer count fails the checkpoint.
|
|
730
|
+
// Read by value through the shared reader, so `3.0` and `3e0` are 3.
|
|
781
731
|
leafCountOk = signedLeafCount(cp) !== null;
|
|
782
732
|
if (!leafCountOk)
|
|
783
733
|
cpErrors.push("checkpoint record leaf_count is not an integer");
|
|
@@ -793,25 +743,30 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
793
743
|
merkleRootOk,
|
|
794
744
|
onChainOk: null, // filled in step 5 if a gateway re-fetch was requested
|
|
795
745
|
onChain: null, // per-gateway (§4.2), filled in step 5
|
|
796
|
-
// `
|
|
797
|
-
//
|
|
798
|
-
// can see rather than a coverage gap we cannot — and this kernel's
|
|
799
|
-
// standing rule is that malformed is a failure while absent is
|
|
800
|
-
// undetermined (the same rule disclosed `content` follows). Without
|
|
801
|
-
// this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
|
|
746
|
+
// A malformed `anchor_ref` is inside the signature: a visible defect,
|
|
747
|
+
// which fails, while an absent one is undetermined.
|
|
802
748
|
ok: envelopeOk && merkleRootOk && leafCountOk && keyed.errors.length === 0 && !duplicateKey,
|
|
803
749
|
errors: cpErrors,
|
|
750
|
+
anchorRefMalformed: keyed.anchorRefMalformed && envelopeOk && merkleRootOk && leafCountOk && !duplicateKey,
|
|
804
751
|
});
|
|
805
752
|
if (keyed.anchorRefMalformed) {
|
|
806
753
|
malformedAnchorRef.add(checkpointResults[checkpointResults.length - 1]);
|
|
807
754
|
}
|
|
808
755
|
}
|
|
809
|
-
//
|
|
756
|
+
// Every checkpoint ref the pack holds, derived or stated, compared as hex is. A ref
|
|
757
|
+
// outside it names a checkpoint the pack does not hold, which is a stamp missing, never a
|
|
758
|
+
// stamp proven wrong (section 5.1, as section 5.3.3 rule 1 reads a stamp).
|
|
759
|
+
const heldRefs = new Set([...byKey.keys()].map((k) => k.toLowerCase()));
|
|
760
|
+
for (const raw of body.checkpoints) {
|
|
761
|
+
const ref = isJsonObject(raw) ? raw.checkpoint_ref : undefined;
|
|
762
|
+
if (typeof ref === "string")
|
|
763
|
+
heldRefs.add(ref.toLowerCase());
|
|
764
|
+
}
|
|
765
|
+
// Step 4: per event.
|
|
810
766
|
const eventResults = [];
|
|
767
|
+
const agentItems = [];
|
|
811
768
|
for (const rawEv of body.events) {
|
|
812
|
-
//
|
|
813
|
-
// already uses: a null or non-object entry is graded as not an event,
|
|
814
|
-
// never raised on.
|
|
769
|
+
// A non-object entry is graded as not an event, never raised on.
|
|
815
770
|
if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
|
|
816
771
|
eventResults.push({
|
|
817
772
|
eventId: "<unknown>",
|
|
@@ -822,34 +777,23 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
822
777
|
contentOk: null,
|
|
823
778
|
ok: false,
|
|
824
779
|
errors: ["events[] entry is not an object"],
|
|
780
|
+
agent: null,
|
|
825
781
|
});
|
|
782
|
+
agentItems.push(null);
|
|
826
783
|
continue;
|
|
827
784
|
}
|
|
828
785
|
const ev = rawEv;
|
|
829
786
|
const evErrors = [];
|
|
830
787
|
const eventId = getEventId(ev.envelope);
|
|
831
788
|
let envelopeOk = false;
|
|
789
|
+
let envelopeFaults = [];
|
|
832
790
|
let payloadBindingOk = null;
|
|
833
|
-
// A retired field name
|
|
834
|
-
// unknown field: ignored, it reads as a withheld record (W-3).
|
|
791
|
+
// A retired field name is a reject: ignored, it would read as withheld.
|
|
835
792
|
const legacyError = legacyEventBytesError(ev, "event", bodyType);
|
|
836
793
|
if (legacyError !== null)
|
|
837
794
|
evErrors.push(legacyError);
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
// `event_bytes` that will not decode leaves `eventBytes` undefined, and an
|
|
841
|
-
// undefined `eventBytes` is exactly how a WITHHELD record is spelled one
|
|
842
|
-
// line down. So an event carrying garbage in place of its record used to
|
|
843
|
-
// reach the same tri-state as an event carrying nothing: `payloadHashOk`
|
|
844
|
-
// null, `ok` true, a leading tick on the line, and a `verified` rollup over
|
|
845
|
-
// a record nobody could read. The only thing wrong with the pack was the
|
|
846
|
-
// bytes, and it was the one thing the verdict did not say.
|
|
847
|
-
//
|
|
848
|
-
// A record the producer chose not to disclose is the customer exercising
|
|
849
|
-
// minimum disclosure. A record that is present and unreadable is a defect
|
|
850
|
-
// in the artifact, and this kernel's standing rule (the same one
|
|
851
|
-
// `checkpoints[]` already applies through `keyed.errors`) is that a defect
|
|
852
|
-
// we can see fails.
|
|
795
|
+
// Malformed is a failure; absent is undetermined. Undecodable `event_bytes`
|
|
796
|
+
// would otherwise look exactly like a withheld record and verify.
|
|
853
797
|
let eventBytes;
|
|
854
798
|
let eventBytesMalformed = false;
|
|
855
799
|
try {
|
|
@@ -860,24 +804,20 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
860
804
|
eventBytesMalformed = true;
|
|
861
805
|
evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
|
|
862
806
|
}
|
|
863
|
-
const
|
|
864
|
-
if (
|
|
807
|
+
const notJson = recordNotJson(eventBytes);
|
|
808
|
+
if (notJson !== undefined) {
|
|
865
809
|
eventBytesMalformed = true;
|
|
866
|
-
evErrors.push(
|
|
810
|
+
evErrors.push(notJson);
|
|
867
811
|
}
|
|
868
812
|
try {
|
|
869
813
|
const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
|
|
870
814
|
const hexError = envelopeHexError("event", ev.envelope);
|
|
871
|
-
//
|
|
815
|
+
// Tolerates an undetermined binding (withheld record).
|
|
872
816
|
envelopeOk = res.ok && hexError === null;
|
|
817
|
+
envelopeFaults = eventEnvelopeFaults(res.faults, hexError);
|
|
873
818
|
payloadBindingOk = res.payloadHashOk;
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
// binding was attempted. "signature failed" named a check that never ran,
|
|
877
|
-
// and it was the only thing said about an event whose undetermined
|
|
878
|
-
// binding then rendered as a WITHHELD record, a claim about the
|
|
879
|
-
// producer's disclosure choice out of an input that says nothing about
|
|
880
|
-
// the producer at all (proof#76).
|
|
819
|
+
// No envelope, so no signature was checked: report it as malformed, not as
|
|
820
|
+
// a failed signature or a withheld record.
|
|
881
821
|
if (!isJsonObject(ev.envelope)) {
|
|
882
822
|
evErrors.push("event envelope is not a JSON object");
|
|
883
823
|
}
|
|
@@ -889,11 +829,12 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
889
829
|
}
|
|
890
830
|
if (res.payloadHashOk === false)
|
|
891
831
|
evErrors.push("event record does not bind to payload_hash");
|
|
892
|
-
//
|
|
893
|
-
// and binding (proof#95), so without this the event fails with no reason.
|
|
832
|
+
// Name a malformed skeleton field, which fails an otherwise valid envelope.
|
|
894
833
|
for (const m of res.errors)
|
|
895
834
|
if (m.startsWith("malformed "))
|
|
896
835
|
evErrors.push(`event envelope ${m}`);
|
|
836
|
+
if (res.errors.some((m) => m.startsWith("missing payload_hash")))
|
|
837
|
+
evErrors.push("event envelope missing payload_hash");
|
|
897
838
|
const specFinding = specVersionFinding("event", ev.envelope, res);
|
|
898
839
|
if (specFinding !== null)
|
|
899
840
|
evErrors.push(specFinding);
|
|
@@ -901,18 +842,18 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
901
842
|
catch (e) {
|
|
902
843
|
evErrors.push(`event envelope verify error${thrownDetail(e)}`);
|
|
903
844
|
}
|
|
904
|
-
// Bind the event to exactly one checkpoint
|
|
905
|
-
//
|
|
845
|
+
// Bind the event to exactly one checkpoint, then verify RFC 9162 inclusion
|
|
846
|
+
// against that checkpoint's merkle_root.
|
|
906
847
|
const incl = ev.inclusion;
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
// checkpoint nobody meant.
|
|
848
|
+
// The retired `checkpoint_tx_id` spelling marks a mixed bundle; resolving
|
|
849
|
+
// it could bind the event to an unintended checkpoint.
|
|
910
850
|
const keyField = "checkpoint_ref";
|
|
911
851
|
const wrongField = "checkpoint_tx_id";
|
|
912
852
|
const inclAsRecord = incl;
|
|
913
853
|
const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
|
|
914
854
|
const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
|
|
915
855
|
let cp;
|
|
856
|
+
let stampAbsent = false;
|
|
916
857
|
if (!incl || typeof incl !== "object") {
|
|
917
858
|
evErrors.push("event missing inclusion proof");
|
|
918
859
|
}
|
|
@@ -924,28 +865,24 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
924
865
|
}
|
|
925
866
|
else {
|
|
926
867
|
cp = byKey.get(rawKey);
|
|
927
|
-
if (cp === undefined) {
|
|
868
|
+
if (cp === undefined && !heldRefs.has(rawKey.toLowerCase())) {
|
|
869
|
+
// The pack does not hold the checkpoint the record names: `witnessed` is not
|
|
870
|
+
// proven (`missing`).
|
|
871
|
+
stampAbsent = true;
|
|
872
|
+
evErrors.push(`event inclusion.${keyField} ${JSON.stringify(rawKey)} names a checkpoint this pack does not hold`);
|
|
873
|
+
}
|
|
874
|
+
else if (cp === undefined) {
|
|
928
875
|
evErrors.push(`event inclusion.${keyField} ${JSON.stringify(rawKey)} resolves to no checkpoint`);
|
|
929
876
|
}
|
|
930
877
|
}
|
|
931
878
|
const checkpointBound = cp !== undefined;
|
|
932
879
|
let inclusionOk = false;
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
// bundle cannot verify. One reader of the signed count, shared with the
|
|
940
|
-
// completeness notes, so the verdict and the grade never read two
|
|
941
|
-
// different numbers.
|
|
942
|
-
//
|
|
943
|
-
// Every number here is read BY VALUE (`Number.isSafeInteger`), the model
|
|
944
|
-
// JCS signs, and the Python kernel reads the same way (`_safe_integer`):
|
|
945
|
-
// JCS writes `3.0` as `3`, so a stated `3.0` is the integer 3 under the
|
|
946
|
-
// same wrapper signature. A value that is not a safe integer is malformed,
|
|
947
|
-
// with one sentence that prints no value, because the two kernels cannot
|
|
948
|
-
// print `2.5`, `1e400` or `9007199254740993` the same way.
|
|
880
|
+
// The tree walked must be the tree the checkpoint record signed (§5.1): an
|
|
881
|
+
// RFC 9162 audit path is valid at more than one (leaf_index, leaf_count),
|
|
882
|
+
// so a proof at (2, 3) would also verify as (1, 2). An unreadable signed
|
|
883
|
+
// count already failed its checkpoint. Numbers are read by value, as JCS
|
|
884
|
+
// signs them; a non-safe-integer is malformed, in a sentence that prints no
|
|
885
|
+
// value because the kernels cannot print it alike.
|
|
949
886
|
const signedCount = cp ? signedLeafCount(cp) : null;
|
|
950
887
|
if (incl && checkpointBound && cp) {
|
|
951
888
|
const statedCount = safeInteger(incl.leaf_count);
|
|
@@ -963,18 +900,13 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
963
900
|
else {
|
|
964
901
|
try {
|
|
965
902
|
inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), statedIndex, statedCount, (incl.audit_path ?? []).map((h) => hexToBytes(h)),
|
|
966
|
-
// No `?? ""` fallback
|
|
967
|
-
// handed an invented
|
|
968
|
-
// value and reports the decode failure, so substituting "" here had
|
|
969
|
-
// the two kernels say different things about one bundle (TS: "the
|
|
970
|
-
// proof did not reconstruct the checkpoint root", which is not what
|
|
971
|
-
// happened: there was no claimed root to reconstruct).
|
|
903
|
+
// No `?? ""` fallback: a checkpoint claiming no root must not be
|
|
904
|
+
// handed an invented one.
|
|
972
905
|
hexToBytes(cp.merkle_root));
|
|
973
906
|
if (!inclusionOk)
|
|
974
907
|
evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
|
|
975
|
-
//
|
|
976
|
-
//
|
|
977
|
-
// inclusion proof for an UNRELATED leaf could be smuggled in.
|
|
908
|
+
// The claimed leaf_hash must hash the event's own signed envelope
|
|
909
|
+
// (profile §6), or a proof for an unrelated leaf could be smuggled in.
|
|
978
910
|
const expectedLeaf = await leafHash(utf8(jcs(ev.envelope)));
|
|
979
911
|
const expectedLeafHex = toHex(expectedLeaf);
|
|
980
912
|
if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
|
|
@@ -987,10 +919,10 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
987
919
|
}
|
|
988
920
|
}
|
|
989
921
|
}
|
|
990
|
-
// Content (raw-log) binding — shared with every other event-carrying body.
|
|
991
922
|
const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
|
|
992
923
|
const contentOk = contentRes.contentOk;
|
|
993
924
|
evErrors.push(...contentRes.errors);
|
|
925
|
+
agentItems.push(agentItem(ev, eventBytes, envelopeOk && payloadBindingOk === true && !eventBytesMalformed));
|
|
994
926
|
eventResults.push({
|
|
995
927
|
eventId,
|
|
996
928
|
envelopeOk,
|
|
@@ -998,13 +930,9 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
998
930
|
inclusionOk,
|
|
999
931
|
checkpointBound,
|
|
1000
932
|
contentOk,
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
// withheld record or undisclosed content (binding/content null) does not
|
|
1005
|
-
// fail it.
|
|
1006
|
-
// `!eventBytesMalformed`: present-and-unreadable record bytes are a
|
|
1007
|
-
// defect in the artifact, never a disclosure choice (proof#85).
|
|
933
|
+
// Ok when the envelope is authentic, inclusion reconstructs the root, the
|
|
934
|
+
// checkpoint is present, and disclosed content did not mismatch. Withheld
|
|
935
|
+
// or undisclosed content does not fail it; unreadable record bytes do.
|
|
1008
936
|
ok: envelopeOk &&
|
|
1009
937
|
inclusionOk &&
|
|
1010
938
|
checkpointBound &&
|
|
@@ -1012,15 +940,22 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
1012
940
|
legacyError === null &&
|
|
1013
941
|
!eventBytesMalformed,
|
|
1014
942
|
errors: evErrors,
|
|
943
|
+
agent: null,
|
|
944
|
+
envelopeFault: envelopeFaults[0] ?? null,
|
|
945
|
+
envelopeFaults,
|
|
946
|
+
recordNotJson: notJson !== undefined && envelopeOk && payloadBindingOk === true,
|
|
947
|
+
contentUnsigned: contentOk === false && contentRes.unsigned,
|
|
948
|
+
stampAbsent,
|
|
949
|
+
ownFormFailed: legacyError !== null || eventBytesMalformed,
|
|
1015
950
|
});
|
|
1016
951
|
}
|
|
1017
|
-
|
|
952
|
+
await attachAgentReadings(eventResults, agentItems);
|
|
953
|
+
// Step 5: optional on-chain re-fetch.
|
|
1018
954
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
1019
955
|
let gatewayUnavailable = false;
|
|
1020
956
|
let uncheckedKind = false;
|
|
1021
957
|
let uncheckedId = false;
|
|
1022
|
-
// Simulated witnesses against
|
|
1023
|
-
// checkpoints that name one, as `refetchStamps` counts stamps.
|
|
958
|
+
// Simulated witnesses against any other kind, as `refetchStamps` counts.
|
|
1024
959
|
let simulated = 0;
|
|
1025
960
|
let onChainKinds = 0;
|
|
1026
961
|
if (onChainChecked) {
|
|
@@ -1033,21 +968,11 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
1033
968
|
const cp = byKey.get(cpr.checkpointRef);
|
|
1034
969
|
if (!cp)
|
|
1035
970
|
continue;
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1040
|
-
//
|
|
1041
|
-
// accusation this whole change is removing.
|
|
1042
|
-
// proof#120. An `anchor_ref` that failed structural validation is not
|
|
1043
|
-
// an absent one. Nothing was fetched, so the witness dimension is NOT
|
|
1044
|
-
// CHECKED — `onChain: null`, exactly as when the caller supplied no
|
|
1045
|
-
// adapter — and it must not raise `gatewayUnavailable`, which is what
|
|
1046
|
-
// puts "could not be re-fetched from the supplied gateways" on the
|
|
1047
|
-
// bundle. Reporting an outage nobody observed is a claim about the
|
|
1048
|
-
// public record made out of a defect in the artifact, and verify-page
|
|
1049
|
-
// renders it word for word. The run still fails: the structural error
|
|
1050
|
-
// is already in `cpr.errors` and already in `ok` above.
|
|
971
|
+
// No `anchor_ref`: nothing to re-fetch. Absent (placed, not yet
|
|
972
|
+
// witnessed) reports as unreachable, downgrading the run to partial and
|
|
973
|
+
// never to a mismatch. Malformed reports as not checked (`onChain:
|
|
974
|
+
// null`) and must not raise `gatewayUnavailable`: no outage was
|
|
975
|
+
// observed, and the structural error already fails the run.
|
|
1051
976
|
if (cpr.anchorRef === null && malformedAnchorRef.has(cpr)) {
|
|
1052
977
|
cpr.onChain = null;
|
|
1053
978
|
cpr.onChainOk = null;
|
|
@@ -1079,55 +1004,47 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
1079
1004
|
uncheckedId = true;
|
|
1080
1005
|
if (outcome.error)
|
|
1081
1006
|
cpr.errors.push(outcome.error);
|
|
1082
|
-
//
|
|
1083
|
-
//
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
1086
|
-
// failure. A match (true) keeps it ok.
|
|
1087
|
-
// AND into the verdict already computed, never rebuild it from a
|
|
1088
|
-
// hand-listed set of dimensions. Rebuilding silently dropped every
|
|
1089
|
-
// term not in that list: the duplicate-key gate (proof#28) and the
|
|
1090
|
-
// `keyed.errors` term with it, so supplying a gateway turned a hard
|
|
1091
|
-
// reject back into `verified` and an UNREACHABLE gateway turned it
|
|
1092
|
-
// into `partial` (exit 3), which reads as a network problem an
|
|
1093
|
-
// operator retries. Neither is conditioned on a gateway by the spec:
|
|
1094
|
-
// `evidence-bundle.md` says two checkpoints carrying one key is a
|
|
1095
|
-
// hard reject, full stop. Written this way, a term added at
|
|
1096
|
-
// construction survives here by construction.
|
|
1007
|
+
// A mismatch fails the checkpoint; unreachable leaves it ok and
|
|
1008
|
+
// downgrades the run to partial. AND into the verdict already computed,
|
|
1009
|
+
// never rebuild it, so every construction-time term (the duplicate-key
|
|
1010
|
+
// gate included) survives.
|
|
1097
1011
|
cpr.ok = cpr.ok && cpr.onChainOk !== false;
|
|
1098
1012
|
}
|
|
1099
1013
|
}
|
|
1100
1014
|
}
|
|
1101
|
-
//
|
|
1015
|
+
// Rollup.
|
|
1102
1016
|
const allCheckpointsOk = checkpointResults.length > 0 && checkpointResults.every((c) => c.ok);
|
|
1103
1017
|
const allEventsOk = eventResults.every((e) => e.ok);
|
|
1104
|
-
const anyHardFailure = checkpointResults.some((c) => !c.ok) || eventResults.some((e) => !e.ok);
|
|
1018
|
+
const anyHardFailure = checkpointResults.some((c) => !c.ok) || eventResults.some((e) => !e.ok && !unproven(e));
|
|
1105
1019
|
const anyWithheldRecord = eventResults.some((e) => e.payloadBindingOk === null);
|
|
1020
|
+
const anyStampAbsent = eventResults.some((e) => e.stampAbsent === true);
|
|
1021
|
+
// A lookup that did not settle is the witness line's to say, never the status's: the
|
|
1022
|
+
// witness is proven offline by the checkpoint and its inclusion proof, and only a lookup
|
|
1023
|
+
// that finds other bytes (a hard failure above) moves anything.
|
|
1024
|
+
pushRefetchGapNotes(errors, "checkpoints", {
|
|
1025
|
+
unavailable: gatewayUnavailable,
|
|
1026
|
+
kind: uncheckedKind,
|
|
1027
|
+
id: uncheckedId,
|
|
1028
|
+
});
|
|
1029
|
+
// A checkpoint nobody has witnessed yet (no anchor_ref) is placed, not witnessed: the
|
|
1030
|
+
// `witnessed` claim is not proven (evidence-bundle.md section 3), as for a stamp.
|
|
1031
|
+
const anyUnwitnessed = checkpointResults.some((c) => c.anchorRef === null);
|
|
1106
1032
|
let status;
|
|
1107
1033
|
if (anyHardFailure) {
|
|
1108
1034
|
status = "failed";
|
|
1109
1035
|
}
|
|
1110
|
-
else if (
|
|
1111
|
-
|
|
1112
|
-
// on-chain confirmation could not be made — the bundle is offline-sound,
|
|
1113
|
-
// its anchored state unconfirmed. "partial" + a note the CLI maps to exit 3.
|
|
1114
|
-
status = "partial";
|
|
1115
|
-
pushRefetchGapNotes(errors, "checkpoints", {
|
|
1116
|
-
unavailable: gatewayUnavailable,
|
|
1117
|
-
kind: uncheckedKind,
|
|
1118
|
-
id: uncheckedId,
|
|
1119
|
-
});
|
|
1036
|
+
else if (anyUnwitnessed || anyStampAbsent || eventResults.some(unproven)) {
|
|
1037
|
+
status = "incomplete";
|
|
1120
1038
|
}
|
|
1121
1039
|
else if (anyWithheldRecord) {
|
|
1122
|
-
//
|
|
1123
|
-
|
|
1124
|
-
status = "partial";
|
|
1040
|
+
// Some record bytes were withheld, so their binding is not checked.
|
|
1041
|
+
status = "incomplete";
|
|
1125
1042
|
}
|
|
1126
1043
|
else if (allCheckpointsOk && allEventsOk) {
|
|
1127
1044
|
status = "verified";
|
|
1128
1045
|
}
|
|
1129
1046
|
else {
|
|
1130
|
-
status = "
|
|
1047
|
+
status = "incomplete";
|
|
1131
1048
|
}
|
|
1132
1049
|
return {
|
|
1133
1050
|
status,
|
|
@@ -1135,60 +1052,20 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
|
|
|
1135
1052
|
bodyHashOk: true,
|
|
1136
1053
|
signatureOk: true,
|
|
1137
1054
|
bodyType,
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
// `refetchStamps`). The checkpoints stay not checked and the run partial.
|
|
1055
|
+
// Simulated-only witnesses looked nothing up on a chain, so the run is not
|
|
1056
|
+
// counted as checked on one.
|
|
1141
1057
|
onChainChecked: onChainChecked && !(simulated > 0 && onChainKinds === 0),
|
|
1142
1058
|
checkpoints: checkpointResults,
|
|
1143
1059
|
events: eventResults,
|
|
1144
|
-
assertedStatus,
|
|
1145
1060
|
errors,
|
|
1146
1061
|
};
|
|
1147
1062
|
}
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
// ── What this can prove, and what it deliberately does not claim ──
|
|
1152
|
-
//
|
|
1153
|
-
// A stamp is not a producer artifact. Under DL-52 the customer signs an
|
|
1154
|
-
// envelope and POSTs it; the commitment is witnessed later, on an interval, by
|
|
1155
|
-
// a stamp the customer never sees and holds no key for. So there is no
|
|
1156
|
-
// producer signature inside this body to check, and — this is the part worth
|
|
1157
|
-
// being precise about — there is no SECOND Sanning signature either. The
|
|
1158
|
-
// control plane stores the stamp's canonical bytes and hands them to a witness
|
|
1159
|
-
// as a signed chain data item; the signature lives on the chain, not in the
|
|
1160
|
-
// `stamps` table and not in this body.
|
|
1161
|
-
//
|
|
1162
|
-
// What "Sanning signed this" means for a stamp bundle is therefore exactly the
|
|
1163
|
-
// WRAPPER signature, verified generically before this function is reached. And
|
|
1164
|
-
// what makes the stamp evidence rather than an assertion is the witness, which
|
|
1165
|
-
// is on a chain and not checkable offline. Hence the rollup below: a stamp we
|
|
1166
|
-
// can read but nobody has witnessed holds the bundle at `partial`.
|
|
1167
|
-
//
|
|
1168
|
-
// So the checkable surface here is the stamp's INTERNAL consistency against
|
|
1169
|
-
// the one authority in the bundle — the witnessed bytes:
|
|
1170
|
-
//
|
|
1171
|
-
// 1. `stamp_ref` is SHA-256 of the DECODED bytes (derived, not asserted).
|
|
1172
|
-
// 2. Those bytes parse and re-canonicalize to themselves under JCS.
|
|
1173
|
-
// 3. Their `stamp_version` is the one this body_type pins.
|
|
1174
|
-
// 4. Every sub-tree range accounts for exactly one leaf of the stamp.
|
|
1175
|
-
//
|
|
1176
|
-
// (4) is the one that carries customer weight. R-8's whole per-agent story is
|
|
1177
|
-
// "prove this agent was under evidence over window W", answered by a
|
|
1178
|
-
// contiguous `first_leaf`/`leaf_count` slice of the org tree. Ranges that do
|
|
1179
|
-
// not tile `[0, leaf_count)` describe a partition that does not exist, so a
|
|
1180
|
-
// slice claim resting on them is unfounded — and unlike a bad signature,
|
|
1181
|
-
// nothing else in the stack would ever notice.
|
|
1182
|
-
// ===========================================================================
|
|
1183
|
-
// A JSON object, as opposed to null, an array, a string or a number. Used at
|
|
1184
|
-
// every point where a nested field MUST be an object before any check of it
|
|
1185
|
-
// means anything: probing a verifier with a non-object asks a question about
|
|
1186
|
-
// something that was never the thing, and the answer must say so rather than
|
|
1187
|
-
// name a check that never ran (proof#76).
|
|
1063
|
+
// A JSON object, as opposed to null, an array, a string or a number. A nested
|
|
1064
|
+
// field must be one before any check of it means anything.
|
|
1188
1065
|
function isJsonObject(v) {
|
|
1189
1066
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1190
1067
|
}
|
|
1191
|
-
/** Lowercase hex, even length, non-empty.
|
|
1068
|
+
/** Lowercase hex, even length, non-empty. */
|
|
1192
1069
|
function isLowerHex(s, nibbles) {
|
|
1193
1070
|
if (typeof s !== "string" || s.length === 0 || s.length % 2 !== 0)
|
|
1194
1071
|
return false;
|
|
@@ -1197,54 +1074,29 @@ function isLowerHex(s, nibbles) {
|
|
|
1197
1074
|
return /^[0-9a-f]+$/.test(s);
|
|
1198
1075
|
}
|
|
1199
1076
|
/**
|
|
1200
|
-
* A JSON number read
|
|
1201
|
-
* `
|
|
1202
|
-
* goes through (A38), mirrored by `_non_neg_integer` in
|
|
1203
|
-
* `src/sanning_proof/evidence.py`. `Number.isInteger` alone accepted `1e+21`,
|
|
1204
|
-
* JCS's own spelling of a count past 2**53 - 1, which Python refused.
|
|
1077
|
+
* A JSON number read by value as a non-negative safe integer, or null. Matches
|
|
1078
|
+
* Python's `_non_neg_integer`; `Number.isInteger` alone would accept `1e+21`.
|
|
1205
1079
|
*/
|
|
1206
1080
|
function nonNegInteger(n) {
|
|
1207
1081
|
const v = safeInteger(n);
|
|
1208
1082
|
return v !== null && v >= 0 ? v : null;
|
|
1209
1083
|
}
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
* meaningful, so a bundle with two independent defects reports two — a
|
|
1215
|
-
* verifier that stops at the first one turns a second fix into a second
|
|
1216
|
-
* round-trip.
|
|
1217
|
-
*/
|
|
1218
|
-
// ===========================================================================
|
|
1219
|
-
// Disclosed-content binding, shared by every body that carries events.
|
|
1220
|
-
//
|
|
1221
|
-
// Extracted for `sanning.stamp.evidence/v2` (§5.3) rather than copied. The
|
|
1222
|
-
// rawLog -> content_hash link is the one step the chain
|
|
1223
|
-
// (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
|
|
1224
|
-
// not otherwise cover, and a second copy of it is a second place for the
|
|
1225
|
-
// never-fail-on-absent discipline to drift. The rules are unchanged from the
|
|
1226
|
-
// anchor-trace path this came from: undetermined (null) when nothing is
|
|
1227
|
-
// disclosed or nothing is committed; false ONLY on a real mismatch or an
|
|
1228
|
-
// in-body vs side-input disagreement.
|
|
1229
|
-
// ===========================================================================
|
|
1084
|
+
// Disclosed-content binding, shared by every body that carries events. The
|
|
1085
|
+
// raw log to `content_hash` link is the one step the signature chain does not
|
|
1086
|
+
// cover. Undetermined (null) when nothing is disclosed or committed; false only
|
|
1087
|
+
// on a real mismatch or an in-body vs side-input disagreement.
|
|
1230
1088
|
async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
|
|
1231
1089
|
const evErrors = [];
|
|
1232
1090
|
let contentOk = null;
|
|
1233
|
-
//
|
|
1234
|
-
//
|
|
1235
|
-
|
|
1236
|
-
//
|
|
1237
|
-
//
|
|
1238
|
-
//
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
//
|
|
1242
|
-
// (`isConformantHandoffRecord`) and the value is 64 characters of lowercase
|
|
1243
|
-
// hex. Anything short of that commits nothing this reader will bind to, so
|
|
1244
|
-
// the reading is undetermined and never true: a record dressed as a hand-off
|
|
1245
|
-
// record gains no binding from the dress, and a record outside §4.1 keeps its
|
|
1246
|
-
// `content_hash` exactly as before, whatever `message_hash` field it writes.
|
|
1247
|
-
// The Python twin is `evidence._evaluate_content`.
|
|
1091
|
+
// Whether a `false` rests on side bytes nobody signed: the side input checked alone, or
|
|
1092
|
+
// side bytes that differ from in-body bytes that do not fail on their own.
|
|
1093
|
+
let unsigned = false;
|
|
1094
|
+
// Which hash binds depends on the record's structure (§5.1). A record §4.1
|
|
1095
|
+
// governs commits through `event.message_hash` (SHA-256 of the message's
|
|
1096
|
+
// JCS), and binds only when it is a conformant hand-off record and the value
|
|
1097
|
+
// is 64 lowercase hex characters; otherwise the reading is undetermined, never
|
|
1098
|
+
// true. Any other record binds through `content_hash`. Python's twin is
|
|
1099
|
+
// `_evaluate_content`.
|
|
1248
1100
|
const committed = [];
|
|
1249
1101
|
let recordContentHash;
|
|
1250
1102
|
let governed = false;
|
|
@@ -1277,8 +1129,8 @@ async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
|
|
|
1277
1129
|
}
|
|
1278
1130
|
}
|
|
1279
1131
|
catch {
|
|
1280
|
-
//
|
|
1281
|
-
//
|
|
1132
|
+
// Record not JSON: the payload binding reflects that; content stays
|
|
1133
|
+
// undetermined.
|
|
1282
1134
|
}
|
|
1283
1135
|
}
|
|
1284
1136
|
if (governed) {
|
|
@@ -1289,9 +1141,8 @@ async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
|
|
|
1289
1141
|
committed.push(recordContentHash);
|
|
1290
1142
|
}
|
|
1291
1143
|
else if (ev.envelope && typeof ev.envelope === "object") {
|
|
1292
|
-
// Promoted fallback
|
|
1293
|
-
//
|
|
1294
|
-
// on adversarial input.
|
|
1144
|
+
// Promoted fallback, guarded: `contentHashes` would throw on a non-object
|
|
1145
|
+
// envelope.
|
|
1295
1146
|
for (const c of contentHashes(ev.envelope))
|
|
1296
1147
|
committed.push(c.hash.toLowerCase());
|
|
1297
1148
|
}
|
|
@@ -1301,6 +1152,8 @@ async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
|
|
|
1301
1152
|
if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
|
|
1302
1153
|
contentOk = false;
|
|
1303
1154
|
evErrors.push("disclosed content disagreement: in-body events[].content and the supplied content differ");
|
|
1155
|
+
// Unsigned unless the in-body bytes, which the wrapper signs, fail on their own.
|
|
1156
|
+
unsigned = committed.length === 0 || committed.includes(await sha256Hex(inBody));
|
|
1304
1157
|
}
|
|
1305
1158
|
else {
|
|
1306
1159
|
const disclosed = inBody ?? side;
|
|
@@ -1310,14 +1163,15 @@ async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
|
|
|
1310
1163
|
evErrors.push(governed
|
|
1311
1164
|
? "disclosed content present but this hand-off record commits no " +
|
|
1312
1165
|
"message_hash a reader can bind to (not a conformant hand-off " +
|
|
1313
|
-
"record, or message_hash is not 64 lowercase hex) --
|
|
1166
|
+
"record, or message_hash is not 64 lowercase hex) -- not checked"
|
|
1314
1167
|
: "disclosed content present but no committed content_hash to bind to " +
|
|
1315
|
-
"(record withheld or record has no event.content_hash) --
|
|
1168
|
+
"(record withheld or record has no event.content_hash) -- not checked");
|
|
1316
1169
|
}
|
|
1317
1170
|
}
|
|
1318
1171
|
else {
|
|
1319
1172
|
const got = await sha256Hex(disclosed);
|
|
1320
1173
|
contentOk = committed.includes(got);
|
|
1174
|
+
unsigned = !contentOk && inBody === undefined;
|
|
1321
1175
|
if (!contentOk) {
|
|
1322
1176
|
const fieldName = governed ? "message_hash" : "content_hash";
|
|
1323
1177
|
evErrors.push(`disclosed content does not match the committed ${fieldName}: ` +
|
|
@@ -1325,8 +1179,12 @@ async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
|
|
|
1325
1179
|
}
|
|
1326
1180
|
}
|
|
1327
1181
|
}
|
|
1328
|
-
return { contentOk, errors: evErrors };
|
|
1182
|
+
return { contentOk, unsigned, errors: evErrors };
|
|
1329
1183
|
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Parse and check one stamp's witnessed bytes. Errors accumulate wherever a
|
|
1186
|
+
* later check still means something, so independent defects are all reported.
|
|
1187
|
+
*/
|
|
1330
1188
|
async function verifyStampEntry(raw, seen) {
|
|
1331
1189
|
const errors = [];
|
|
1332
1190
|
const base = {
|
|
@@ -1351,7 +1209,7 @@ async function verifyStampEntry(raw, seen) {
|
|
|
1351
1209
|
return { result: base };
|
|
1352
1210
|
}
|
|
1353
1211
|
const e = raw;
|
|
1354
|
-
//
|
|
1212
|
+
// The witnessed bytes are the only authority in this body.
|
|
1355
1213
|
if (!isLowerHex(e.stamp_bytes)) {
|
|
1356
1214
|
errors.push("stamp_bytes is missing or not lowercase hex");
|
|
1357
1215
|
return { result: base };
|
|
@@ -1364,19 +1222,14 @@ async function verifyStampEntry(raw, seen) {
|
|
|
1364
1222
|
errors.push(`stamp_bytes is not hex${thrownDetail(err)}`);
|
|
1365
1223
|
return { result: base };
|
|
1366
1224
|
}
|
|
1367
|
-
// Gated by `isLowerHex` above
|
|
1368
|
-
// the exact witnessed bytes — §5.2.2's "one authority in this body".
|
|
1225
|
+
// Gated by `isLowerHex` above: already the exact witnessed bytes' hex (§5.2.2).
|
|
1369
1226
|
const witnessedHex = e.stamp_bytes;
|
|
1370
|
-
// --- the derived identity -------------------------------------------------
|
|
1371
1227
|
const derived = await sha256Hex(bytes);
|
|
1372
1228
|
if (typeof e.stamp_ref !== "string" || e.stamp_ref === "") {
|
|
1373
1229
|
errors.push("stamp entry missing stamp_ref");
|
|
1374
1230
|
}
|
|
1375
1231
|
else if (e.stamp_ref.toLowerCase() !== derived) {
|
|
1376
|
-
//
|
|
1377
|
-
// and the bytes were not. Either way the bundle names a stamp it is not
|
|
1378
|
-
// carrying, and quoting that name to a witness would resolve to something
|
|
1379
|
-
// else entirely.
|
|
1232
|
+
// Bytes and label disagree: the bundle names a stamp it is not carrying.
|
|
1380
1233
|
errors.push(`stamp_ref does not match SHA-256(stamp_bytes): claimed=${e.stamp_ref} derived=${derived}`);
|
|
1381
1234
|
}
|
|
1382
1235
|
else {
|
|
@@ -1390,7 +1243,6 @@ async function verifyStampEntry(raw, seen) {
|
|
|
1390
1243
|
seen.add(derived);
|
|
1391
1244
|
}
|
|
1392
1245
|
}
|
|
1393
|
-
// --- the body itself ------------------------------------------------------
|
|
1394
1246
|
let body;
|
|
1395
1247
|
try {
|
|
1396
1248
|
const parsed = jcsParse(bytes);
|
|
@@ -1399,10 +1251,8 @@ async function verifyStampEntry(raw, seen) {
|
|
|
1399
1251
|
return { result: base, witnessedHex };
|
|
1400
1252
|
}
|
|
1401
1253
|
body = parsed;
|
|
1402
|
-
//
|
|
1403
|
-
//
|
|
1404
|
-
// a retry a DIFFERENT submission, and makes every independent re-derivation
|
|
1405
|
-
// of the stamp disagree with the one that was witnessed.
|
|
1254
|
+
// Must re-canonicalize to itself: a non-canonical body makes every
|
|
1255
|
+
// independent re-derivation disagree with what was witnessed.
|
|
1406
1256
|
if (jcs(body) !== decodeUtf8Strict(bytes)) {
|
|
1407
1257
|
errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
|
|
1408
1258
|
return { result: base, witnessedHex };
|
|
@@ -1413,10 +1263,8 @@ async function verifyStampEntry(raw, seen) {
|
|
|
1413
1263
|
return { result: base, witnessedHex };
|
|
1414
1264
|
}
|
|
1415
1265
|
if (body.stamp_version !== STAMP_BODY_VERSION) {
|
|
1416
|
-
//
|
|
1417
|
-
//
|
|
1418
|
-
// reader's dispatch key and it said v1; honouring the other spelling is
|
|
1419
|
-
// how a reader half-parses a shape it does not know.
|
|
1266
|
+
// The inner version must match the body type: two answers inside one
|
|
1267
|
+
// signature are rejected.
|
|
1420
1268
|
errors.push(`unsupported stamp_version ${JSON.stringify(body.stamp_version ?? null)} in a ` +
|
|
1421
1269
|
`${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`);
|
|
1422
1270
|
return { result: base, witnessedHex };
|
|
@@ -1429,7 +1277,7 @@ async function verifyStampEntry(raw, seen) {
|
|
|
1429
1277
|
base.bodyOk = false;
|
|
1430
1278
|
}
|
|
1431
1279
|
else if (f !== "org_ref" && !isRfc3339UtcInstant(body[f])) {
|
|
1432
|
-
//
|
|
1280
|
+
// The promise window uses the form `signed_at` pins (§5.2).
|
|
1433
1281
|
errors.push(`stamp body ${f} is not an RFC 3339 UTC date-time with Z naming a real date and time`);
|
|
1434
1282
|
base.bodyOk = false;
|
|
1435
1283
|
}
|
|
@@ -1451,27 +1299,23 @@ async function verifyStampEntry(raw, seen) {
|
|
|
1451
1299
|
else {
|
|
1452
1300
|
base.leafCount = stampCount;
|
|
1453
1301
|
}
|
|
1454
|
-
// --- the sub-tree ranges --------------------------------------------------
|
|
1455
1302
|
base.subtreesOk = checkSubtrees(body, base, errors);
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
|
|
1459
|
-
|
|
1303
|
+
// No malformed/absent split is needed: the re-fetch leaves `onChain` null
|
|
1304
|
+
// whenever there is no `AnchorRef`.
|
|
1305
|
+
const clean = errors.length === 0;
|
|
1306
|
+
const parsedRef = parseAnchorRef(e.anchor_ref, errors);
|
|
1307
|
+
base.anchorRef = parsedRef.ref;
|
|
1460
1308
|
base.ok =
|
|
1461
1309
|
base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
|
|
1310
|
+
base.anchorRefMalformed =
|
|
1311
|
+
parsedRef.malformed && clean && base.stampRefOk && base.bodyOk && base.subtreesOk;
|
|
1462
1312
|
return { result: base, witnessedHex };
|
|
1463
1313
|
}
|
|
1464
1314
|
/**
|
|
1465
|
-
* The range arithmetic (§5.2)
|
|
1466
|
-
*
|
|
1467
|
-
*
|
|
1468
|
-
*
|
|
1469
|
-
* ranges partition `[0, leaf_count)` exactly, by construction. A body whose
|
|
1470
|
-
* ranges do not is either not a stamp this stack built or one built wrong, and
|
|
1471
|
-
* in both cases the per-agent claim the ranges exist to support is unfounded.
|
|
1472
|
-
*
|
|
1473
|
-
* The sum is checked BEFORE the tiling and reports its own message, so a body
|
|
1474
|
-
* that is merely short does not get filed under a gap it does not have.
|
|
1315
|
+
* The range arithmetic (§5.2). Leaves are agent-major, so the ranges must
|
|
1316
|
+
* partition `[0, leaf_count)` exactly, or the per-agent slice claims they
|
|
1317
|
+
* support are unfounded. The sum is checked before the tiling so a short body
|
|
1318
|
+
* is not reported as a gap.
|
|
1475
1319
|
*/
|
|
1476
1320
|
function checkSubtrees(body, out, errors) {
|
|
1477
1321
|
const raw = body.subtrees;
|
|
@@ -1494,9 +1338,8 @@ function checkSubtrees(body, out, errors) {
|
|
|
1494
1338
|
errors.push(`sub-tree ${ref} first_leaf is not a non-negative integer`);
|
|
1495
1339
|
shapeOk = false;
|
|
1496
1340
|
}
|
|
1497
|
-
// A zero-leaf
|
|
1498
|
-
//
|
|
1499
|
-
// up.
|
|
1341
|
+
// A zero-leaf range is a slice of nothing, and empty ranges could pad a
|
|
1342
|
+
// tiling that still adds up.
|
|
1500
1343
|
if (subCount === null || subCount === 0) {
|
|
1501
1344
|
errors.push(`sub-tree ${ref} leaf_count is not a positive integer`);
|
|
1502
1345
|
shapeOk = false;
|
|
@@ -1521,9 +1364,7 @@ function checkSubtrees(body, out, errors) {
|
|
|
1521
1364
|
const total = out.leafCount;
|
|
1522
1365
|
if (total === null)
|
|
1523
1366
|
return false; // already reported by the caller
|
|
1524
|
-
// Summed exactly
|
|
1525
|
-
// and a float sum past 2**53 prints a different number than Python's
|
|
1526
|
-
// unbounded one in the sentence below.
|
|
1367
|
+
// Summed exactly: the sum can pass 2**53 and must print as Python's does.
|
|
1527
1368
|
const sum = subtrees.reduce((n, s) => n + BigInt(s.leafCount), 0n);
|
|
1528
1369
|
if (sum !== BigInt(total)) {
|
|
1529
1370
|
errors.push(`sub-tree leaf_count sum ${sum} does not equal the stamp's leaf_count ${total}`);
|
|
@@ -1540,7 +1381,19 @@ function checkSubtrees(body, out, errors) {
|
|
|
1540
1381
|
}
|
|
1541
1382
|
return true;
|
|
1542
1383
|
}
|
|
1543
|
-
|
|
1384
|
+
// `sanning.stamp.trace/v1`: the Sanning-signed interval stamp (§5.2).
|
|
1385
|
+
//
|
|
1386
|
+
// A stamp has no producer signature and no second Sanning signature in the
|
|
1387
|
+
// bundle: "Sanning signed this" is the wrapper signature, and the witness on a
|
|
1388
|
+
// chain is what makes it evidence. So an unwitnessed stamp holds the bundle at
|
|
1389
|
+
// `partial`. Offline, this checks the stamp against its witnessed bytes:
|
|
1390
|
+
//
|
|
1391
|
+
// 1. `stamp_ref` is SHA-256 of the decoded bytes.
|
|
1392
|
+
// 2. The bytes parse and re-canonicalize to themselves under JCS.
|
|
1393
|
+
// 3. Their `stamp_version` is the one this body type pins.
|
|
1394
|
+
// 4. The sub-tree ranges tile the stamp's leaves exactly: per-agent slice
|
|
1395
|
+
// claims rest on that, and nothing else in the stack would notice.
|
|
1396
|
+
async function verifyStampTrace(b, specVersionOk, errors, options) {
|
|
1544
1397
|
const body = b.body;
|
|
1545
1398
|
if (!Array.isArray(body?.stamps)) {
|
|
1546
1399
|
return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true, true);
|
|
@@ -1553,46 +1406,34 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
|
|
|
1553
1406
|
stamps.push(outcome.result);
|
|
1554
1407
|
witnessed.push(outcome.witnessedHex);
|
|
1555
1408
|
}
|
|
1556
|
-
//
|
|
1557
|
-
//
|
|
1558
|
-
// The SAME stamps[] array §5.3 carries, so it gets the same capability. The
|
|
1559
|
-
// alternative — a stamp that can be re-fetched inside one body and not the
|
|
1560
|
-
// other — asks every consumer to remember which wrapper it is holding before
|
|
1561
|
-
// it can trust `onChain: null`, and that is exactly the kind of per-body
|
|
1562
|
-
// exception a reader eventually gets wrong.
|
|
1409
|
+
// The optional on-chain re-fetch (§5.2.4), shared with §5.3 so `onChain:
|
|
1410
|
+
// null` means the same thing in both bodies.
|
|
1563
1411
|
const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(stamps, witnessed, options, errors);
|
|
1564
|
-
//
|
|
1412
|
+
// Rollup.
|
|
1565
1413
|
const anyHardFailure = stamps.some((s) => !s.ok);
|
|
1566
1414
|
const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
|
|
1415
|
+
// A lookup that did not settle is said here and moves nothing (the witness line says it).
|
|
1416
|
+
pushRefetchGapNotes(errors, "stamps", {
|
|
1417
|
+
unavailable: gatewayUnavailable,
|
|
1418
|
+
kind: uncheckedKind,
|
|
1419
|
+
id: uncheckedId,
|
|
1420
|
+
});
|
|
1567
1421
|
let status;
|
|
1568
1422
|
if (anyHardFailure) {
|
|
1569
1423
|
status = "failed";
|
|
1570
1424
|
}
|
|
1571
1425
|
else if (stamps.length === 0) {
|
|
1572
|
-
//
|
|
1573
|
-
|
|
1574
|
-
// does not have.
|
|
1575
|
-
status = "partial";
|
|
1426
|
+
// Asserts nothing: not a failure, but never `verified`.
|
|
1427
|
+
status = "incomplete";
|
|
1576
1428
|
errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
|
|
1577
1429
|
}
|
|
1578
|
-
else if (
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
id: uncheckedId,
|
|
1586
|
-
});
|
|
1587
|
-
if (anyUnwitnessed) {
|
|
1588
|
-
// The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
|
|
1589
|
-
// sealed and unwitnessed: `retained-by-sanning`, which is a statement
|
|
1590
|
-
// about our coverage and never an accusation about their evidence. It is
|
|
1591
|
-
// also not `verified`, because for a stamp the witness is the entire
|
|
1592
|
-
// independent part — unlike an anchor-trace checkpoint, which still
|
|
1593
|
-
// carries a producer signature and an inclusion proof that stand on
|
|
1594
|
-
// their own offline.
|
|
1595
|
-
errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
|
|
1430
|
+
else if (anyUnwitnessed) {
|
|
1431
|
+
status = "incomplete";
|
|
1432
|
+
{
|
|
1433
|
+
// A stamp with no `anchor_ref` is sealed and unwitnessed: a statement
|
|
1434
|
+
// about Sanning's coverage, never an accusation. Not `verified` either:
|
|
1435
|
+
// for a stamp the witness is the whole independent part.
|
|
1436
|
+
errors.push("one or more stamps carries no anchor_ref: sealed by Sanning, witnessed by nobody");
|
|
1596
1437
|
}
|
|
1597
1438
|
}
|
|
1598
1439
|
else {
|
|
@@ -1607,32 +1448,21 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
|
|
|
1607
1448
|
onChainChecked,
|
|
1608
1449
|
checkpoints: [],
|
|
1609
1450
|
events: [],
|
|
1610
|
-
assertedStatus,
|
|
1611
1451
|
stamps,
|
|
1612
1452
|
errors,
|
|
1613
1453
|
};
|
|
1614
1454
|
}
|
|
1615
|
-
//
|
|
1616
|
-
//
|
|
1617
|
-
//
|
|
1618
|
-
//
|
|
1619
|
-
//
|
|
1620
|
-
|
|
1621
|
-
// `verifyDisclosedContent` — because a second copy of Merkle verification is a
|
|
1622
|
-
// second place for it to drift.
|
|
1623
|
-
//
|
|
1624
|
-
// What differs, and only this: the join. An event resolves `inclusion.stamp_ref`
|
|
1625
|
-
// to a stamps[] entry and walks its audit path to the root committed INSIDE
|
|
1626
|
-
// that stamp's witnessed bytes. There is no `merkle_root` field on the event to
|
|
1627
|
-
// read instead — repeating the root beside its own authoritative bytes would be
|
|
1628
|
-
// two answers to one question inside one signature (§5.1.2).
|
|
1629
|
-
// ===========================================================================
|
|
1630
|
-
async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options) {
|
|
1455
|
+
// `sanning.stamp.evidence/v2`: both halves, bound (evidence-bundle.md §5.3).
|
|
1456
|
+
// The stamp half is `verifyStampEntry`; the event half reuses the anchor-trace
|
|
1457
|
+
// primitives. Only the join differs: an event resolves `inclusion.stamp_ref`
|
|
1458
|
+
// and walks to the root inside that stamp's witnessed bytes. There is no
|
|
1459
|
+
// `merkle_root` on the event, which would be a second answer (§5.1.2).
|
|
1460
|
+
async function verifyStampEvidence(b, specVersionOk, errors, options) {
|
|
1631
1461
|
const body = b.body;
|
|
1632
1462
|
if (!Array.isArray(body?.stamps) || !Array.isArray(body?.events)) {
|
|
1633
1463
|
return malformed(`${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`, true, true);
|
|
1634
1464
|
}
|
|
1635
|
-
//
|
|
1465
|
+
// The stamp half.
|
|
1636
1466
|
const seen = new Set();
|
|
1637
1467
|
const stamps = [];
|
|
1638
1468
|
const witnessed = [];
|
|
@@ -1641,31 +1471,35 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1641
1471
|
stamps.push(outcome.result);
|
|
1642
1472
|
witnessed.push(outcome.witnessedHex);
|
|
1643
1473
|
}
|
|
1644
|
-
//
|
|
1645
|
-
//
|
|
1646
|
-
// verification is not a root anyone should walk a proof to, and admitting one
|
|
1647
|
-
// would let a broken stamp lend its authority to an event.
|
|
1474
|
+
// Only a stamp whose derived ref and witnessed bytes both checked out is
|
|
1475
|
+
// joinable: a failed stamp must not lend its root to an event.
|
|
1648
1476
|
const byRef = new Map();
|
|
1649
1477
|
for (const st of stamps) {
|
|
1650
1478
|
if (st.stampRef && st.stampRefOk && st.bodyOk)
|
|
1651
1479
|
byRef.set(st.stampRef, st);
|
|
1652
1480
|
}
|
|
1653
|
-
//
|
|
1481
|
+
// Every ref a stamp entry claims or derives. A ref outside it names a stamp the pack
|
|
1482
|
+
// does not hold, which is a stamp missing, never a stamp proven wrong (section 5.3.3
|
|
1483
|
+
// rule 1).
|
|
1484
|
+
const heldRefs = new Set();
|
|
1485
|
+
for (const st of stamps)
|
|
1486
|
+
if (st.stampRef)
|
|
1487
|
+
heldRefs.add(st.stampRef);
|
|
1488
|
+
for (const raw of body.stamps) {
|
|
1489
|
+
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
|
|
1490
|
+
const claimed = raw.stamp_ref;
|
|
1491
|
+
if (typeof claimed === "string")
|
|
1492
|
+
heldRefs.add(claimed);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
// The event half.
|
|
1654
1496
|
const eventResults = [];
|
|
1497
|
+
const agentItems = [];
|
|
1655
1498
|
for (const rawEv of body.events) {
|
|
1656
1499
|
const evErrors = [];
|
|
1657
|
-
//
|
|
1658
|
-
// stack trace instead of a verdict cannot tell a malformed pack from a
|
|
1659
|
-
// broken tool. `events[]` is attacker-shaped, so a null/non-object entry
|
|
1660
|
-
// is coerced to an empty event and fails through the normal path, exactly
|
|
1661
|
-
// as the Python kernel does. Found by adversarial review, not by the
|
|
1662
|
-
// cross-kernel leg — whose cases did not include one, and now do.
|
|
1500
|
+
// `events[]` is attacker-shaped; a verifier never raises on it.
|
|
1663
1501
|
if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
|
|
1664
|
-
//
|
|
1665
|
-
// empty one: probing verifyEnvelope with nothing asks the envelope layer
|
|
1666
|
-
// a question about an object that was never an envelope, and the two
|
|
1667
|
-
// kernels answer it differently (undetermined vs failed) — a real
|
|
1668
|
-
// divergence, but not this body's to resolve. Reported separately.
|
|
1502
|
+
// Graded as not an event, never coerced to an empty one.
|
|
1669
1503
|
eventResults.push({
|
|
1670
1504
|
eventId: "<unknown>",
|
|
1671
1505
|
envelopeOk: false,
|
|
@@ -1675,33 +1509,22 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1675
1509
|
contentOk: null,
|
|
1676
1510
|
ok: false,
|
|
1677
1511
|
errors: ["events[] entry is not an object"],
|
|
1512
|
+
agent: null,
|
|
1678
1513
|
});
|
|
1514
|
+
agentItems.push(null);
|
|
1679
1515
|
continue;
|
|
1680
1516
|
}
|
|
1681
1517
|
const ev = rawEv;
|
|
1682
1518
|
const eventId = getEventId(ev.envelope);
|
|
1683
1519
|
let envelopeOk = false;
|
|
1520
|
+
let envelopeFaults = [];
|
|
1684
1521
|
let payloadBindingOk = null;
|
|
1685
|
-
// A retired field name
|
|
1686
|
-
// unknown field: ignored, it reads as a withheld record (W-3).
|
|
1522
|
+
// A retired field name is a reject: ignored, it would read as withheld.
|
|
1687
1523
|
const legacyError = legacyEventBytesError(ev, "event", STAMP_EVIDENCE_BODY_TYPE);
|
|
1688
1524
|
if (legacyError !== null)
|
|
1689
1525
|
evErrors.push(legacyError);
|
|
1690
|
-
//
|
|
1691
|
-
//
|
|
1692
|
-
// `event_bytes` that will not decode leaves `eventBytes` undefined, and an
|
|
1693
|
-
// undefined `eventBytes` is exactly how a WITHHELD record is spelled one
|
|
1694
|
-
// line down. So an event carrying garbage in place of its record used to
|
|
1695
|
-
// reach the same tri-state as an event carrying nothing: `payloadHashOk`
|
|
1696
|
-
// null, `ok` true, a leading tick on the line, and a `verified` rollup over
|
|
1697
|
-
// a record nobody could read. The only thing wrong with the pack was the
|
|
1698
|
-
// bytes, and it was the one thing the verdict did not say.
|
|
1699
|
-
//
|
|
1700
|
-
// A record the producer chose not to disclose is the customer exercising
|
|
1701
|
-
// minimum disclosure. A record that is present and unreadable is a defect
|
|
1702
|
-
// in the artifact, and this kernel's standing rule (the same one
|
|
1703
|
-
// `checkpoints[]` already applies through `keyed.errors`) is that a defect
|
|
1704
|
-
// we can see fails.
|
|
1526
|
+
// Malformed is a failure; absent is undetermined. Undecodable `event_bytes`
|
|
1527
|
+
// would otherwise look exactly like a withheld record and verify.
|
|
1705
1528
|
let eventBytes;
|
|
1706
1529
|
let eventBytesMalformed = false;
|
|
1707
1530
|
try {
|
|
@@ -1712,24 +1535,20 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1712
1535
|
eventBytesMalformed = true;
|
|
1713
1536
|
evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
|
|
1714
1537
|
}
|
|
1715
|
-
const
|
|
1716
|
-
if (
|
|
1538
|
+
const notJson = recordNotJson(eventBytes);
|
|
1539
|
+
if (notJson !== undefined) {
|
|
1717
1540
|
eventBytesMalformed = true;
|
|
1718
|
-
evErrors.push(
|
|
1541
|
+
evErrors.push(notJson);
|
|
1719
1542
|
}
|
|
1720
1543
|
try {
|
|
1721
1544
|
const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
|
|
1722
1545
|
const hexError = envelopeHexError("event", ev.envelope);
|
|
1723
|
-
//
|
|
1546
|
+
// Tolerates an undetermined binding (withheld record).
|
|
1724
1547
|
envelopeOk = res.ok && hexError === null;
|
|
1548
|
+
envelopeFaults = eventEnvelopeFaults(res.faults, hexError);
|
|
1725
1549
|
payloadBindingOk = res.payloadHashOk;
|
|
1726
|
-
//
|
|
1727
|
-
//
|
|
1728
|
-
// binding was attempted. "signature failed" named a check that never ran,
|
|
1729
|
-
// and it was the only thing said about an event whose undetermined
|
|
1730
|
-
// binding then rendered as a WITHHELD record, a claim about the
|
|
1731
|
-
// producer's disclosure choice out of an input that says nothing about
|
|
1732
|
-
// the producer at all (proof#76).
|
|
1550
|
+
// No envelope, so no signature was checked: report it as malformed, not as
|
|
1551
|
+
// a failed signature or a withheld record.
|
|
1733
1552
|
if (!isJsonObject(ev.envelope)) {
|
|
1734
1553
|
evErrors.push("event envelope is not a JSON object");
|
|
1735
1554
|
}
|
|
@@ -1741,11 +1560,12 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1741
1560
|
}
|
|
1742
1561
|
if (res.payloadHashOk === false)
|
|
1743
1562
|
evErrors.push("event record does not bind to payload_hash");
|
|
1744
|
-
//
|
|
1745
|
-
// and binding (proof#95), so without this the event fails with no reason.
|
|
1563
|
+
// Name a malformed skeleton field, which fails an otherwise valid envelope.
|
|
1746
1564
|
for (const m of res.errors)
|
|
1747
1565
|
if (m.startsWith("malformed "))
|
|
1748
1566
|
evErrors.push(`event envelope ${m}`);
|
|
1567
|
+
if (res.errors.some((m) => m.startsWith("missing payload_hash")))
|
|
1568
|
+
evErrors.push("event envelope missing payload_hash");
|
|
1749
1569
|
const specFinding = specVersionFinding("event", ev.envelope, res);
|
|
1750
1570
|
if (specFinding !== null)
|
|
1751
1571
|
evErrors.push(specFinding);
|
|
@@ -1753,16 +1573,15 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1753
1573
|
catch (e) {
|
|
1754
1574
|
evErrors.push(`event envelope verify error${thrownDetail(e)}`);
|
|
1755
1575
|
}
|
|
1756
|
-
//
|
|
1576
|
+
// The join (§5.3.3 rule 1).
|
|
1757
1577
|
const incl = ev.inclusion;
|
|
1758
1578
|
let st;
|
|
1579
|
+
let stampAbsent = false;
|
|
1759
1580
|
if (!incl || typeof incl !== "object") {
|
|
1760
1581
|
evErrors.push("event missing inclusion proof");
|
|
1761
1582
|
}
|
|
1762
1583
|
else if (incl.checkpoint_ref !== undefined || incl.checkpoint_tx_id !== undefined) {
|
|
1763
|
-
//
|
|
1764
|
-
// join key inside this body is a mixed bundle, and resolving it anyway is
|
|
1765
|
-
// how an event silently binds to something nobody meant (§5.1.2).
|
|
1584
|
+
// An anchor-trace join key inside this body is a mixed bundle (§5.1.2).
|
|
1766
1585
|
evErrors.push(`event inclusion carries an anchor-trace join key in a ${STAMP_EVIDENCE_BODY_TYPE} body`);
|
|
1767
1586
|
}
|
|
1768
1587
|
else if (typeof incl.stamp_ref !== "string") {
|
|
@@ -1770,24 +1589,22 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1770
1589
|
}
|
|
1771
1590
|
else {
|
|
1772
1591
|
st = byRef.get(incl.stamp_ref);
|
|
1773
|
-
if (st === undefined) {
|
|
1774
|
-
//
|
|
1775
|
-
//
|
|
1592
|
+
if (st === undefined && !heldRefs.has(incl.stamp_ref)) {
|
|
1593
|
+
// The pack does not hold the stamp the record names: the inclusion has no root to
|
|
1594
|
+
// check against, so `witnessed` is not proven (`missing`).
|
|
1595
|
+
stampAbsent = true;
|
|
1596
|
+
evErrors.push(`event inclusion.stamp_ref ${JSON.stringify(incl.stamp_ref)} names a stamp this pack does not hold`);
|
|
1597
|
+
}
|
|
1598
|
+
else if (st === undefined) {
|
|
1776
1599
|
evErrors.push(`event inclusion.stamp_ref ${JSON.stringify(incl.stamp_ref)} resolves to no verified stamp`);
|
|
1777
1600
|
}
|
|
1778
1601
|
}
|
|
1779
1602
|
const stampBound = st !== undefined;
|
|
1780
1603
|
let inclusionOk = false;
|
|
1781
1604
|
if (incl && stampBound && st) {
|
|
1782
|
-
// Rule 3: the tree walked must be the tree the stamp committed to
|
|
1783
|
-
//
|
|
1784
|
-
//
|
|
1785
|
-
//
|
|
1786
|
-
// Read BY VALUE, as the anchor-trace path reads them (A38), and as the
|
|
1787
|
-
// Python kernel does (`_safe_integer`): the wrapper signs JCS(body),
|
|
1788
|
-
// which writes `3.0` as `3`, so a stated `3.0` is 3 under the same
|
|
1789
|
-
// signature. Anything that is not a safe integer is malformed, with a
|
|
1790
|
-
// sentence that prints no value.
|
|
1605
|
+
// Rule 3: the tree walked must be the tree the stamp committed to; RFC
|
|
1606
|
+
// 9162's path shape depends on the size. Read by value, as JCS signs it
|
|
1607
|
+
// (`3.0` is 3); anything not a safe integer is malformed.
|
|
1791
1608
|
const statedCount = safeInteger(incl.leaf_count);
|
|
1792
1609
|
const statedIndex = safeInteger(incl.leaf_index);
|
|
1793
1610
|
if (statedCount === null) {
|
|
@@ -1806,9 +1623,7 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1806
1623
|
if (!inclusionOk) {
|
|
1807
1624
|
evErrors.push("RFC 9162 inclusion proof did not reconstruct the stamp root");
|
|
1808
1625
|
}
|
|
1809
|
-
//
|
|
1810
|
-
// own signed envelope bytes, or a valid proof for an UNRELATED leaf
|
|
1811
|
-
// could be smuggled in.
|
|
1626
|
+
// As §5.1: the claimed leaf must be this event's own signed envelope.
|
|
1812
1627
|
const expectedLeafHex = toHex(await leafHash(utf8(jcs(ev.envelope))));
|
|
1813
1628
|
if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
|
|
1814
1629
|
inclusionOk = false;
|
|
@@ -1823,16 +1638,16 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1823
1638
|
const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
|
|
1824
1639
|
const contentOk = contentRes.contentOk;
|
|
1825
1640
|
evErrors.push(...contentRes.errors);
|
|
1641
|
+
agentItems.push(agentItem(ev, eventBytes, envelopeOk && payloadBindingOk === true && !eventBytesMalformed));
|
|
1826
1642
|
eventResults.push({
|
|
1827
1643
|
eventId,
|
|
1828
1644
|
envelopeOk,
|
|
1829
1645
|
payloadBindingOk,
|
|
1830
1646
|
inclusionOk,
|
|
1831
|
-
//
|
|
1647
|
+
// Here: whether `stamp_ref` resolved.
|
|
1832
1648
|
checkpointBound: stampBound,
|
|
1833
1649
|
contentOk,
|
|
1834
|
-
//
|
|
1835
|
-
// defect in the artifact, never a disclosure choice (proof#85).
|
|
1650
|
+
// Unreadable record bytes are a defect, never a disclosure choice.
|
|
1836
1651
|
ok: envelopeOk &&
|
|
1837
1652
|
inclusionOk &&
|
|
1838
1653
|
stampBound &&
|
|
@@ -1840,49 +1655,48 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1840
1655
|
legacyError === null &&
|
|
1841
1656
|
!eventBytesMalformed,
|
|
1842
1657
|
errors: evErrors,
|
|
1658
|
+
agent: null,
|
|
1659
|
+
envelopeFault: envelopeFaults[0] ?? null,
|
|
1660
|
+
envelopeFaults,
|
|
1661
|
+
recordNotJson: notJson !== undefined && envelopeOk && payloadBindingOk === true,
|
|
1662
|
+
contentUnsigned: contentOk === false && contentRes.unsigned,
|
|
1663
|
+
stampAbsent,
|
|
1664
|
+
ownFormFailed: legacyError !== null || eventBytesMalformed,
|
|
1843
1665
|
});
|
|
1844
1666
|
}
|
|
1845
|
-
|
|
1667
|
+
await attachAgentReadings(eventResults, agentItems);
|
|
1668
|
+
// The optional on-chain re-fetch (§5.2.4).
|
|
1846
1669
|
const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(stamps, witnessed, options, errors);
|
|
1847
|
-
//
|
|
1848
|
-
//
|
|
1849
|
-
|
|
1850
|
-
// verify fails the bundle — it is never rolled up as verified.
|
|
1851
|
-
const anyHardFailure = stamps.some((st) => !st.ok) || eventResults.some((e) => !e.ok);
|
|
1670
|
+
// Rollup. Rule 4: a declared event must be checked, so an event that did
|
|
1671
|
+
// not verify fails the bundle.
|
|
1672
|
+
const anyHardFailure = stamps.some((st) => !st.ok) || eventResults.some((e) => !e.ok && !unproven(e));
|
|
1852
1673
|
const anyUnwitnessed = stamps.some((st) => st.anchorRef === null);
|
|
1674
|
+
const anyStampAbsent = eventResults.some((e) => e.stampAbsent === true);
|
|
1675
|
+
pushRefetchGapNotes(errors, "stamps", {
|
|
1676
|
+
unavailable: gatewayUnavailable,
|
|
1677
|
+
kind: uncheckedKind,
|
|
1678
|
+
id: uncheckedId,
|
|
1679
|
+
});
|
|
1853
1680
|
let status;
|
|
1854
1681
|
if (anyHardFailure) {
|
|
1855
1682
|
status = "failed";
|
|
1856
1683
|
}
|
|
1857
1684
|
else if (stamps.length === 0 && eventResults.length === 0) {
|
|
1858
|
-
status = "
|
|
1685
|
+
status = "incomplete";
|
|
1859
1686
|
errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
|
|
1860
1687
|
}
|
|
1861
|
-
else if (eventResults.length === 0 ||
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
anyUnwitnessed) {
|
|
1866
|
-
// Each holds the bundle at `partial`, and EACH gets said. A pack with no
|
|
1867
|
-
// events, a stamp nobody witnessed and a witness we could not reach are
|
|
1868
|
-
// different gaps with different fixes, and an auditor told only about one
|
|
1869
|
-
// would read the others as fine. The order is fixed so the two kernels
|
|
1870
|
-
// emit the same list.
|
|
1871
|
-
status = "partial";
|
|
1688
|
+
else if (eventResults.length === 0 || anyUnwitnessed || anyStampAbsent || eventResults.some(unproven)) {
|
|
1689
|
+
// Each gap holds the bundle at `incomplete` and each is named, in a fixed order
|
|
1690
|
+
// so both kernels emit the same list.
|
|
1691
|
+
status = "incomplete";
|
|
1872
1692
|
if (eventResults.length === 0) {
|
|
1873
|
-
//
|
|
1874
|
-
//
|
|
1693
|
+
// Section 5.3.2: an events-empty body is a section 5.2 stamp trace: incomplete, never
|
|
1694
|
+
// verified.
|
|
1875
1695
|
errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no events -- it evidences no event, only its stamps`);
|
|
1876
1696
|
}
|
|
1877
|
-
pushRefetchGapNotes(errors, "stamps", {
|
|
1878
|
-
unavailable: gatewayUnavailable,
|
|
1879
|
-
kind: uncheckedKind,
|
|
1880
|
-
id: uncheckedId,
|
|
1881
|
-
});
|
|
1882
1697
|
if (anyUnwitnessed) {
|
|
1883
|
-
//
|
|
1884
|
-
|
|
1885
|
-
errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
|
|
1698
|
+
// Section 5.3.3 rule 6: events resting on an unwitnessed stamp inherit `incomplete`.
|
|
1699
|
+
errors.push("one or more stamps carries no anchor_ref: sealed by Sanning, witnessed by nobody");
|
|
1886
1700
|
}
|
|
1887
1701
|
}
|
|
1888
1702
|
else {
|
|
@@ -1897,44 +1711,16 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
|
|
|
1897
1711
|
onChainChecked,
|
|
1898
1712
|
checkpoints: [],
|
|
1899
1713
|
events: eventResults,
|
|
1900
|
-
assertedStatus,
|
|
1901
1714
|
stamps,
|
|
1902
1715
|
errors,
|
|
1903
1716
|
};
|
|
1904
1717
|
}
|
|
1905
1718
|
/**
|
|
1906
1719
|
* The optional on-chain re-fetch over `stamps[]`, shared by §5.2 and §5.3.
|
|
1907
|
-
*
|
|
1908
|
-
*
|
|
1909
|
-
*
|
|
1910
|
-
*
|
|
1911
|
-
* carry these outcomes would make a stamp indistinguishable from a checkpoint
|
|
1912
|
-
* in every consumer that reads `checkpoints[]` — the exact conflation §5.2.1
|
|
1913
|
-
* and §5.3.1 refused twice, most sharply in "a Sanning attestation
|
|
1914
|
-
* indistinguishable on the wire from a producer's". They are different
|
|
1915
|
-
* artifacts with different signers and different meanings when absent, and a
|
|
1916
|
-
* reader must never have to guess which one it is holding. So the stamp
|
|
1917
|
-
* results go on the stamps, and `checkpoints: []` keeps saying the true thing:
|
|
1918
|
-
* this body has none.
|
|
1919
|
-
*
|
|
1920
|
-
* ── Why an unwitnessed stamp gets `onChain: null` and NOT all-unreachable ──
|
|
1921
|
-
*
|
|
1922
|
-
* The anchor-trace path synthesises an all-`unreachable` result for a
|
|
1923
|
-
* checkpoint with no `anchor_ref`. That is not copied here, for two reasons.
|
|
1924
|
-
*
|
|
1925
|
-
* It would be untrue: nothing was asked of any gateway, so reporting that
|
|
1926
|
-
* every gateway failed to answer invents a network event. "Not checked" and
|
|
1927
|
-
* "we asked and nobody answered" are different facts, and collapsing them is
|
|
1928
|
-
* precisely the defect that had a surface reporting an unreachable gateway
|
|
1929
|
-
* when it had contacted none.
|
|
1930
|
-
*
|
|
1931
|
-
* And it would be load-bearing in the wrong direction. Over there the
|
|
1932
|
-
* synthesis exists to drive `gatewayUnavailable`, which is what downgrades that
|
|
1933
|
-
* run to `partial`. Here §5.2.4 already downgrades an unwitnessed stamp to
|
|
1934
|
-
* `partial` on its own, with a better error. Copying the synthesis would add
|
|
1935
|
-
* nothing to the status, which is `partial` either way, and would add an
|
|
1936
|
-
* unreachable-gateway note for a network event that never happened, based on
|
|
1937
|
-
* nothing but whether the caller happened to pass a gateway list.
|
|
1720
|
+
* Results land on the stamps and `checkpoints` stays empty: a stamp must never
|
|
1721
|
+
* be mistaken for a producer checkpoint. An unwitnessed stamp gets `onChain:
|
|
1722
|
+
* null`, not a synthesized all-unreachable result: no gateway was asked, and
|
|
1723
|
+
* §5.2.4 already holds it at `partial`.
|
|
1938
1724
|
*/
|
|
1939
1725
|
async function refetchStamps(stamps, witnessed, options, errors) {
|
|
1940
1726
|
const gateways = options.gateways ?? [];
|
|
@@ -1955,8 +1741,7 @@ async function refetchStamps(stamps, witnessed, options, errors) {
|
|
|
1955
1741
|
for (let i = 0; i < stamps.length; i++) {
|
|
1956
1742
|
const st = stamps[i];
|
|
1957
1743
|
const witnessedHex = witnessed[i];
|
|
1958
|
-
// No witness named, or bytes
|
|
1959
|
-
// nothing a gateway could confirm or deny. Left as `onChain: null`.
|
|
1744
|
+
// No witness named, or unreadable bytes: nothing to re-fetch.
|
|
1960
1745
|
if (st.anchorRef === null || witnessedHex === undefined)
|
|
1961
1746
|
continue;
|
|
1962
1747
|
if (st.anchorRef.kind === SIMULATED_WITNESS_KIND)
|
|
@@ -1974,22 +1759,13 @@ async function refetchStamps(stamps, witnessed, options, errors) {
|
|
|
1974
1759
|
uncheckedId = true;
|
|
1975
1760
|
if (outcome.error)
|
|
1976
1761
|
st.errors.push(outcome.error);
|
|
1977
|
-
//
|
|
1978
|
-
//
|
|
1979
|
-
//
|
|
1980
|
-
// downgrades the run to `partial` via gatewayUnavailable. A confirm (true)
|
|
1981
|
-
// changes nothing that was not already true offline.
|
|
1982
|
-
//
|
|
1983
|
-
// `st.ok` is kept and narrowed rather than recomputed: its offline form
|
|
1984
|
-
// includes `errors.length === 0`, and re-evaluating that AFTER pushing an
|
|
1985
|
-
// unreachable note would fail a stamp for a gateway being down.
|
|
1762
|
+
// As for a checkpoint: a mismatch fails the stamp, unreachable downgrades
|
|
1763
|
+
// the run to `partial`. Narrowed, not recomputed, so an unreachable note
|
|
1764
|
+
// does not fail the stamp.
|
|
1986
1765
|
st.ok = st.ok && st.onChainOk !== false;
|
|
1987
1766
|
}
|
|
1988
|
-
// A simulated witness
|
|
1989
|
-
//
|
|
1990
|
-
// It stays not checked like any kind this verifier has no adapter for, and
|
|
1991
|
-
// the run stays undetermined; what changes is that a run whose only
|
|
1992
|
-
// witnesses are simulated no longer says it checked anything on a chain.
|
|
1767
|
+
// A simulated witness has no chain to look up, so it is never counted as
|
|
1768
|
+
// checked on one; the run stays undetermined.
|
|
1993
1769
|
if (simulated > 0 && onChainKinds === 0) {
|
|
1994
1770
|
return { onChainChecked: false, gatewayUnavailable, uncheckedKind, uncheckedId };
|
|
1995
1771
|
}
|
|
@@ -2001,24 +1777,17 @@ async function refetchStamps(stamps, witnessed, options, errors) {
|
|
|
2001
1777
|
* on a chain.
|
|
2002
1778
|
*/
|
|
2003
1779
|
export const SIMULATED_WITNESS_KIND = "simulated";
|
|
2004
|
-
// How many
|
|
2005
|
-
//
|
|
2006
|
-
// depth bound already rejects a pathological bundle, but the unwrap must be
|
|
2007
|
-
// independently bounded rather than trusting that it ran first.
|
|
1780
|
+
// How many export wrappers deep the source bundle may nest. Bounded here
|
|
1781
|
+
// independently of the canonicalization depth bound.
|
|
2008
1782
|
const MAX_EXPORT_UNWRAP = 4;
|
|
2009
1783
|
/**
|
|
2010
|
-
* Materialize the disclosed raw bytes of a received bundle (evidence-bundle
|
|
1784
|
+
* Materialize the disclosed raw bytes of a received bundle (evidence-bundle.md
|
|
1785
|
+
* §5.1). The bundle is verified with {@link verifyEvidenceBundle}, and an
|
|
1786
|
+
* event's bytes are returned only when that verification bound them to the
|
|
1787
|
+
* committed `content_hash`.
|
|
2011
1788
|
*
|
|
2012
|
-
*
|
|
2013
|
-
*
|
|
2014
|
-
* bytes are returned ONLY when that verification bound them to the committed
|
|
2015
|
-
* `content_hash`. There is no path through this function that yields bytes
|
|
2016
|
-
* whose SHA-256 is not the commitment.
|
|
2017
|
-
*
|
|
2018
|
-
* Returns `Uint8Array`, never a string: the spec commits to raw bytes, and a
|
|
2019
|
-
* UTF-8 decode is lossy for any payload that is not valid UTF-8 — re-encoding
|
|
2020
|
-
* such a string would produce different bytes and a different hash. Callers who
|
|
2021
|
-
* know their payload is text decode it themselves and own that assumption.
|
|
1789
|
+
* Returns `Uint8Array`, never a string: a UTF-8 decode is lossy and would
|
|
1790
|
+
* change the hash.
|
|
2022
1791
|
*/
|
|
2023
1792
|
export async function disclosedContent(bundle, options = {}) {
|
|
2024
1793
|
const verification = await verifyEvidenceBundle(bundle, options);
|
|
@@ -2039,11 +1808,9 @@ export async function disclosedContent(bundle, options = {}) {
|
|
|
2039
1808
|
`(body_type ${JSON.stringify(verification.bodyType)})`);
|
|
2040
1809
|
return empty();
|
|
2041
1810
|
}
|
|
2042
|
-
// Fail closed
|
|
2043
|
-
//
|
|
2044
|
-
//
|
|
2045
|
-
// only guarantee this function makes. A broken wrapper lands here too:
|
|
2046
|
-
// verifyEvidenceBundle returns zero event results, so nothing is emitted.
|
|
1811
|
+
// Fail closed unless the decoded body and the trusted results are the same
|
|
1812
|
+
// events in the same order. A broken wrapper yields zero results, so nothing
|
|
1813
|
+
// is emitted.
|
|
2047
1814
|
if (traceEvents.length !== verification.events.length) {
|
|
2048
1815
|
errors.push("the bundle did not verify far enough to materialize content: " +
|
|
2049
1816
|
`${verification.events.length} verified event result(s) for ${traceEvents.length} body event(s)`);
|
|
@@ -2066,17 +1833,15 @@ export async function disclosedContent(bundle, options = {}) {
|
|
|
2066
1833
|
const evErrors = [];
|
|
2067
1834
|
let status;
|
|
2068
1835
|
if (!res.ok || res.contentOk === false) {
|
|
2069
|
-
// The event
|
|
2070
|
-
// unbound checkpoint) or its disclosure contradicts the commitment.
|
|
1836
|
+
// The event is not trustworthy, or its disclosure contradicts the commitment.
|
|
2071
1837
|
status = "failed";
|
|
2072
1838
|
evErrors.push(res.contentOk === false
|
|
2073
|
-
? "disclosed content does not bind to the committed content_hash
|
|
2074
|
-
: "the event's own proof chain did not verify
|
|
1839
|
+
? "disclosed content does not bind to the committed content_hash; withheld"
|
|
1840
|
+
: "the event's own proof chain did not verify; nothing here is safe to materialize");
|
|
2075
1841
|
evErrors.push(...res.errors);
|
|
2076
1842
|
}
|
|
2077
1843
|
else if (!selection.offered) {
|
|
2078
|
-
// Minimal disclosure:
|
|
2079
|
-
// Distinct from empty bytes, and never an error.
|
|
1844
|
+
// Minimal disclosure: distinct from empty bytes, and never an error.
|
|
2080
1845
|
status = "not_disclosed";
|
|
2081
1846
|
}
|
|
2082
1847
|
else if (bytes === undefined) {
|
|
@@ -2085,11 +1850,9 @@ export async function disclosedContent(bundle, options = {}) {
|
|
|
2085
1850
|
evErrors.push(...selection.errors);
|
|
2086
1851
|
}
|
|
2087
1852
|
else if (res.contentOk !== true) {
|
|
2088
|
-
//
|
|
2089
|
-
// (withheld record, or a record committing no content_hash). Unbound
|
|
2090
|
-
// bytes are exactly what a consumer must not silently write out.
|
|
1853
|
+
// Nothing to check the bytes against: unbound bytes must not be written.
|
|
2091
1854
|
status = "undetermined";
|
|
2092
|
-
evErrors.push("disclosed bytes are not bound to any committed content_hash
|
|
1855
|
+
evErrors.push("disclosed bytes are not bound to any committed content_hash; withheld rather than returned");
|
|
2093
1856
|
evErrors.push(...res.errors);
|
|
2094
1857
|
}
|
|
2095
1858
|
else {
|
|
@@ -2099,7 +1862,7 @@ export async function disclosedContent(bundle, options = {}) {
|
|
|
2099
1862
|
if (verified) {
|
|
2100
1863
|
if (content.has(res.eventId)) {
|
|
2101
1864
|
errors.push(`duplicate event_id ${JSON.stringify(res.eventId)}: the content map keeps the first ` +
|
|
2102
|
-
"occurrence
|
|
1865
|
+
"occurrence; read events[] for every event");
|
|
2103
1866
|
}
|
|
2104
1867
|
else {
|
|
2105
1868
|
content.set(res.eventId, bytes);
|
|
@@ -2114,21 +1877,12 @@ export async function disclosedContent(bundle, options = {}) {
|
|
|
2114
1877
|
}
|
|
2115
1878
|
return { status: verification.status, verification, events, content, errors };
|
|
2116
1879
|
}
|
|
2117
|
-
// The events a bundle carries,
|
|
2118
|
-
//
|
|
2119
|
-
//
|
|
2120
|
-
//
|
|
2121
|
-
//
|
|
2122
|
-
//
|
|
2123
|
-
// BOTH event-carrying bodies, not just anchor-trace (proof#73): §5.3's
|
|
2124
|
-
// `sanning.stamp.evidence/v2` is the hand-over pack, and it presents the same
|
|
2125
|
-
// event shape.
|
|
2126
|
-
//
|
|
2127
|
-
// Exported for `chain.ts`, which needs the same descent and must not derive it
|
|
2128
|
-
// again: two answers to "which events is this bundle actually about" inside one
|
|
2129
|
-
// kernel is two implementations of one rule, and the one that drifted would be
|
|
2130
|
-
// the one nobody was looking at. Deliberately NOT re-exported from `index.ts` —
|
|
2131
|
-
// same posture as `side-content.ts`, internal and shared.
|
|
1880
|
+
// The events a bundle carries, through any export wrappers to the inline
|
|
1881
|
+
// source, as `verifyEvidenceBundle` recurses, so they line up with its results.
|
|
1882
|
+
// Both event-carrying bodies (anchor trace and §5.3 stamp evidence). `undefined`
|
|
1883
|
+
// when the bundle carries no events this reader understands. Exported for
|
|
1884
|
+
// `chain.ts`, so there is one answer to which events a bundle is about; not
|
|
1885
|
+
// re-exported from `index.ts`.
|
|
2132
1886
|
export function resolveTraceEvents(bundle) {
|
|
2133
1887
|
let cur = bundle;
|
|
2134
1888
|
for (let depth = 0; depth < MAX_EXPORT_UNWRAP; depth++) {
|
|
@@ -2136,14 +1890,6 @@ export function resolveTraceEvents(bundle) {
|
|
|
2136
1890
|
return undefined;
|
|
2137
1891
|
const b = cur;
|
|
2138
1892
|
const bodyType = typeof b.body_type === "string" ? b.body_type : null;
|
|
2139
|
-
// Both event-carrying bodies, not just anchor-trace (proof#73). §5.3's
|
|
2140
|
-
// `sanning.stamp.evidence/v2` presents the SAME event shape (a
|
|
2141
|
-
// producer-signed envelope, optional `event_bytes`, an inclusion proof),
|
|
2142
|
-
// and it is the HAND-OVER PACK: the only artifact an auditor is ever
|
|
2143
|
-
// handed. Dispatching on anchor-trace alone meant `readChains` reported
|
|
2144
|
-
// zero chains on every pack, however well-formed the links inside its
|
|
2145
|
-
// records were, so "is it in order?" read as NOT CHECKED on the one shape
|
|
2146
|
-
// that matters. The writers have been emitting those links the whole time.
|
|
2147
1893
|
if (bodyType === ANCHOR_TRACE_BODY_TYPE ||
|
|
2148
1894
|
bodyType === STAMP_EVIDENCE_BODY_TYPE) {
|
|
2149
1895
|
const body = b.body;
|
|
@@ -2160,20 +1906,15 @@ export function resolveTraceEvents(bundle) {
|
|
|
2160
1906
|
}
|
|
2161
1907
|
return undefined;
|
|
2162
1908
|
}
|
|
2163
|
-
//
|
|
2164
|
-
//
|
|
2165
|
-
//
|
|
2166
|
-
|
|
2167
|
-
//
|
|
2168
|
-
//
|
|
2169
|
-
// ===========================================================================
|
|
2170
|
-
async function verifyExportBody(b, specVersionOk, assertedStatus, errors, options) {
|
|
2171
|
-
// Malformed-as-an-export (§5 exit 2): a required field is missing or a key is
|
|
2172
|
-
// unparseable — the wrapper verified, but no verdict can be rendered. The
|
|
2173
|
-
// wrapper fields stay true (they DID verify) for any consumer that inspects
|
|
2174
|
-
// them; the CLI's malformed branch shows the errors and exits 2.
|
|
1909
|
+
// Attested-evidence-export verification (evidence-export.md §5, steps 3 to 9),
|
|
1910
|
+
// reached after the wrapper verified. Recomputes the inline source verdict and
|
|
1911
|
+
// checks the embedded RSA-PSS operator attestations.
|
|
1912
|
+
async function verifyExportBody(b, specVersionOk, errors, options) {
|
|
1913
|
+
// The wrapper verified but no verdict can be rendered: unreadable as an export.
|
|
1914
|
+
// The wrapper fields stay true, because they did verify.
|
|
2175
1915
|
const exportMalformed = (error) => ({
|
|
2176
|
-
status: "
|
|
1916
|
+
status: "incomplete",
|
|
1917
|
+
unreadable: true,
|
|
2177
1918
|
specVersionOk,
|
|
2178
1919
|
bodyHashOk: true,
|
|
2179
1920
|
signatureOk: true,
|
|
@@ -2181,7 +1922,6 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
|
|
|
2181
1922
|
onChainChecked: false,
|
|
2182
1923
|
checkpoints: [],
|
|
2183
1924
|
events: [],
|
|
2184
|
-
assertedStatus,
|
|
2185
1925
|
errors: [...errors, error],
|
|
2186
1926
|
});
|
|
2187
1927
|
const body = b.body;
|
|
@@ -2199,14 +1939,13 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
|
|
|
2199
1939
|
Array.isArray(body.kernel_verdict)) {
|
|
2200
1940
|
return exportMalformed("export body missing kernel_verdict");
|
|
2201
1941
|
}
|
|
2202
|
-
//
|
|
1942
|
+
// Step 3: source-bundle linkage.
|
|
2203
1943
|
const inline = body.source_bundle;
|
|
2204
1944
|
if (inline === undefined || inline === null) {
|
|
2205
|
-
//
|
|
2206
|
-
//
|
|
2207
|
-
// dependent checks are UNDETERMINED (exit 3), NOT a failure (§5 step 3).
|
|
1945
|
+
// An out-of-line source is not verifiable offline unless co-delivered: the
|
|
1946
|
+
// source-dependent checks are not checked, never failed.
|
|
2208
1947
|
return {
|
|
2209
|
-
status: "
|
|
1948
|
+
status: "incomplete",
|
|
2210
1949
|
specVersionOk,
|
|
2211
1950
|
bodyHashOk: true,
|
|
2212
1951
|
signatureOk: true,
|
|
@@ -2214,11 +1953,10 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
|
|
|
2214
1953
|
onChainChecked: false,
|
|
2215
1954
|
checkpoints: [],
|
|
2216
1955
|
events: [],
|
|
2217
|
-
assertedStatus,
|
|
2218
1956
|
errors: [
|
|
2219
1957
|
...errors,
|
|
2220
|
-
"source_bundle is not inline (source_bundle_ref)
|
|
2221
|
-
"source-dependent checks
|
|
1958
|
+
"source_bundle is not inline (source_bundle_ref): bytes unavailable offline; " +
|
|
1959
|
+
"source-dependent checks not checked",
|
|
2222
1960
|
],
|
|
2223
1961
|
};
|
|
2224
1962
|
}
|
|
@@ -2233,80 +1971,65 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
|
|
|
2233
1971
|
catch (e) {
|
|
2234
1972
|
errors.push(`source_bundle canonicalization failed${thrownDetail(e)}`);
|
|
2235
1973
|
}
|
|
2236
|
-
//
|
|
2237
|
-
// The existing evidence path over the inline source bundle — same primitives
|
|
2238
|
-
// (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
|
|
2239
|
-
// content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
|
|
1974
|
+
// Step 4: recompute the source verdict, including the optional re-fetch.
|
|
2240
1975
|
const sourceResult = await verifyExportSource(inline, options);
|
|
2241
|
-
//
|
|
1976
|
+
// For the attestation data_hash binding (§5 step 6c).
|
|
2242
1977
|
const sourceCheckpoints = await indexSourceCheckpoints(inline);
|
|
2243
|
-
//
|
|
1978
|
+
// Step 6: embedded attestation records.
|
|
2244
1979
|
const attestations = [];
|
|
2245
1980
|
for (const rec of body.attestations) {
|
|
2246
1981
|
const r = await verifyAttestation(rec, sourceCheckpoints);
|
|
2247
1982
|
if (r.malformed !== undefined)
|
|
2248
1983
|
return exportMalformed(r.malformed);
|
|
1984
|
+
if (typeof r.result.fieldError === "string")
|
|
1985
|
+
errors.push(r.result.fieldError);
|
|
2249
1986
|
attestations.push(r.result);
|
|
2250
1987
|
}
|
|
2251
|
-
// --- Build the recomputed §4 verdict object -------------------------------
|
|
2252
1988
|
const verdict = buildVerdictObject(sourceResult, attestations);
|
|
2253
1989
|
if (typeof b.generated_at === "string")
|
|
2254
1990
|
verdict.as_of = b.generated_at;
|
|
2255
|
-
//
|
|
2256
|
-
//
|
|
2257
|
-
//
|
|
2258
|
-
//
|
|
2259
|
-
// merkle roots, and per-attestation signature/operator-address/data_hash
|
|
2260
|
-
// binding. The on-chain per-gateway dimension is environment/time-dependent —
|
|
2261
|
-
// the issuer observed it online at compose time; an offline verifier
|
|
2262
|
-
// legitimately sees `null` — so it is INFORMATIONAL and MUST NOT by itself
|
|
2263
|
-
// trigger an exit-1 tamper verdict (the verifier's own on-chain outcomes fold
|
|
2264
|
-
// in at step 7 only if it re-fetches). Advisory rollups (status / counts /
|
|
2265
|
-
// summary / as_of / custody_chain) are derived, not compared. See the §5
|
|
2266
|
-
// step-5 erratum flagged in the slice report.
|
|
1991
|
+
// Step 5: the cached `kernel_verdict` is never trusted, and agreement compares
|
|
1992
|
+
// only the deterministic, offline-recomputable dimensions. The on-chain
|
|
1993
|
+
// dimension depends on when and where it was observed, so it must not by
|
|
1994
|
+
// itself read as tamper. Derived rollups are not compared.
|
|
2267
1995
|
let verdictAgreementOk = false;
|
|
2268
1996
|
try {
|
|
2269
1997
|
const cached = projectVerdictForAgreement(body.kernel_verdict);
|
|
2270
1998
|
const fresh = projectVerdictForAgreement(verdict);
|
|
2271
|
-
|
|
1999
|
+
tolerateUnboundMessageContent(cached, fresh, sourceEventsGoverned(inline));
|
|
2272
2000
|
const cachedProjection = jcs(cached);
|
|
2273
2001
|
const freshProjection = jcs(fresh);
|
|
2274
2002
|
verdictAgreementOk = cachedProjection === freshProjection;
|
|
2275
2003
|
if (!verdictAgreementOk) {
|
|
2276
2004
|
errors.push("recomputed verdict disagrees with the cached kernel_verdict " +
|
|
2277
|
-
"(deterministic dimensions)
|
|
2005
|
+
"(deterministic dimensions); recompute-don't-trust");
|
|
2278
2006
|
}
|
|
2279
2007
|
}
|
|
2280
2008
|
catch (e) {
|
|
2281
2009
|
errors.push(`verdict agreement comparison failed${thrownDetail(e)}`);
|
|
2282
2010
|
}
|
|
2283
|
-
//
|
|
2284
|
-
//
|
|
2285
|
-
// a checkpoint `mismatch` made it "failed"; an all-`unreachable` checkpoint
|
|
2286
|
-
// made it "partial" + an unreachable note → exit 3.)
|
|
2287
|
-
// Propagate the source recompute's notes so the auditor — and the CLI's
|
|
2288
|
-
// exit-3 detection (which keys off "unreachable") — see them.
|
|
2011
|
+
// Step 7 is already folded into `sourceResult.status` by the source recompute.
|
|
2012
|
+
// The CLI's exit-3 detection keys off "unreachable" in these notes.
|
|
2289
2013
|
for (const e of sourceResult.errors)
|
|
2290
2014
|
errors.push(`source: ${e}`);
|
|
2291
|
-
//
|
|
2015
|
+
// Step 9: export rollup.
|
|
2292
2016
|
const anyAttestationFailed = attestations.some((a) => !a.ok);
|
|
2293
2017
|
let status;
|
|
2294
2018
|
if (!sourceLinkageOk ||
|
|
2295
2019
|
!verdictAgreementOk ||
|
|
2296
2020
|
sourceResult.status === "failed" ||
|
|
2297
|
-
sourceResult.
|
|
2021
|
+
sourceResult.unreadable === true ||
|
|
2298
2022
|
anyAttestationFailed) {
|
|
2299
2023
|
status = "failed";
|
|
2300
2024
|
}
|
|
2301
|
-
else if (sourceResult.status === "
|
|
2302
|
-
//
|
|
2303
|
-
|
|
2304
|
-
status = "partial";
|
|
2025
|
+
else if (sourceResult.status === "incomplete") {
|
|
2026
|
+
// A source not fully checked (a withheld record). Never a failure.
|
|
2027
|
+
status = "incomplete";
|
|
2305
2028
|
}
|
|
2306
2029
|
else {
|
|
2307
2030
|
status = "verified";
|
|
2308
2031
|
}
|
|
2309
|
-
// The
|
|
2032
|
+
// The displayed verdict reflects the full recompute, attestations included.
|
|
2310
2033
|
verdict.status = status;
|
|
2311
2034
|
verdict.summary = summarizeExport(status, sourceResult, attestations);
|
|
2312
2035
|
return {
|
|
@@ -2316,11 +2039,9 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
|
|
|
2316
2039
|
signatureOk: true,
|
|
2317
2040
|
bodyType: EXPORT_BODY_TYPE,
|
|
2318
2041
|
onChainChecked: sourceResult.onChainChecked,
|
|
2319
|
-
// The
|
|
2320
|
-
// results, so the CLI's existing rendering shows them unchanged.
|
|
2042
|
+
// The recomputed source results, so the CLI renders them unchanged.
|
|
2321
2043
|
checkpoints: sourceResult.checkpoints,
|
|
2322
2044
|
events: sourceResult.events,
|
|
2323
|
-
assertedStatus,
|
|
2324
2045
|
export: {
|
|
2325
2046
|
sourceLinkageOk,
|
|
2326
2047
|
sourceStatus: sourceResult.status,
|
|
@@ -2328,38 +2049,39 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
|
|
|
2328
2049
|
attestations,
|
|
2329
2050
|
verdict,
|
|
2330
2051
|
status,
|
|
2052
|
+
// A source whose own wrapper time breaks its format is a source that does not follow
|
|
2053
|
+
// its format, as one that cannot be read is.
|
|
2054
|
+
sourceUnreadable: (sourceResult.unreadable === true || sourceResult.generatedAtMalformed === true) && sourceLinkageOk,
|
|
2331
2055
|
},
|
|
2332
2056
|
errors,
|
|
2333
2057
|
};
|
|
2334
2058
|
}
|
|
2335
2059
|
export async function recomputeExportVerdict(sourceBundle, attestationRecords, generatedAt, options = {}) {
|
|
2336
2060
|
const sourceResult = await verifyExportSource(sourceBundle, options);
|
|
2337
|
-
//
|
|
2338
|
-
// verifyExportBody, deliberately shared rather than restated.
|
|
2061
|
+
// The same index `verifyExportBody` builds (§5 step 6c).
|
|
2339
2062
|
const sourceCheckpoints = await indexSourceCheckpoints(sourceBundle);
|
|
2340
2063
|
const attestations = [];
|
|
2341
2064
|
for (const rec of attestationRecords) {
|
|
2342
2065
|
const r = await verifyAttestation(rec, sourceCheckpoints);
|
|
2343
|
-
|
|
2344
|
-
|
|
2066
|
+
const malformedField = r.malformed ?? r.result.fieldError ?? undefined;
|
|
2067
|
+
if (malformedField !== undefined) {
|
|
2068
|
+
throw new Error(`attestation record is malformed: ${malformedField}`);
|
|
2345
2069
|
}
|
|
2346
2070
|
attestations.push(r.result);
|
|
2347
2071
|
}
|
|
2348
2072
|
const verdict = buildVerdictObject(sourceResult, attestations);
|
|
2349
2073
|
verdict.as_of = generatedAt;
|
|
2350
|
-
//
|
|
2351
|
-
//
|
|
2352
|
-
// there is no prior cached verdict to disagree with), so the only failure
|
|
2353
|
-
// inputs here are the recomputed source status and any failed attestation.
|
|
2074
|
+
// Linkage and agreement always hold at compose time, so only the source
|
|
2075
|
+
// status and the attestations can fail the rollup.
|
|
2354
2076
|
const anyAttestationFailed = attestations.some((a) => !a.ok);
|
|
2355
2077
|
let status;
|
|
2356
2078
|
if (sourceResult.status === "failed" ||
|
|
2357
|
-
sourceResult.
|
|
2079
|
+
sourceResult.unreadable === true ||
|
|
2358
2080
|
anyAttestationFailed) {
|
|
2359
2081
|
status = "failed";
|
|
2360
2082
|
}
|
|
2361
|
-
else if (sourceResult.status === "
|
|
2362
|
-
status = "
|
|
2083
|
+
else if (sourceResult.status === "incomplete") {
|
|
2084
|
+
status = "incomplete";
|
|
2363
2085
|
}
|
|
2364
2086
|
else {
|
|
2365
2087
|
status = "verified";
|
|
@@ -2368,19 +2090,10 @@ export async function recomputeExportVerdict(sourceBundle, attestationRecords, g
|
|
|
2368
2090
|
verdict.summary = summarizeExport(status, sourceResult, attestations);
|
|
2369
2091
|
return { verdict, sourceStatus: sourceResult.status, status, attestations };
|
|
2370
2092
|
}
|
|
2371
|
-
//
|
|
2372
|
-
//
|
|
2373
|
-
//
|
|
2374
|
-
//
|
|
2375
|
-
// undetermined-tolerant and never gates.
|
|
2376
|
-
// The attestation payload fields evidence-export.md §3.1 marks Required, with
|
|
2377
|
-
// the type it gives each. A record whose payload lacks one, or carries another
|
|
2378
|
-
// type (null included), is malformed: §5 step 9 and §4.5 make "a missing
|
|
2379
|
-
// required field" an export the verifier cannot render (exit 2). §5 step 6
|
|
2380
|
-
// gates a WELL-FORMED record on signature, operator and data_hash only; these
|
|
2381
|
-
// checks decide whether the record is well-formed at all. `level` is the §3.1
|
|
2382
|
-
// enum 1 | 2 | 3. The integers are non-negative and within 2**53 - 1 (the
|
|
2383
|
-
// §3.1 clarification).
|
|
2093
|
+
// The payload fields evidence-export.md section 3.1 requires, with their types. Read only
|
|
2094
|
+
// under a signature that verifies: a record then missing one, or carrying another type
|
|
2095
|
+
// (null included), is one the issuer signed that does not follow its format (section 4.5,
|
|
2096
|
+
// `follows_rules` disproven). `level` is 1, 2 or 3; integers are non-negative safe integers.
|
|
2384
2097
|
const PAYLOAD_REQUIRED = [
|
|
2385
2098
|
["attested_at", "string"],
|
|
2386
2099
|
["tx_id", "string"],
|
|
@@ -2408,7 +2121,6 @@ function payloadFieldError(payload) {
|
|
|
2408
2121
|
want = "a boolean";
|
|
2409
2122
|
}
|
|
2410
2123
|
else if (kind === "integer") {
|
|
2411
|
-
// §3.1 (clarified): a non-negative integer no larger than 2**53 - 1.
|
|
2412
2124
|
ok = typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
2413
2125
|
want = "a non-negative integer";
|
|
2414
2126
|
}
|
|
@@ -2419,12 +2131,16 @@ function payloadFieldError(payload) {
|
|
|
2419
2131
|
if (!ok)
|
|
2420
2132
|
return `attestation payload field ${JSON.stringify(name)} is missing or not ${want}`;
|
|
2421
2133
|
if (name === "attested_at" && !isRfc3339UtcInstant(v)) {
|
|
2422
|
-
// evidence-export §3.1
|
|
2134
|
+
// The form `signed_at` pins (evidence-export.md §3.1).
|
|
2423
2135
|
return 'attestation payload field "attested_at" is not an RFC 3339 UTC date-time with Z naming a real date and time';
|
|
2424
2136
|
}
|
|
2425
2137
|
}
|
|
2426
2138
|
return null;
|
|
2427
2139
|
}
|
|
2140
|
+
// Verify one embedded attestation record (section 5 step 6). Returns `malformed` (exit 2)
|
|
2141
|
+
// where the signature cannot be checked (no payload, an unparseable key or signature);
|
|
2142
|
+
// otherwise `ok` gates on the signature, then the payload fields under it (`fieldError`),
|
|
2143
|
+
// then the operator address and data_hash. `subject_ref` never gates.
|
|
2428
2144
|
async function verifyAttestation(rec, sourceCheckpoints) {
|
|
2429
2145
|
const errs = [];
|
|
2430
2146
|
const result = {
|
|
@@ -2464,16 +2180,12 @@ async function verifyAttestation(rec, sourceCheckpoints) {
|
|
|
2464
2180
|
if (typeof rec.signature !== "string") {
|
|
2465
2181
|
return { result, malformed: "attestation record missing signature" };
|
|
2466
2182
|
}
|
|
2467
|
-
const fieldError = payloadFieldError(payload);
|
|
2468
|
-
if (fieldError !== null)
|
|
2469
|
-
return { result, malformed: fieldError };
|
|
2470
2183
|
const rsaKey = { kty: "RSA", n: pub.n, e: pub.e };
|
|
2471
2184
|
result.operator = typeof payload.operator === "string" ? payload.operator : "";
|
|
2472
2185
|
result.gateway = typeof payload.gateway === "string" ? payload.gateway : null;
|
|
2473
|
-
result.level =
|
|
2474
|
-
// (a) RSA-PSS-SHA-256
|
|
2475
|
-
//
|
|
2476
|
-
// fails to verify returns false (→ exit 1).
|
|
2186
|
+
result.level = safeInteger(payload.level);
|
|
2187
|
+
// (a) RSA-PSS-SHA-256 over JCS(payload). Malformed hex or an unimportable key
|
|
2188
|
+
// is malformed (exit 2); a signature that does not verify is false (exit 1).
|
|
2477
2189
|
let payloadBytes;
|
|
2478
2190
|
try {
|
|
2479
2191
|
payloadBytes = utf8(jcs(payload));
|
|
@@ -2487,16 +2199,17 @@ async function verifyAttestation(rec, sourceCheckpoints) {
|
|
|
2487
2199
|
errs.push("attestation RSA-PSS signature verification failed");
|
|
2488
2200
|
}
|
|
2489
2201
|
catch {
|
|
2490
|
-
// A
|
|
2491
|
-
// `verifyRsaPssSha256: malformed signature hex: ...` and the Python kernel
|
|
2492
|
-
// raises `malformed signature hex: ...`, so quoting either one split the
|
|
2493
|
-
// two kernels on a finding a holder can put in an export body it signs,
|
|
2494
|
-
// and an export pack's findings reach a case reading through `reasons`
|
|
2495
|
-
// (gate 4 on proof#140, finding 1). Callers of `verifyRsaPssSha256` still
|
|
2496
|
-
// get this kernel's own words.
|
|
2202
|
+
// A fixed sentence, not the library's, so both kernels report one finding.
|
|
2497
2203
|
return { result, malformed: "attestation signature or key is malformed" };
|
|
2498
2204
|
}
|
|
2499
|
-
//
|
|
2205
|
+
// The fields are read only under a signature that holds: over bytes the issuer did not
|
|
2206
|
+
// sign, a bad field says nothing the failed signature does not.
|
|
2207
|
+
if (result.signatureOk) {
|
|
2208
|
+
result.fieldError = payloadFieldError(payload);
|
|
2209
|
+
if (result.fieldError !== null)
|
|
2210
|
+
errs.push(result.fieldError);
|
|
2211
|
+
}
|
|
2212
|
+
// (b) base64url(SHA-256(modulus)) == payload.operator.
|
|
2500
2213
|
try {
|
|
2501
2214
|
const derived = await deriveOperatorAddress(pub.n);
|
|
2502
2215
|
result.operatorAddressBound = result.operator !== "" && derived === result.operator;
|
|
@@ -2507,10 +2220,8 @@ async function verifyAttestation(rec, sourceCheckpoints) {
|
|
|
2507
2220
|
catch (e) {
|
|
2508
2221
|
return { result, malformed: `attestation operator key${thrownDetail(e)}` };
|
|
2509
2222
|
}
|
|
2510
|
-
// (c)
|
|
2511
|
-
//
|
|
2512
|
-
// against, §4.2), so the checkpoint's committed content hash is
|
|
2513
|
-
// SHA-256(JCS(checkpoint.envelope)).
|
|
2223
|
+
// (c) data_hash binding. The checkpoint's witnessed data is its uploaded
|
|
2224
|
+
// JCS(envelope), so the committed hash is SHA-256(JCS(checkpoint.envelope)).
|
|
2514
2225
|
const cp = sourceCheckpoints.get(result.checkpointRef);
|
|
2515
2226
|
result.checkpointResolved = cp !== undefined;
|
|
2516
2227
|
if (!cp) {
|
|
@@ -2530,19 +2241,18 @@ async function verifyAttestation(rec, sourceCheckpoints) {
|
|
|
2530
2241
|
errs.push(`cannot recompute checkpoint content hash${thrownDetail(e)}`);
|
|
2531
2242
|
}
|
|
2532
2243
|
}
|
|
2533
|
-
// (d) subject_ref (§3.2)
|
|
2534
|
-
// in v1); undetermined (null), never a hard failure.
|
|
2244
|
+
// (d) subject_ref (§3.2): well-formedness only; null, never a failure.
|
|
2535
2245
|
result.subjectRefOk = evaluateSubjectRef(payload.subject_ref, errs);
|
|
2536
2246
|
result.ok =
|
|
2537
2247
|
result.signatureOk &&
|
|
2248
|
+
(result.fieldError ?? null) === null &&
|
|
2538
2249
|
result.operatorAddressBound &&
|
|
2539
2250
|
result.dataHashBound &&
|
|
2540
2251
|
result.checkpointResolved;
|
|
2541
2252
|
return { result };
|
|
2542
2253
|
}
|
|
2543
|
-
// subject_ref (§3.2):
|
|
2544
|
-
//
|
|
2545
|
-
// Present + malformed → null + a surfaced note (never gates the attestation).
|
|
2254
|
+
// subject_ref (§3.2): null when absent, when well-formed with no side input to
|
|
2255
|
+
// compare against, and when malformed (with a note). Never gates.
|
|
2546
2256
|
function evaluateSubjectRef(subjectRef, errs) {
|
|
2547
2257
|
if (subjectRef === undefined || subjectRef === null)
|
|
2548
2258
|
return null;
|
|
@@ -2557,12 +2267,10 @@ function evaluateSubjectRef(subjectRef, errs) {
|
|
|
2557
2267
|
errs.push("subject_ref is present but not well-formed ({hash: sha256-hex, type: token})");
|
|
2558
2268
|
return null;
|
|
2559
2269
|
}
|
|
2560
|
-
return null;
|
|
2270
|
+
return null;
|
|
2561
2271
|
}
|
|
2562
|
-
// Build the §4 verdict object
|
|
2563
|
-
//
|
|
2564
|
-
// renderer/verify-API display; it is compared (over its deterministic subset)
|
|
2565
|
-
// to the cached body.kernel_verdict in step 5.
|
|
2272
|
+
// Build the §4 verdict object from the recomputed source result and the
|
|
2273
|
+
// attestations: what surfaces display, compared with the cached copy in step 5.
|
|
2566
2274
|
function buildVerdictObject(source, attestations) {
|
|
2567
2275
|
const attByCheckpoint = new Map();
|
|
2568
2276
|
for (const a of attestations) {
|
|
@@ -2611,7 +2319,7 @@ function buildVerdictObject(source, attestations) {
|
|
|
2611
2319
|
}
|
|
2612
2320
|
return {
|
|
2613
2321
|
schema_version: "sanning.evidence.verdict/v1",
|
|
2614
|
-
status: source.
|
|
2322
|
+
status: source.unreadable === true ? "failed" : source.status,
|
|
2615
2323
|
counts: { verified, failed, undetermined },
|
|
2616
2324
|
events,
|
|
2617
2325
|
checkpoints,
|
|
@@ -2622,8 +2330,7 @@ function toVerdictOnChain(o) {
|
|
|
2622
2330
|
return {
|
|
2623
2331
|
rollup: o.rollup,
|
|
2624
2332
|
on_chain_ok: o.onChainOk,
|
|
2625
|
-
//
|
|
2626
|
-
// enrichment (optional in the §4.4 schema) the offline verifier does not
|
|
2333
|
+
// `block_height` is issuer-side enrichment the offline verifier does not
|
|
2627
2334
|
// recompute.
|
|
2628
2335
|
per_gateway: o.perGateway.map((g) => ({ gateway: g.gateway, outcome: g.outcome })),
|
|
2629
2336
|
};
|
|
@@ -2632,15 +2339,12 @@ function eventVerdictStatus(e) {
|
|
|
2632
2339
|
if (!e.ok)
|
|
2633
2340
|
return "failed";
|
|
2634
2341
|
if (e.payloadBindingOk === null)
|
|
2635
|
-
return "
|
|
2342
|
+
return "incomplete"; // withheld record
|
|
2636
2343
|
return "verified";
|
|
2637
2344
|
}
|
|
2638
|
-
// The §5 step
|
|
2639
|
-
//
|
|
2640
|
-
//
|
|
2641
|
-
// nullish normalization) — a structural difference surfaces as disagreement,
|
|
2642
|
-
// which is the correct tamper signal. Works on both the cached snake_case JSON
|
|
2643
|
-
// and the freshly built VerdictObject.
|
|
2345
|
+
// The §5 step 5 projection: only what an offline verifier deterministically
|
|
2346
|
+
// recomputes. Tolerates a garbled cached verdict: a structural difference
|
|
2347
|
+
// reads as disagreement, which is the right signal.
|
|
2644
2348
|
function projectVerdictForAgreement(v) {
|
|
2645
2349
|
const o = (v ?? {});
|
|
2646
2350
|
const events = Array.isArray(o.events) ? o.events : [];
|
|
@@ -2663,7 +2367,7 @@ function projectVerdictForAgreement(v) {
|
|
|
2663
2367
|
return {
|
|
2664
2368
|
checkpoint_ref: cp.checkpoint_ref ?? null,
|
|
2665
2369
|
merkle_root_ok: cp.merkle_root_ok ?? null,
|
|
2666
|
-
// on_chain
|
|
2370
|
+
// on_chain omitted: environment and time dependent.
|
|
2667
2371
|
attestations: atts.map((a) => {
|
|
2668
2372
|
const at = (a ?? {});
|
|
2669
2373
|
return {
|
|
@@ -2678,11 +2382,9 @@ function projectVerdictForAgreement(v) {
|
|
|
2678
2382
|
}),
|
|
2679
2383
|
};
|
|
2680
2384
|
}
|
|
2681
|
-
// Per event of an export's inline
|
|
2682
|
-
//
|
|
2683
|
-
//
|
|
2684
|
-
// not JCS is not governed here either. The Python twin is
|
|
2685
|
-
// `evidence._source_events_governed`.
|
|
2385
|
+
// Per event of an export's inline source, in order: whether §4.1 governs its
|
|
2386
|
+
// record, decoded as the content binding decodes it. Python's twin is
|
|
2387
|
+
// `_source_events_governed`.
|
|
2686
2388
|
function sourceEventsGoverned(source) {
|
|
2687
2389
|
if (source === null || typeof source !== "object" || Array.isArray(source))
|
|
2688
2390
|
return [];
|
|
@@ -2718,17 +2420,13 @@ function sourceEventsGoverned(source) {
|
|
|
2718
2420
|
}
|
|
2719
2421
|
});
|
|
2720
2422
|
}
|
|
2721
|
-
// The one difference §5 step 5
|
|
2722
|
-
//
|
|
2723
|
-
//
|
|
2724
|
-
//
|
|
2725
|
-
//
|
|
2726
|
-
//
|
|
2727
|
-
|
|
2728
|
-
// difference on a record outside §4.1 still disagree. Edits `fresh` in place
|
|
2729
|
-
// to match `cached` on those events. The Python twin is
|
|
2730
|
-
// `evidence._tolerate_pre_a20_content`.
|
|
2731
|
-
function toleratePreA20Content(cached, fresh, governed) {
|
|
2423
|
+
// The one difference §5 step 5 tolerates: a cached `content_ok` of `null`
|
|
2424
|
+
// against a recomputed `true`, on an event whose record §4.1 governs. A composer
|
|
2425
|
+
// that could not bind hand-off messages claims less than the recompute proves,
|
|
2426
|
+
// which is not tamper. Only that direction; every other difference still
|
|
2427
|
+
// disagrees. Edits `fresh` in place. Python's twin is
|
|
2428
|
+
// `_tolerate_unbound_message_content`.
|
|
2429
|
+
function tolerateUnboundMessageContent(cached, fresh, governed) {
|
|
2732
2430
|
const n = Math.min(cached.events.length, fresh.events.length);
|
|
2733
2431
|
for (let i = 0; i < n; i++) {
|
|
2734
2432
|
if (governed[i] === true &&
|
|
@@ -2744,69 +2442,35 @@ function summarizeExport(status, source, attestations) {
|
|
|
2744
2442
|
`(${source.events.length} event(s) across ${source.checkpoints.length} checkpoint(s)); ` +
|
|
2745
2443
|
`${attOk}/${attestations.length} operator attestation(s) valid.`);
|
|
2746
2444
|
}
|
|
2747
|
-
// Re-fetch a checkpoint's
|
|
2748
|
-
//
|
|
2749
|
-
// worst-finding-wins. Unlike the pre-§4.2 first-responsive-wins collapse, this
|
|
2750
|
-
// records a per-gateway array so a confirm/mismatch/unreachable MIX across
|
|
2751
|
-
// gateways is preserved; the derived collapsed `onChainOk` reproduces the old
|
|
2752
|
-
// single-boolean exactly for a single gateway.
|
|
2445
|
+
// Re-fetch a checkpoint's bytes from each gateway and record each outcome
|
|
2446
|
+
// (evidence-export.md §4.2), then roll them up worst-finding-wins.
|
|
2753
2447
|
async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
|
|
2754
2448
|
let expectedHex;
|
|
2755
2449
|
try {
|
|
2756
|
-
//
|
|
2757
|
-
//
|
|
2758
|
-
// uploaded has to be recomputed from it. See `refetchStamp` for the case
|
|
2759
|
-
// where this step is not merely unnecessary but WRONG.
|
|
2450
|
+
// The bundle carries the envelope as an object, so its witnessed bytes are
|
|
2451
|
+
// recomputed as JCS. Stamps differ: see `refetchStamp`.
|
|
2760
2452
|
expectedHex = toHex(utf8(jcs(cp.envelope)));
|
|
2761
2453
|
}
|
|
2762
2454
|
catch {
|
|
2763
|
-
//
|
|
2764
|
-
// (we can never confirm bytes we cannot compute).
|
|
2455
|
+
// Bytes we cannot compute can never be confirmed: every gateway mismatches.
|
|
2765
2456
|
expectedHex = undefined;
|
|
2766
2457
|
}
|
|
2767
2458
|
return refetchAnchored("checkpoint", expectedHex, anchorRef, gateways, fetchImpl);
|
|
2768
2459
|
}
|
|
2769
|
-
// Re-fetch a
|
|
2770
|
-
//
|
|
2771
|
-
//
|
|
2772
|
-
//
|
|
2773
|
-
//
|
|
2774
|
-
// witnessed bytes are not in the bundle — only the object they were derived
|
|
2775
|
-
// from is. A stamp is the opposite case: §5.2.2 defines `stamp_bytes` as
|
|
2776
|
-
// "lowercase hex of the exact JCS bytes that were handed to the witness", and
|
|
2777
|
-
// §5.3.2 repeats it — *"the EXACT bytes that were witnessed, verbatim"*. They
|
|
2778
|
-
// are already the bytes, already hex, already lowercase (`isLowerHex` gated
|
|
2779
|
-
// them), and `stamp_ref` is their SHA-256.
|
|
2780
|
-
//
|
|
2781
|
-
// So the expected hex IS `stamp_bytes`, and re-canonicalizing would be a
|
|
2782
|
-
// defect, not a redundancy: it would compare the chain against bytes NOBODY
|
|
2783
|
-
// SIGNED. On a canonical body the two agree and the bug hides; on a body that
|
|
2784
|
-
// is not canonical — the exact case §5.2.2 makes the kernel check for, because
|
|
2785
|
-
// the plane replays these bytes as text precisely so a normalizing serializer
|
|
2786
|
-
// cannot alter them — a re-canonicalizing verifier reports `mismatch` against
|
|
2787
|
-
// a chain that faithfully holds what it was given. A false accusation, from
|
|
2788
|
-
// the verifier, about untampered evidence.
|
|
2789
|
-
//
|
|
2790
|
-
// The bytes handed here are the SAME value `verifyStampEntry` hashed to derive
|
|
2791
|
-
// `stamp_ref`, threaded through rather than re-read from the body, so the bytes
|
|
2792
|
-
// graded and the bytes compared cannot drift apart.
|
|
2460
|
+
// Re-fetch a stamp's witnessed bytes (§5.2.2, §5.3.2). Unlike a checkpoint,
|
|
2461
|
+
// `stamp_bytes` already are the exact witnessed bytes, so they are compared
|
|
2462
|
+
// verbatim: re-canonicalizing would compare the chain against bytes nobody
|
|
2463
|
+
// signed and accuse a faithful chain of mismatch on a non-canonical body. The
|
|
2464
|
+
// hex is the same value `verifyStampEntry` hashed for `stamp_ref`.
|
|
2793
2465
|
async function refetchStamp(witnessedHex, anchorRef, gateways, fetchImpl) {
|
|
2794
2466
|
return refetchAnchored("stamp", witnessedHex, anchorRef, gateways, fetchImpl);
|
|
2795
2467
|
}
|
|
2796
|
-
//
|
|
2797
|
-
// the
|
|
2798
|
-
// copied — a second rollup rule is a second place for "mismatch beats confirm
|
|
2799
|
-
// beats unreachable" to drift, and the drifted copy is the one nobody reads.
|
|
2800
|
-
// The subject only ever selects wording; it never changes a verdict.
|
|
2468
|
+
// Shared by both paths: probe every gateway, roll up with `rollupOnChain`, and
|
|
2469
|
+
// phrase the finding. The subject selects wording only, never a verdict.
|
|
2801
2470
|
async function refetchAnchored(subject, expectedHex, anchorRef, gateways, fetchImpl) {
|
|
2802
|
-
// Only a `kind`
|
|
2803
|
-
//
|
|
2804
|
-
// `
|
|
2805
|
-
// of any other kind is not an Arweave id, so whatever a gateway serves at
|
|
2806
|
-
// that path says nothing about the witness, and comparing it would turn an
|
|
2807
|
-
// unrelated 200 into a false mismatch. No fetch, `onChain: null`, and the
|
|
2808
|
-
// run goes `partial`: the caller asked for the witness check and it was not
|
|
2809
|
-
// made.
|
|
2471
|
+
// Only a `kind` with a witness adapter is re-fetched (§5.1.1: an unrecognised
|
|
2472
|
+
// kind must not fail verification). No fetch, `onChain: null`, and the run is
|
|
2473
|
+
// `partial`.
|
|
2810
2474
|
if (!WITNESS_ADAPTER_KINDS.has(anchorRef.kind)) {
|
|
2811
2475
|
return {
|
|
2812
2476
|
onChain: null,
|
|
@@ -2815,11 +2479,9 @@ async function refetchAnchored(subject, expectedHex, anchorRef, gateways, fetchI
|
|
|
2815
2479
|
error: `no witness adapter for kind '${anchorRef.kind}'; the witness was not checked`,
|
|
2816
2480
|
};
|
|
2817
2481
|
}
|
|
2818
|
-
// The id becomes a URL path segment
|
|
2819
|
-
//
|
|
2820
|
-
//
|
|
2821
|
-
// `a?b`), and the two kernels would then request different URLs. Such an id
|
|
2822
|
-
// is never fetched and never failed: the witness is not checked.
|
|
2482
|
+
// The id becomes a URL path segment: anything but a 43-character base64url id
|
|
2483
|
+
// could escape the gateway's base path or make the kernels request different
|
|
2484
|
+
// URLs. Never fetched, never failed: the witness is not checked.
|
|
2823
2485
|
if (!ARWEAVE_ID_RE.test(anchorRef.id)) {
|
|
2824
2486
|
return {
|
|
2825
2487
|
onChain: null,
|
|
@@ -2848,16 +2510,13 @@ async function refetchAnchored(subject, expectedHex, anchorRef, gateways, fetchI
|
|
|
2848
2510
|
}
|
|
2849
2511
|
return { onChain, unavailable: onChain.rollup === "unreachable", unchecked: null, error };
|
|
2850
2512
|
}
|
|
2851
|
-
// The `anchor_ref.kind` values
|
|
2852
|
-
// (evidence-bundle.md §5.1.1 registers exactly `arweave`). Any other kind is
|
|
2853
|
-
// left unchecked by `refetchAnchored`, never probed.
|
|
2513
|
+
// The `anchor_ref.kind` values with a witness adapter (§5.1.1 registers `arweave`).
|
|
2854
2514
|
const WITNESS_ADAPTER_KINDS = new Set(["arweave"]);
|
|
2855
2515
|
// The only `arweave` ids the adapter looks up: a 43-character base64url
|
|
2856
2516
|
// transaction or data-item id.
|
|
2857
2517
|
const ARWEAVE_ID_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
2858
|
-
//
|
|
2859
|
-
//
|
|
2860
|
-
// CLIs map to exit 3. The order is fixed so the two kernels emit the same list.
|
|
2518
|
+
// Notes for requested re-fetches that were not made. Each keeps "could not be
|
|
2519
|
+
// re-fetched", which both CLIs map to exit 3. Fixed order across kernels.
|
|
2861
2520
|
function pushRefetchGapNotes(errors, subject, gaps) {
|
|
2862
2521
|
if (gaps.unavailable) {
|
|
2863
2522
|
errors.push(`one or more ${subject} could not be re-fetched from the supplied gateways`);
|
|
@@ -2869,8 +2528,7 @@ function pushRefetchGapNotes(errors, subject, gaps) {
|
|
|
2869
2528
|
errors.push(`one or more ${subject} name an Arweave witness id this verifier cannot look up, so it could not be re-fetched`);
|
|
2870
2529
|
}
|
|
2871
2530
|
}
|
|
2872
|
-
// One gateway's outcome
|
|
2873
|
-
// (bytes disagree), or unreachable (no response / !ok / network error).
|
|
2531
|
+
// One gateway's outcome: confirm, mismatch, or unreachable.
|
|
2874
2532
|
async function probeGateway(anchorRef, gateway, expectedHex, fetchImpl) {
|
|
2875
2533
|
const url = `${gateway.replace(/\/+$/, "")}/${anchorRef.id}`;
|
|
2876
2534
|
let resp;
|
|
@@ -2891,10 +2549,9 @@ async function probeGateway(anchorRef, gateway, expectedHex, fetchImpl) {
|
|
|
2891
2549
|
}
|
|
2892
2550
|
if (expectedHex === undefined)
|
|
2893
2551
|
return "mismatch";
|
|
2894
|
-
// The on-chain bytes are the uploaded
|
|
2895
|
-
//
|
|
2896
|
-
//
|
|
2897
|
-
// the load-bearing check.
|
|
2552
|
+
// The on-chain bytes are the uploaded JCS bytes. The structural compare
|
|
2553
|
+
// tolerates a gateway that re-pretty-prints; the canonical match is the
|
|
2554
|
+
// load-bearing check.
|
|
2898
2555
|
if (toHex(onChainBytes) === expectedHex)
|
|
2899
2556
|
return "confirm";
|
|
2900
2557
|
try {
|
|
@@ -2903,14 +2560,12 @@ async function probeGateway(anchorRef, gateway, expectedHex, fetchImpl) {
|
|
|
2903
2560
|
return "confirm";
|
|
2904
2561
|
}
|
|
2905
2562
|
catch {
|
|
2906
|
-
//
|
|
2563
|
+
// Not parseable: a genuine mismatch.
|
|
2907
2564
|
}
|
|
2908
2565
|
return "mismatch";
|
|
2909
2566
|
}
|
|
2910
|
-
// Rollup
|
|
2911
|
-
//
|
|
2912
|
-
// true on confirm, null on all-unreachable. An empty gateway list is treated as
|
|
2913
|
-
// all-unreachable (no signal).
|
|
2567
|
+
// Rollup (§4.2): mismatch if any gateway mismatched, else confirm if any
|
|
2568
|
+
// confirmed, else unreachable. An empty list is all-unreachable.
|
|
2914
2569
|
function rollupOnChain(perGateway) {
|
|
2915
2570
|
let rollup = "unreachable";
|
|
2916
2571
|
if (perGateway.some((g) => g.outcome === "mismatch"))
|
|
@@ -2926,14 +2581,7 @@ function getEventId(env) {
|
|
|
2926
2581
|
}
|
|
2927
2582
|
return "<unknown>";
|
|
2928
2583
|
}
|
|
2929
|
-
// The side-input presence rule lives in
|
|
2930
|
-
//
|
|
2931
|
-
// It used to live here as a private `lookupSideContent`, and `grading.ts`
|
|
2932
|
-
// carried a second, laxer answer to the same question (`hasEntry`: own +
|
|
2933
|
-
// enumerable, nothing more). On one and the same content map the grader said
|
|
2934
|
-
// PRESENT while this verifier said nothing was offered, and `gradePack` then
|
|
2935
|
-
// returned `incomplete` with ZERO causes. Read `side-content.ts` for the rule
|
|
2936
|
-
// and for what each clause of it cost.
|
|
2584
|
+
// The side-input presence rule lives in `./side-content.ts`, stated once.
|
|
2937
2585
|
function selectDisclosedBytes(ev, eventId, sideContent) {
|
|
2938
2586
|
const errors = [];
|
|
2939
2587
|
let inBody;
|
|
@@ -2958,15 +2606,8 @@ function selectDisclosedBytes(ev, eventId, sideContent) {
|
|
|
2958
2606
|
errors.push(`supplied content for ${eventId} is not hex${thrownDetail(e)}`);
|
|
2959
2607
|
}
|
|
2960
2608
|
}
|
|
2961
|
-
// `offered` comes from the
|
|
2962
|
-
// `value !== undefined
|
|
2963
|
-
// remove, and it was removed from ONE of the two operands. The left one
|
|
2964
|
-
// carried it unchanged: an in-body `"content": null` is `!== undefined`, so
|
|
2965
|
-
// the event was marked offered while no bytes were selected, and the kernel
|
|
2966
|
-
// split against itself — `verifyEvidenceBundle` VERIFIED, `disclosedContent`
|
|
2967
|
-
// FAILED, on one bundle. `null` is how an optional field's absence
|
|
2968
|
-
// serializes; `inBodyContentOffered` states that once, beside the side-input
|
|
2969
|
-
// rule it mirrors.
|
|
2609
|
+
// `offered` comes from the same tests that selected the value, never from
|
|
2610
|
+
// `value !== undefined`: an in-body `null` is an absent optional field.
|
|
2970
2611
|
return {
|
|
2971
2612
|
inBody,
|
|
2972
2613
|
side,
|