@sanning/proof 0.10.0 → 0.11.1
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 +4 -4
- package/dist/agent-proof.js +8 -2
- package/dist/case.d.ts +745 -0
- package/dist/case.js +1318 -0
- package/dist/chain.js +20 -1
- package/dist/cli.js +181 -27
- package/dist/crypto.d.ts +1 -0
- package/dist/crypto.js +29 -0
- package/dist/declaration.d.ts +284 -0
- package/dist/declaration.js +674 -0
- package/dist/evidence.js +296 -73
- package/dist/grading.d.ts +65 -4
- package/dist/grading.js +112 -6
- package/dist/handoff.d.ts +209 -0
- package/dist/handoff.js +617 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +50 -0
- package/dist/message-parts.d.ts +283 -0
- package/dist/message-parts.js +903 -0
- package/dist/verifier.d.ts +36 -0
- package/dist/verifier.js +150 -3
- package/package.json +1 -1
- package/src/agent-proof.ts +8 -2
- package/src/case.ts +1573 -0
- package/src/chain.ts +20 -1
- package/src/cli.ts +213 -29
- package/src/crypto.ts +28 -0
- package/src/declaration.ts +1005 -0
- package/src/evidence.ts +306 -71
- package/src/grading.ts +140 -6
- package/src/handoff.ts +694 -0
- package/src/index.ts +127 -0
- package/src/message-parts.ts +998 -0
- package/src/verifier.ts +150 -3
- package/vectors/README.md +36 -1
- package/vectors/jcs-reader.json +166 -0
- package/vectors/pack-grading.json +131 -3
package/src/case.ts
ADDED
|
@@ -0,0 +1,1573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One verdict for a CASE, across packs and across organizations (anchor#198).
|
|
3
|
+
*
|
|
4
|
+
* The TypeScript half of `src/sanning_proof/case.py`. Both kernels are held to
|
|
5
|
+
* the same corpus set, `test-vectors/case/`, so neither can pass by agreeing
|
|
6
|
+
* with the other.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS LAYER EXISTS, AND WHAT IT IS THE CLOSURE FOR
|
|
9
|
+
* =====================================================
|
|
10
|
+
*
|
|
11
|
+
* `envelope-spec.md` 4.1 reads records. `evidence-bundle.md` 5.4 reads one pack
|
|
12
|
+
* against its own declaration. Neither can answer the question an audit team
|
|
13
|
+
* actually asks, and 4.1 (v1.10, A15) and 5.4.2 rule 7 (v1.10) now say so in
|
|
14
|
+
* these words:
|
|
15
|
+
*
|
|
16
|
+
* **A pack a party assembles about itself is not self-proving about what
|
|
17
|
+
* that party LEFT OUT.**
|
|
18
|
+
*
|
|
19
|
+
* `handoffs[]` MAY be empty (5.4.1), so a holder declares a record in
|
|
20
|
+
* `records[]` and claims it nowhere: rule 2 cannot reach it because it is
|
|
21
|
+
* declared, rule 7 cannot reach it because it is unclaimed, and the pack reads
|
|
22
|
+
* `verified` with no deviation, which is the same reading the honest pack gets.
|
|
23
|
+
* The corpus pins that pack as EXPECTED
|
|
24
|
+
* (`test-vectors/declaration/declaration-unclaimed-record-01`) rather than
|
|
25
|
+
* leaving it to be rediscovered. Six repairs treated a property of one-sided
|
|
26
|
+
* evidence as a defect in a rule (proof#137, ratified by Phil 2026-09-22), and
|
|
27
|
+
* the omission happens BEFORE any reader runs, so no rule at either of those
|
|
28
|
+
* layers reaches it.
|
|
29
|
+
*
|
|
30
|
+
* **This module is the closure, and the reason it can be is that it reads
|
|
31
|
+
* several parties' packs at once.** A `handoff.sent` with no counterpart among
|
|
32
|
+
* the records of the case is always NAMED, and when a pack signed by the key
|
|
33
|
+
* that record names is in the case, the reading says that pack holds no
|
|
34
|
+
* counterpart. No party can make a missing counterpart read clean.
|
|
35
|
+
*
|
|
36
|
+
* What it does NOT establish is that a party which presented no pack signed by
|
|
37
|
+
* the named key recorded nothing. Nothing binds a pack's wrapper key to the
|
|
38
|
+
* keys its records are signed with, so a counterparty that hands over its
|
|
39
|
+
* account under any other key, or inside an export an issuer signed, or not at
|
|
40
|
+
* all, gets the reading that says only that no pack in the case is signed by
|
|
41
|
+
* the named key. Proving that a party did NOT record something needs that
|
|
42
|
+
* party's account or a completeness mechanism over its records, and which of
|
|
43
|
+
* those this reading waits for is proof#143.
|
|
44
|
+
*
|
|
45
|
+
* WHAT THAT MEANS FOR HOW THE JOIN IS BUILT
|
|
46
|
+
* =========================================
|
|
47
|
+
*
|
|
48
|
+
* **The case reader does not consult `handoffs[]` to pair anything.** That is
|
|
49
|
+
* not an oversight, it is the whole mechanism. The declaration is written by
|
|
50
|
+
* the party being audited, so a join keyed on it inherits exactly the hole it
|
|
51
|
+
* is meant to close: claim the clean pair, keep the tampered receipt
|
|
52
|
+
* unclaimed, and a declaration-keyed reader never grades the receipt at all.
|
|
53
|
+
* This reader pairs records with records, through `readHandoff`, over the
|
|
54
|
+
* union of every record every pack in the case supplied. `handoffs[]` is read
|
|
55
|
+
* for ONE thing and one only: to name a record its own holder DECLARED and
|
|
56
|
+
* claimed nowhere (`RECORD_UNCLAIMED`), which is the pinned vector above
|
|
57
|
+
* becoming visible. That claim check runs over every record a pack declares,
|
|
58
|
+
* and not only over the ones 4.1 governs, because "is this a conformant
|
|
59
|
+
* hand-off record" is an answer the audited party writes.
|
|
60
|
+
*
|
|
61
|
+
* **Matching is on hashes and identifiers alone and NEVER on tenancy**
|
|
62
|
+
* (anchor#198, and DL-60: producer identity gates what the plane accepts and
|
|
63
|
+
* never whether evidence verifies). The union pile is not partitioned by pack,
|
|
64
|
+
* by issuer, by `tenant_id` or by organization anywhere in this module, so a
|
|
65
|
+
* pair assembled by two organizations reads exactly as a pair assembled by
|
|
66
|
+
* one. There is no code path here that reads a tenant.
|
|
67
|
+
*
|
|
68
|
+
* THE DEFECT CLASS THIS MODULE IS WRITTEN AGAINST
|
|
69
|
+
* ===============================================
|
|
70
|
+
*
|
|
71
|
+
* Seventeen findings on this program share one generator: **a deviation
|
|
72
|
+
* designed as a comparison between two present things, where the deviation
|
|
73
|
+
* itself removes one of them, so the code takes the "nothing to compare"
|
|
74
|
+
* branch written for the honest case.** A rule shaped that way reads the
|
|
75
|
+
* absence of a signal as "fine" rather than as "I could not tell", and a real
|
|
76
|
+
* deviation grades clean.
|
|
77
|
+
*
|
|
78
|
+
* Acceptance criterion 3 of anchor#198 is made of exactly that shape: *a
|
|
79
|
+
* one-sided hand-off is an exception, not a failure*. So the four rules below
|
|
80
|
+
* are structural and are the thing to preserve in any change here.
|
|
81
|
+
*
|
|
82
|
+
* 1. **Every exception is NAMED and appears in the output, and every exception
|
|
83
|
+
* contributes a verdict.** There is no path through this module that
|
|
84
|
+
* removes a record, a pack or a claim from the reading. An empty
|
|
85
|
+
* `exceptions` is the ONLY shape a reader may read as clean, and `readCase`
|
|
86
|
+
* is invariant: `verified` is reachable only when that array is empty.
|
|
87
|
+
* 2. **Nothing a party writes decides whether grading happens.** No graded
|
|
88
|
+
* value is part of any key that selects a code path. The pile is everything
|
|
89
|
+
* every pack supplied; there is no filter.
|
|
90
|
+
* 3. **Every field that selects a code path and is chosen by the party being
|
|
91
|
+
* audited has an accept-set, and an unknown value goes to a named exception
|
|
92
|
+
* rather than to silence.** `body_type` is `CASE_BODY_TYPES`;
|
|
93
|
+
* `declaration_version` is `checkDeclaration`'s; `event_type` and
|
|
94
|
+
* `event.counterparty_public_key` are 4.1's, through `readHandoffs`. A pack
|
|
95
|
+
* that supplies zero records is `PACK_HOLDS_NO_RECORDS` and a case with no
|
|
96
|
+
* hand-off record in it at all is `CASE_HOLDS_NO_HANDOFF_RECORDS`, because
|
|
97
|
+
* "nothing to grade" is never "nothing is wrong".
|
|
98
|
+
* 4. **The 4.1 predicates are CALLED, never restated.** `readHandoffs`,
|
|
99
|
+
* `normalizedSigningKey` and `packRecords` are the one implementation of
|
|
100
|
+
* each rule. A validation copied beside the one that enforces it drifts,
|
|
101
|
+
* and a copy that is looser blesses a value the real one then rejects.
|
|
102
|
+
*
|
|
103
|
+
* EVERY EARLY EXIT IN THIS MODULE, AND WHAT IT SKIPS
|
|
104
|
+
* ==================================================
|
|
105
|
+
*
|
|
106
|
+
* Three defects on this layer were one shape: an exit between a record
|
|
107
|
+
* entering the pile and a check running over it, with the audited party
|
|
108
|
+
* choosing whether the exit was taken. So the exits are enumerated here rather
|
|
109
|
+
* than left to be rediscovered a fourth time. `src/sanning_proof/case.py` has
|
|
110
|
+
* the same exits in the same order, and the pull request that added this list
|
|
111
|
+
* carries the same table.
|
|
112
|
+
*
|
|
113
|
+
* 1. **No packs** (`return`). Skips everything. Reached by the reader, who
|
|
114
|
+
* chose what to hand in. Named: `CASE_HOLDS_NO_PACKS`.
|
|
115
|
+
* 2. **A wrapper key this reader cannot read** (`continue`). Skips the bundle
|
|
116
|
+
* verification, the declaration check and every record in the pack. The
|
|
117
|
+
* holder writes the key, so it steers here freely, and the pack is NAMED
|
|
118
|
+
* (`PACK_KEY_UNREADABLE`). Both bundle verifiers now refuse every such
|
|
119
|
+
* spelling in the same words (proof#142), so this exit and exit 3 agree on
|
|
120
|
+
* which packs they take; it stays first because it names WHY. While any
|
|
121
|
+
* pack takes this exit, a one-sided record whose named key signed no pack
|
|
122
|
+
* reads `COUNTERPART_ABSENT_SIGNER_UNKNOWN` and never the innocent
|
|
123
|
+
* `COUNTERPART_ABSENT`.
|
|
124
|
+
* 3. **The wrapper does not hold** (`continue`): `signatureOk` and
|
|
125
|
+
* `bodyHashOk` are not both true. Skips the declaration check and every
|
|
126
|
+
* record in the pack, and the key it claims is never counted as a pack
|
|
127
|
+
* signer. **This is the forger's case and the only exit that turns on
|
|
128
|
+
* provenance**: those two fields are the whole of what ties a pack to a
|
|
129
|
+
* key. Reached by whoever built the pack, and by anyone who alters its
|
|
130
|
+
* unsigned `signature` field, which is indistinguishable from a forgery.
|
|
131
|
+
* Named: `PACK_DOES_NOT_VERIFY`, and it moves one-sided records to
|
|
132
|
+
* `COUNTERPART_ABSENT_SIGNER_UNKNOWN` exactly as exit 2 does.
|
|
133
|
+
*
|
|
134
|
+
* A pack whose wrapper HOLDS is never sent here, whatever is wrong inside
|
|
135
|
+
* it (gate 3 on proof#140): its key is counted, its own bundle verdict is
|
|
136
|
+
* `PACK_BUNDLE_NOT_VERIFIED` when that verdict is not `verified`, and each
|
|
137
|
+
* record is graded on its own envelope below.
|
|
138
|
+
* 3b. **The bundle verdict is `malformed`** (`continue`, after exit 3 and after
|
|
139
|
+
* `PACK_BUNDLE_NOT_VERIFIED`). The body is one the verifier could not read,
|
|
140
|
+
* so it graded no record. The key is already counted.
|
|
141
|
+
* 4. **`body_type` outside `CASE_BODY_TYPES`** (`continue`). Skips the
|
|
142
|
+
* declaration check and every record in the pack. The holder writes
|
|
143
|
+
* `body_type`, so the holder steers here freely, which is why it is named
|
|
144
|
+
* every time (`PACK_BODY_TYPE_UNKNOWN`) and why the pack's wrapper key is
|
|
145
|
+
* counted ABOVE it: a counterparty cannot leave the damning counterpart
|
|
146
|
+
* line by choosing a body (F3). `sanning.evidence.export/v2` lands here and
|
|
147
|
+
* is not unwrapped.
|
|
148
|
+
* 5. **No records** (`continue`). Skips the declaration check, over nothing.
|
|
149
|
+
* The holder steers here by handing over an empty `events[]`. Named:
|
|
150
|
+
* `PACK_HOLDS_NO_RECORDS`; the wrapper key is already counted.
|
|
151
|
+
* 5b. **A record under a quarantined profile** and 5c. **a record whose own
|
|
152
|
+
* envelope does not verify** (`continue`). Each skips 4.1 for that record,
|
|
153
|
+
* its candidacy for any other record, and the claim check, because 4.1
|
|
154
|
+
* compares only envelopes that verified (`HeldRecord`). Named:
|
|
155
|
+
* `RECORD_PROFILE_QUARANTINED` (`incomplete`, pending DL-63) and
|
|
156
|
+
* `RECORD_ENVELOPE_DOES_NOT_VERIFY` (`failed`). Neither removes a named
|
|
157
|
+
* failure from another record: the other records of the pack are still
|
|
158
|
+
* graded, and pairs still form over every record that verified.
|
|
159
|
+
* 6. **`event_bytes` withheld** and 7. **`event_bytes` unreadable**
|
|
160
|
+
* (`continue`). Each skips 4.1 for that record, its candidacy for any other
|
|
161
|
+
* record, and the claim check. The holder steers into both by what it
|
|
162
|
+
* discloses. Each is named in the claim check's place
|
|
163
|
+
* (`RECORD_BYTES_WITHHELD` `incomplete`, `RECORD_BYTES_UNREADABLE`
|
|
164
|
+
* `malformed`), so neither buys a cleaner reading than an unclaimed record
|
|
165
|
+
* would get, and neither decides which counterpart kind another record
|
|
166
|
+
* gets, since that reads wrapper keys.
|
|
167
|
+
*
|
|
168
|
+
* Not exits: a record whose envelope verifies and whose content does not
|
|
169
|
+
* match its commitment is `RECORD_CONTENT_MISMATCH`, and one that fails
|
|
170
|
+
* another check inside its pack is `RECORD_DOES_NOT_VERIFY_IN_ITS_PACK`.
|
|
171
|
+
* Both are named and both STAY in the pile, so neither unsigned content
|
|
172
|
+
* handed in beside a pack nor a broken inclusion proof removes a record
|
|
173
|
+
* from pairing.
|
|
174
|
+
* 8. **`not_a_handoff_record`** (`continue`). Skips the hand-off count and the
|
|
175
|
+
* counterpart, ambiguity and pair reporting, which 4.1 does not give a
|
|
176
|
+
* record outside its scope. The holder steers here by respelling a field.
|
|
177
|
+
* It no longer skips the claim check, which runs ABOVE it because it asks
|
|
178
|
+
* what the pack SAID and not what the record is (F1).
|
|
179
|
+
* 9. **THIS record is malformed** (`continue`). Skips the counterpart,
|
|
180
|
+
* ambiguity and pair reporting, and 4.1 computed none of them for a
|
|
181
|
+
* malformed subject, so nothing is lost. A party steers here only by
|
|
182
|
+
* breaking a record it signed, and the record is named `RECORD_MALFORMED`.
|
|
183
|
+
* A well formed record whose CANDIDATE is malformed does not take this
|
|
184
|
+
* exit: it is `COUNTERPART_MALFORMED` and the reporting below still runs
|
|
185
|
+
* (F2).
|
|
186
|
+
* 10. **`counterpart_absent`** (`continue`). Skips the ambiguity and pair
|
|
187
|
+
* reporting, and there is nothing to skip: 4.1 emits that finding only
|
|
188
|
+
* for an EMPTY candidate list. Which of the three counterpart kinds it
|
|
189
|
+
* gets reads the keys of the packs that verified, counted before exit 4,
|
|
190
|
+
* and whether any pack took exit 2 or 3.
|
|
191
|
+
*
|
|
192
|
+
* Not an exit, and listed because it is one more place this shape could
|
|
193
|
+
* hide: the claim check runs only where the pack's declaration is READABLE. An
|
|
194
|
+
* absent or unreadable one is named once for the pack (`PACK_UNDECLARED`,
|
|
195
|
+
* `PACK_DECLARATION_UNREADABLE`) rather than once per record.
|
|
196
|
+
*
|
|
197
|
+
* THE VERDICT VOCABULARY IS CLOSED
|
|
198
|
+
* ================================
|
|
199
|
+
*
|
|
200
|
+
* `verified`, `failed`, `partial`, `mixed`, `incomplete` and `malformed`,
|
|
201
|
+
* every one of them already in use (`evidence-bundle.md` 3 and
|
|
202
|
+
* `PACK_VERDICTS`). **No new word is coined anywhere in this module**, and
|
|
203
|
+
* `CASE_VERDICTS` is asserted against `PACK_VERDICTS` by the test suite so a
|
|
204
|
+
* future addition here cannot invent one quietly. Everything this module has
|
|
205
|
+
* to say that is not one of those six is an EXCEPTION KIND reported beside the
|
|
206
|
+
* verdict, exactly as `counterpart_absent` and `not_a_handoff_record` are
|
|
207
|
+
* findings beside a verdict in 4.1.
|
|
208
|
+
*
|
|
209
|
+
* A case with more than one outcome is `mixed` (anchor#198, criterion 4).
|
|
210
|
+
*
|
|
211
|
+
* WHAT THIS READER DOES NOT CLAIM
|
|
212
|
+
* ===============================
|
|
213
|
+
*
|
|
214
|
+
* * **Not that the case is complete.** Joining is voluntary. A counterparty
|
|
215
|
+
* that never handed over a pack leaves `COUNTERPART_ABSENT`, which is an
|
|
216
|
+
* exception and never a failure. It says that no pack in the case is signed
|
|
217
|
+
* by the named key, and nothing about what that party recorded.
|
|
218
|
+
* * **Not that a party which presented no pack under the named key recorded
|
|
219
|
+
* nothing.** See the top of this comment, and proof#143.
|
|
220
|
+
* * **Not that two organizations are independent.** One team holding both keys
|
|
221
|
+
* proves which agent said what, not that two parties agreed (4.1, 5.4.4).
|
|
222
|
+
* * **Not that a `verified` case has no other problem.** It reads hand-offs.
|
|
223
|
+
* It runs `verifyEvidenceBundle` over every pack itself, offline and with no
|
|
224
|
+
* gateways, and a pack whose own bundle verdict is not `verified` is named
|
|
225
|
+
* with that verdict and its reasons, so the case never reads above its
|
|
226
|
+
* weakest pack. On-chain re-fetch and `gradePack` are a caller's to run.
|
|
227
|
+
*
|
|
228
|
+
* THE COST OF 4.1's "GRADE EVERY CANDIDATE", INHERITED HERE AND STATED RATHER
|
|
229
|
+
* THAN LEFT TO BE FOUND (proof#134, ratified by Phil 2026-09-22). A reader
|
|
230
|
+
* whose pile holds two or more records of the complementary type signed by one
|
|
231
|
+
* counterparty key cannot reach `verified`: the other exchanges' records are
|
|
232
|
+
* candidates too, they commit to other messages, and they fail. One agent
|
|
233
|
+
* sending two messages to the same counterparty inside one case lands there,
|
|
234
|
+
* and it surfaces as `COUNTERPART_AMBIGUOUS` beside the failure. An
|
|
235
|
+
* alternating exchange (A sends, B replies) does NOT, because a reply is
|
|
236
|
+
* signed by the other key and is the other type. Narrowing that here with
|
|
237
|
+
* `handoffs[]` is precisely the move that would reopen proof#137's door.
|
|
238
|
+
*/
|
|
239
|
+
|
|
240
|
+
import {
|
|
241
|
+
type DeclarationClaims,
|
|
242
|
+
checkDeclaration,
|
|
243
|
+
declarationClaims,
|
|
244
|
+
packRecords,
|
|
245
|
+
} from "./declaration.js";
|
|
246
|
+
import {
|
|
247
|
+
type HandoffReading,
|
|
248
|
+
type HeldRecord,
|
|
249
|
+
NOT_A_HANDOFF_FINDING,
|
|
250
|
+
normalizedSigningKey,
|
|
251
|
+
readHandoffs,
|
|
252
|
+
recordIsMalformed,
|
|
253
|
+
} from "./handoff.js";
|
|
254
|
+
import { verifyEvidenceBundle } from "./evidence.js";
|
|
255
|
+
import type { EvidenceBundleResult } from "./evidence.js";
|
|
256
|
+
import type { PackVerdict } from "./grading.js";
|
|
257
|
+
import type { SideContentMap } from "./side-content.js";
|
|
258
|
+
|
|
259
|
+
// ---- The vocabulary ---------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The verdicts a case can reach. Every one is already in use in
|
|
263
|
+
* `evidence-bundle.md` 3 or `PACK_VERDICTS`, and anchor#198 says in terms that
|
|
264
|
+
* no new verdict word is introduced anywhere.
|
|
265
|
+
*/
|
|
266
|
+
export const CASE_VERDICTS = [
|
|
267
|
+
"verified",
|
|
268
|
+
"failed",
|
|
269
|
+
"partial",
|
|
270
|
+
"mixed",
|
|
271
|
+
"incomplete",
|
|
272
|
+
"malformed",
|
|
273
|
+
] as const;
|
|
274
|
+
|
|
275
|
+
export type CaseVerdict = (typeof CASE_VERDICTS)[number];
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* anchor#198 says in terms that no new verdict word is introduced anywhere, and
|
|
279
|
+
* this line is what ENFORCES it rather than asserting it in prose.
|
|
280
|
+
*
|
|
281
|
+
* `tsc` refuses to compile if any member of `CASE_VERDICTS` is not already a
|
|
282
|
+
* `PackVerdict`, so a seventh word added here fails the BUILD rather than
|
|
283
|
+
* reaching a test that might not cover it. The Python half asserts the same
|
|
284
|
+
* containment at run time against `grading.PACK_VERDICTS`, which is that
|
|
285
|
+
* language's equivalent gate.
|
|
286
|
+
*/
|
|
287
|
+
const _caseVerdictsAreSharedVocabulary: PackVerdict[] = [...CASE_VERDICTS];
|
|
288
|
+
void _caseVerdictsAreSharedVocabulary;
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The two pack bodies that carry `events[]` and may carry a `declaration`
|
|
292
|
+
* (`evidence-bundle.md` 5.4.1). This is an ACCEPT-SET and not a hint.
|
|
293
|
+
*
|
|
294
|
+
* `body_type` selects a code path and is written by the party assembling the
|
|
295
|
+
* pack. Without this set, a pack whose records live under a key this reader
|
|
296
|
+
* does not know yields zero records, contributes nothing, and lets the rest of
|
|
297
|
+
* the case read clean over it: the exact defect class this module is written
|
|
298
|
+
* against. An unknown value is `PACK_BODY_TYPE_UNKNOWN`.
|
|
299
|
+
*
|
|
300
|
+
* **`sanning.evidence.export/v2` is NOT in this set and is NOT unwrapped.** It
|
|
301
|
+
* verifies as a bundle and carries a whole `sanning.anchor.trace/v3` pack under
|
|
302
|
+
* `body.source_bundle`, so its records do not reach the pile and the export is
|
|
303
|
+
* named `PACK_BODY_TYPE_UNKNOWN`, `incomplete`. Reading inside it changes which
|
|
304
|
+
* packs this reader grades, so it is a decision of its own. A reader who needs
|
|
305
|
+
* the records hands in the source bundle as a pack. The export's wrapper key is
|
|
306
|
+
* still read, so wrapping a pack in one does not change which counterpart kind
|
|
307
|
+
* another record gets.
|
|
308
|
+
*/
|
|
309
|
+
export const CASE_BODY_TYPES = [
|
|
310
|
+
"sanning.anchor.trace/v3",
|
|
311
|
+
"sanning.stamp.evidence/v2",
|
|
312
|
+
] as const;
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The envelope profiles the case reader reads records under: the ratified
|
|
316
|
+
* accept-set (`envelope-spec.md` 5) minus every profile under quarantine in
|
|
317
|
+
* `cross-kernel/accept-set.json`. A test in each kernel derives both arrays
|
|
318
|
+
* from that file, and a test here pins this one equal to this kernel's own
|
|
319
|
+
* `ACCEPTED_SPEC_MAJORS`, which is what `verifyEvidenceBundle` reads under:
|
|
320
|
+
* the Python kernel narrows its verifier to this set for the case reader, and
|
|
321
|
+
* this kernel's verifier already reads exactly it. If this kernel ever admits
|
|
322
|
+
* a quarantined profile, that test fails, and the case reader has to narrow
|
|
323
|
+
* its verifier the way the Python one does before it can pass.
|
|
324
|
+
*/
|
|
325
|
+
export const CASE_RECORD_PROFILES = ["sanning.agent/v1", "sanning.events/v2"] as const;
|
|
326
|
+
|
|
327
|
+
/** The ratified profiles under quarantine, pending DL-63. */
|
|
328
|
+
export const CASE_QUARANTINED_PROFILES = ["sanning.mlflow/v1"] as const;
|
|
329
|
+
|
|
330
|
+
// ---- The exceptions ---------------------------------------------------------
|
|
331
|
+
//
|
|
332
|
+
// Each of these is a NAME for something a reader would otherwise have to infer
|
|
333
|
+
// from an absence. None of them is a verdict word.
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* A conformant hand-off record with no counterpart among the records this
|
|
337
|
+
* reader read, where every pack in the case verified under a key this reader
|
|
338
|
+
* can read and NO pack in the case is signed by the key the record names.
|
|
339
|
+
* **An exception, never a failure** (anchor#198, criterion 3).
|
|
340
|
+
*
|
|
341
|
+
* That is ALL it says. It does not say the counterparty's account is absent,
|
|
342
|
+
* and it does not say nothing was tampered with: a counterparty that signs its
|
|
343
|
+
* own pack under a second key, or hands over an export an issuer signed,
|
|
344
|
+
* reaches this reading too, and nothing offline binds a pack's key to the keys
|
|
345
|
+
* of the records inside it (proof#143). The rendered text states the fact and
|
|
346
|
+
* stops.
|
|
347
|
+
*
|
|
348
|
+
* It says nothing about RECORDS signed by that key either. A party may hold records
|
|
349
|
+
* its counterparty signed, and those are still graded as candidates, but they
|
|
350
|
+
* are not the counterparty's account of itself, so they do not move this
|
|
351
|
+
* record to `COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS`.
|
|
352
|
+
*/
|
|
353
|
+
export const COUNTERPART_ABSENT = "counterpart_absent";
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* THE CLOSURE anchor#198 exists for.
|
|
357
|
+
*
|
|
358
|
+
* A conformant hand-off record with no counterpart, where a pack that VERIFIES
|
|
359
|
+
* and is SIGNED by the key this record names is in the case, and not one
|
|
360
|
+
* record this reader could read is this record's counterpart. It states that a
|
|
361
|
+
* pack signed by the named key is present and holds no counterpart this reader
|
|
362
|
+
* could read, and nothing more. 4.1 (v1.10, A15) and 5.4.2 rule 7 (v1.10) both point here, and
|
|
363
|
+
* `declaration/declaration-unclaimed-record-01` is the pack that reaches this
|
|
364
|
+
* line.
|
|
365
|
+
*
|
|
366
|
+
* **"Its own account" is read from the key that signed the PACK, and never
|
|
367
|
+
* from the keys that signed records.** A pack's wrapper key signs `body_hash`
|
|
368
|
+
* and `body` carries the declaration (`evidence-bundle.md` 5.4, "Who signs
|
|
369
|
+
* it"), so a wrapper signed by B is provenance the accusing party cannot forge
|
|
370
|
+
* without B's private key. Records signed by B that A merely HOLDS are not B's
|
|
371
|
+
* account of itself: keyed on record keys, one pack from A plus one unrelated
|
|
372
|
+
* record B signed at some other time reached this line when B had handed over
|
|
373
|
+
* nothing at all.
|
|
374
|
+
*
|
|
375
|
+
* What it does NOT prove, stated so no surface claims it: that B handed the
|
|
376
|
+
* pack over for THIS case. A pack B signed for some other purpose and a third
|
|
377
|
+
* party replayed reads the same, because the only values that could say
|
|
378
|
+
* otherwise (the declaration's `case_id` and `period`) are written by B and
|
|
379
|
+
* would let B leave this line by writing them differently. The cost the other
|
|
380
|
+
* way: a pack B assembles under any key other than the one its records name, a
|
|
381
|
+
* per-organization hand-over key (anchor#196) included, reads
|
|
382
|
+
* `COUNTERPART_ABSENT`, because nothing in the case binds that key to B. That
|
|
383
|
+
* reading is still true: no pack signed by the named key is here. So a
|
|
384
|
+
* counterparty can AVOID this line by choosing the key it signs under; what it
|
|
385
|
+
* cannot do is make the missing counterpart read clean.
|
|
386
|
+
*
|
|
387
|
+
* It is deliberately NOT also conditioned on a record signed by B being in the
|
|
388
|
+
* pile. B's pack can hold none this reader reads (a body it does not unwrap,
|
|
389
|
+
* bytes B withheld, an empty `events[]`), and each of those is a choice B makes
|
|
390
|
+
* and is named on its own. A second conjunct on them hands B the innocent line
|
|
391
|
+
* for making one.
|
|
392
|
+
*
|
|
393
|
+
* It is a DISTINCT kind from `COUNTERPART_ABSENT` and not a louder spelling of
|
|
394
|
+
* it. Collapsing the two would be the defect class again: "the counterparty
|
|
395
|
+
* never joined" and "the counterparty joined and its own account does not
|
|
396
|
+
* contain this hand-off" are the honest reading and the damning one, and one
|
|
397
|
+
* name for both lets the second read as the first.
|
|
398
|
+
*
|
|
399
|
+
* It is still an EXCEPTION and not a failure, which criterion 3 requires and
|
|
400
|
+
* which is also the honest call: the counterparty's pack is a selection over
|
|
401
|
+
* its own interval, and a hand-off outside that interval is legitimately not
|
|
402
|
+
* in it. What this names is that the question can now be ASKED of the other
|
|
403
|
+
* party's own evidence, which is what no single pack could ever support.
|
|
404
|
+
*/
|
|
405
|
+
export const COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS =
|
|
406
|
+
"counterpart_absent_from_counterparty_records";
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* A conformant hand-off record with no counterpart, no VERIFIED pack in the
|
|
410
|
+
* case signed by the key it names, and at least one pack in the case that this
|
|
411
|
+
* reader could not tie to a key: its wrapper key is unreadable
|
|
412
|
+
* (`PACK_KEY_UNREADABLE`) or it does not verify (`PACK_DOES_NOT_VERIFY`).
|
|
413
|
+
* Whether a pack signed by the named key is in the case is UNKNOWN, so neither
|
|
414
|
+
* of the other two kinds is true.
|
|
415
|
+
*
|
|
416
|
+
* The gate on the previous version of this reader showed why it exists. A
|
|
417
|
+
* counterparty that signed its own pack under `" " + key` got
|
|
418
|
+
* `PACK_KEY_UNREADABLE` for the pack and the innocent `COUNTERPART_ABSENT` for
|
|
419
|
+
* the record, and the output contradicted itself: whose pack it was is
|
|
420
|
+
* UNKNOWN, and then no pack is signed by the named key. The second sentence
|
|
421
|
+
* was not established.
|
|
422
|
+
*
|
|
423
|
+
* `incomplete`, like the other two one-sided kinds: "I could not tell".
|
|
424
|
+
*/
|
|
425
|
+
export const COUNTERPART_ABSENT_SIGNER_UNKNOWN = "counterpart_absent_signer_unknown";
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* More than one candidate counterpart (4.1, and proof#134's stated cost). The
|
|
429
|
+
* reading is the worst of them and the ambiguity is named: a reader permitted
|
|
430
|
+
* to take one match can be handed an honest decoy beside a tampered record.
|
|
431
|
+
*/
|
|
432
|
+
export const COUNTERPART_AMBIGUOUS = "counterpart_ambiguous";
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* A formed pair that failed a check. Carries which checks, from 4.1's own
|
|
436
|
+
* fixed order, so a reader is told the record AND the check.
|
|
437
|
+
*/
|
|
438
|
+
export const PAIR_FAILED = "pair_failed";
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* THIS record is wrong: 4.1 governs it and `recordIsMalformed` says so of the
|
|
442
|
+
* record itself. A statement about the record this exception names, and about
|
|
443
|
+
* no other.
|
|
444
|
+
*/
|
|
445
|
+
export const RECORD_MALFORMED = "record_malformed";
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* A CANDIDATE counterpart of this record is malformed, and this record is not.
|
|
449
|
+
*
|
|
450
|
+
* 4.1 grades every candidate and reports the worst of them, and `malformed`
|
|
451
|
+
* outranks `failed`, so one junk record signed by the counterparty's key makes
|
|
452
|
+
* the reading of a perfectly well formed record `malformed` too. Filed under
|
|
453
|
+
* `RECORD_MALFORMED`, that read as "this record is not a well formed hand-off
|
|
454
|
+
* record" about the HONEST record, and the branch it took skipped the
|
|
455
|
+
* counterpart, ambiguity and `failedChecks` reporting entirely: one junk record
|
|
456
|
+
* both blamed the wrong party and stripped a named `message_hash` failure out
|
|
457
|
+
* of the case. That is proof#133 ruling 6, which 4.1 closed by keeping the
|
|
458
|
+
* UNION of `failedChecks`, reappearing one layer out because this layer
|
|
459
|
+
* discarded that union.
|
|
460
|
+
*
|
|
461
|
+
* A record's own conformance and its candidate's are different facts and do
|
|
462
|
+
* not share a kind. This one is `incomplete` and not `malformed`: the proven
|
|
463
|
+
* defect belongs to the candidate, which carries its own `RECORD_MALFORMED`
|
|
464
|
+
* line, and what is true of THIS record is that its pairing could not be
|
|
465
|
+
* determined. Contributing `malformed` here would attribute the candidate's
|
|
466
|
+
* defect to the record a party being accused did not write.
|
|
467
|
+
*/
|
|
468
|
+
export const COUNTERPART_MALFORMED = "counterpart_malformed";
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* A record that its own pack DECLARES and its own pack's READABLE declaration
|
|
472
|
+
* claims in no `handoffs[]` entry. This is proof#137's door, named at the layer
|
|
473
|
+
* that can see it: the holder declared the record and claimed it nowhere, so
|
|
474
|
+
* neither 5.4.2 rule 2 nor rule 7 reaches it and the pack reads `verified`.
|
|
475
|
+
*
|
|
476
|
+
* **It turns on what the PACK said about the record, and never on whether 4.1
|
|
477
|
+
* governs it.** Gating it on "is this a conformant hand-off record" put it
|
|
478
|
+
* behind a value the audited party writes: the same second receipt, respelled
|
|
479
|
+
* with an `ff` ligature in `event_type` and a Cyrillic `е` in
|
|
480
|
+
* `counterparty_public_key`, left 4.1's scope, took the `not_a_handoff_record`
|
|
481
|
+
* branch, and bought a case reading of `verified` with zero exceptions where
|
|
482
|
+
* the conformant spelling read `mixed` with four. proof#136 ratified that a
|
|
483
|
+
* respelling buys a DIFFERENT LINE rather than no line; at this layer it
|
|
484
|
+
* bought no line at all, and a strictly better outcome than honesty.
|
|
485
|
+
*
|
|
486
|
+
* It is not an accusation. `handoffs[]` MAY be empty and an unclaimed record
|
|
487
|
+
* may be entirely innocent. What it is, is the end of the silence: the reader
|
|
488
|
+
* cannot tell whether an unclaimed record is part of the hand-over the holder
|
|
489
|
+
* meant to give or one it is keeping out of its own account, and that is "I
|
|
490
|
+
* could not tell", which is why it carries `incomplete` and not `verified`.
|
|
491
|
+
*
|
|
492
|
+
* THE COST, stated rather than left to be found: a pack that declares records
|
|
493
|
+
* which are not hand-off sides at all, an ordinary log line among them, is
|
|
494
|
+
* named once per such record, so a case holding one cannot read `verified`.
|
|
495
|
+
* That is the price of a rule with no branch the holder selects, and the only
|
|
496
|
+
* narrower rule available keys on a value the holder writes. A surface that
|
|
497
|
+
* wants the two apart reads `CaseRecordReading.findings`, where
|
|
498
|
+
* `not_a_handoff_record` still says which is which.
|
|
499
|
+
*/
|
|
500
|
+
export const RECORD_UNCLAIMED = "record_unclaimed_by_its_pack";
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* The pack supplied no `event_bytes` for a record, so this reader cannot see
|
|
504
|
+
* what the record IS in either direction.
|
|
505
|
+
*
|
|
506
|
+
* `event_bytes` is OPTIONAL (5.1, 5.3) and withholding it is minimum
|
|
507
|
+
* disclosure, which the corpus calls a customer's right and not a defect
|
|
508
|
+
* (proof#137, defect 3), so this is not a deviation and does not fail. It is
|
|
509
|
+
* still SAID, because a withheld record is exactly where a missing counterpart
|
|
510
|
+
* would hide, and a reader that dropped it silently would read a trimmed pack
|
|
511
|
+
* as an honest one.
|
|
512
|
+
*/
|
|
513
|
+
export const RECORD_BYTES_WITHHELD = "record_bytes_withheld";
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* The pack SUPPLIED `event_bytes` this reader cannot read as
|
|
517
|
+
* `JCS(event record)` (5.4.2 rule 7's parser rule, one implementation in
|
|
518
|
+
* `packRecords`). Bytes the holder handed over that do not decode are a defect
|
|
519
|
+
* in what was handed over, not an absence, so this one is `malformed`.
|
|
520
|
+
*/
|
|
521
|
+
export const RECORD_BYTES_UNREADABLE = "record_bytes_unreadable";
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The pack deviates from its own declaration (5.4.2). A proven mismatch, so it
|
|
525
|
+
* fails: a case cannot be clean over a pack that is proven to be trimmed.
|
|
526
|
+
*/
|
|
527
|
+
export const PACK_DECLARATION_DEVIATES = "pack_declaration_deviates";
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* The pack and the UNSIGNED content handed in beside it do not line up (5.4.2
|
|
531
|
+
* rules 3 and 4, proof#147, ratified by Phil 2026-09-22). A record the pack
|
|
532
|
+
* declares `disclosed` for which no bytes reached this reader, or one it
|
|
533
|
+
* declares `content_withheld` for which bytes reached this reader from the
|
|
534
|
+
* side-content map alone.
|
|
535
|
+
*
|
|
536
|
+
* `incomplete` and never `failed`, which is the whole point of the kind. 5.4
|
|
537
|
+
* grades a pack's declaration partly against content the READER was handed,
|
|
538
|
+
* and whoever assembles a case fills that map in. Under
|
|
539
|
+
* `PACK_DECLARATION_DEVIATES` an accuser could put an intact pack into a
|
|
540
|
+
* `failed` case WITH NO KEY AT ALL: supply bytes for a record its holder
|
|
541
|
+
* declared withheld, or drop the bytes of one it declared disclosed. The pack
|
|
542
|
+
* was intact, its wrapper verified, and its holder had done nothing. This is
|
|
543
|
+
* the mirror of proof#143, where a counterparty makes ITSELF look better by
|
|
544
|
+
* choosing what it hands over.
|
|
545
|
+
*
|
|
546
|
+
* It is emitted BESIDE `PACK_DECLARATION_DEVIATES` where a pack does both,
|
|
547
|
+
* because the two say different things about different records.
|
|
548
|
+
*/
|
|
549
|
+
export const PACK_DECLARATION_CONTENT_DEPENDENT = "pack_declaration_content_dependent";
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* The pack carries a `declaration` this reader cannot read (5.4.3): an unknown
|
|
553
|
+
* `declaration_version`, an absent or `null` one, a non-object, or one that
|
|
554
|
+
* does not satisfy 5.4.1. `declaration_version` is chosen by the same party
|
|
555
|
+
* that assembles the pack and has no accept-set of its own, so this is UNKNOWN
|
|
556
|
+
* and never clean.
|
|
557
|
+
*/
|
|
558
|
+
export const PACK_DECLARATION_UNREADABLE = "pack_declaration_unreadable";
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The pack carries no `declaration` member at all, so it never said what it
|
|
562
|
+
* holds. `partial` is 5.4.3's own word for a claim nobody made: never
|
|
563
|
+
* `verified`, because there is nothing to check the pack against, and never
|
|
564
|
+
* `failed`, because nothing is wrong.
|
|
565
|
+
*/
|
|
566
|
+
export const PACK_UNDECLARED = "pack_undeclared";
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The pack's `body_type` is not one this reader knows to carry `events[]`
|
|
570
|
+
* (`CASE_BODY_TYPES`).
|
|
571
|
+
*/
|
|
572
|
+
export const PACK_BODY_TYPE_UNKNOWN = "pack_body_type_unknown";
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* The pack carries a body this reader knows and no records in it. A pack that
|
|
576
|
+
* contributes nothing is named rather than passed over.
|
|
577
|
+
*/
|
|
578
|
+
export const PACK_HOLDS_NO_RECORDS = "pack_holds_no_records";
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* The key the pack's wrapper names is not one this reader can read as a key
|
|
582
|
+
* (`normalizedSigningKey` returns nothing for it), so who signed the pack is
|
|
583
|
+
* UNKNOWN. The pack is not verified and none of its records are read, and
|
|
584
|
+
* every one-sided record whose named key signed no verified pack reads
|
|
585
|
+
* `COUNTERPART_ABSENT_SIGNER_UNKNOWN`.
|
|
586
|
+
*
|
|
587
|
+
* Found by constructing it, while enumerating this module's exits: the wrapper
|
|
588
|
+
* key is the ONE input that separates the two counterpart kinds, and a key this
|
|
589
|
+
* reader could not read used to drop out of that set in silence. The Python
|
|
590
|
+
* `verify_evidence_bundle` accepts a wrapper `public_key` with ASCII
|
|
591
|
+
* whitespace inside it or around it (`bytes.fromhex` skips it; this kernel's
|
|
592
|
+
* verifier rejects it), so a counterparty that signed its own pack under
|
|
593
|
+
* `" " + key` handed a Python reader a pack that verified, contributed no key,
|
|
594
|
+
* and moved the subject from `COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS` to
|
|
595
|
+
* the innocent `COUNTERPART_ABSENT`. The same repair 4.1 made for a RECORD
|
|
596
|
+
* whose envelope key it cannot read (proof#133, fourth hunt): a key a reader
|
|
597
|
+
* holds and cannot read is named, never treated as absent. It is `incomplete`
|
|
598
|
+
* rather than `malformed` because whether the spelling verifies is the envelope
|
|
599
|
+
* layer's call (proof#135), and this reader only knows it cannot read it.
|
|
600
|
+
*/
|
|
601
|
+
export const PACK_KEY_UNREADABLE = "pack_key_unreadable";
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* The pack does not verify as a bundle: `verifyEvidenceBundle`, run here
|
|
605
|
+
* offline over the pack and its side content, returned `failed` or
|
|
606
|
+
* `malformed`. None of its records are read, because a record from it would
|
|
607
|
+
* reach 4.1 without the verified envelope `HeldRecord` requires, and the key
|
|
608
|
+
* its wrapper claims is never counted as a pack signer: an unverified wrapper
|
|
609
|
+
* claiming the counterparty's key is exactly what an accusing party would
|
|
610
|
+
* forge.
|
|
611
|
+
*
|
|
612
|
+
* `partial` is not this exception. `partial` means the wrapper signature and
|
|
613
|
+
* `body_hash` held and part of the body could not be determined, which is what
|
|
614
|
+
* an unknown body or withheld bytes produce, and those are named on their own.
|
|
615
|
+
*
|
|
616
|
+
* `failed`: a pack in the case is proven broken.
|
|
617
|
+
*/
|
|
618
|
+
export const PACK_DOES_NOT_VERIFY = "pack_does_not_verify";
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The pack's wrapper HOLDS and its own bundle verdict, from
|
|
622
|
+
* `verifyEvidenceBundle` run here offline over the pack and the content handed
|
|
623
|
+
* in beside it, is not `verified`. The exception carries that verdict
|
|
624
|
+
* (`status`) and the verifier's reasons (`reasons`: its bundle errors, then its
|
|
625
|
+
* stamp errors, then its checkpoint errors, each as the verifier wrote it), and
|
|
626
|
+
* it CONTRIBUTES that verdict, so the case verdict never reads above its
|
|
627
|
+
* weakest pack. Gate 3 on proof#140: two unwitnessed (`partial`) packs read as
|
|
628
|
+
* a `verified` case with no exception. It does not say who is at fault:
|
|
629
|
+
* content handed in beside a pack is not signed by its holder.
|
|
630
|
+
*/
|
|
631
|
+
export const PACK_BUNDLE_NOT_VERIFIED = "pack_bundle_not_verified";
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* A record in a pack whose wrapper holds, whose OWN envelope does not verify
|
|
635
|
+
* (`EventResult.envelopeOk` is false). Not read by 4.1, and NAMED with the
|
|
636
|
+
* verifier's reasons. `failed`: a pack its holder signed carries a record that
|
|
637
|
+
* is proven not to verify. As a reason to drop the whole pack it let the
|
|
638
|
+
* counterparty add one junk event to its own pack, re-seal it, and strip a
|
|
639
|
+
* proven `message_hash` failure out of the case (gate 3 on proof#140).
|
|
640
|
+
*/
|
|
641
|
+
export const RECORD_ENVELOPE_DOES_NOT_VERIFY = "record_envelope_does_not_verify";
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* A record whose envelope verifies and which fails another check the bundle
|
|
645
|
+
* verifier grades it on inside its pack (its inclusion proof, its join to a
|
|
646
|
+
* checkpoint or stamp, a retired field name). NAMED with the verifier's
|
|
647
|
+
* reasons, `failed`, and the record STAYS in the pile: a holder that could
|
|
648
|
+
* remove its own tampered receipt from pairing by breaking that receipt's
|
|
649
|
+
* inclusion proof would be choosing its own reading.
|
|
650
|
+
*/
|
|
651
|
+
export const RECORD_DOES_NOT_VERIFY_IN_ITS_PACK = "record_does_not_verify_in_its_pack";
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* A record whose envelope verifies and whose disclosed content does not hash to
|
|
655
|
+
* the `content_hash` it commits to (`EventResult.contentOk` is false). `failed`,
|
|
656
|
+
* and the record STAYS in the pile: content beside a pack is unsigned and often
|
|
657
|
+
* supplied by whoever assembled the case, and gate 3 on proof#140 showed that
|
|
658
|
+
* party erasing an honest verified pair by swapping it.
|
|
659
|
+
*/
|
|
660
|
+
export const RECORD_CONTENT_MISMATCH = "record_content_mismatch";
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* A record signed under a ratified profile that is under quarantine
|
|
664
|
+
* (`CASE_QUARANTINED_PROFILES`, pending DL-63). Not read, in any kernel.
|
|
665
|
+
* `incomplete`: whether it verifies is what the kernels disagree about.
|
|
666
|
+
*/
|
|
667
|
+
export const RECORD_PROFILE_QUARANTINED = "record_profile_quarantined";
|
|
668
|
+
|
|
669
|
+
/** The reader was handed no packs at all. */
|
|
670
|
+
export const CASE_HOLDS_NO_PACKS = "case_holds_no_packs";
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Every pack was read and not one hand-off record came out of any of them.
|
|
674
|
+
* There is no hand-off to have a verdict about, and saying `verified` over that
|
|
675
|
+
* would be the defect class in its purest form.
|
|
676
|
+
*/
|
|
677
|
+
export const CASE_HOLDS_NO_HANDOFF_RECORDS = "case_holds_no_handoff_records";
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Every exception this reader can report, as a closed set. A surface may render
|
|
681
|
+
* them and a test may assert over them; nothing outside this array is emitted.
|
|
682
|
+
*/
|
|
683
|
+
export const CASE_EXCEPTION_KINDS = [
|
|
684
|
+
COUNTERPART_ABSENT,
|
|
685
|
+
COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS,
|
|
686
|
+
COUNTERPART_ABSENT_SIGNER_UNKNOWN,
|
|
687
|
+
COUNTERPART_AMBIGUOUS,
|
|
688
|
+
COUNTERPART_MALFORMED,
|
|
689
|
+
PAIR_FAILED,
|
|
690
|
+
RECORD_MALFORMED,
|
|
691
|
+
RECORD_UNCLAIMED,
|
|
692
|
+
RECORD_BYTES_WITHHELD,
|
|
693
|
+
RECORD_BYTES_UNREADABLE,
|
|
694
|
+
PACK_DECLARATION_DEVIATES,
|
|
695
|
+
PACK_DECLARATION_CONTENT_DEPENDENT,
|
|
696
|
+
PACK_DECLARATION_UNREADABLE,
|
|
697
|
+
PACK_UNDECLARED,
|
|
698
|
+
PACK_BODY_TYPE_UNKNOWN,
|
|
699
|
+
PACK_HOLDS_NO_RECORDS,
|
|
700
|
+
PACK_KEY_UNREADABLE,
|
|
701
|
+
PACK_DOES_NOT_VERIFY,
|
|
702
|
+
PACK_BUNDLE_NOT_VERIFIED,
|
|
703
|
+
RECORD_ENVELOPE_DOES_NOT_VERIFY,
|
|
704
|
+
RECORD_DOES_NOT_VERIFY_IN_ITS_PACK,
|
|
705
|
+
RECORD_CONTENT_MISMATCH,
|
|
706
|
+
RECORD_PROFILE_QUARANTINED,
|
|
707
|
+
CASE_HOLDS_NO_PACKS,
|
|
708
|
+
CASE_HOLDS_NO_HANDOFF_RECORDS,
|
|
709
|
+
] as const;
|
|
710
|
+
|
|
711
|
+
export type CaseExceptionKind = (typeof CASE_EXCEPTION_KINDS)[number];
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* What each exception kind contributes to the case verdict. It is a TABLE and
|
|
715
|
+
* not a chain of branches, so "which exceptions move the verdict" is one thing
|
|
716
|
+
* a reader can read, and so that adding a kind without deciding what it means
|
|
717
|
+
* is a missing entry rather than a quiet `verified`.
|
|
718
|
+
*
|
|
719
|
+
* Read the `incomplete` rows as "I could not tell", which is the distinction
|
|
720
|
+
* this whole module turns on. None of them is `verified`: an exception never
|
|
721
|
+
* contributes a clean verdict, which is what makes the invariant in `verdictOf`
|
|
722
|
+
* hold.
|
|
723
|
+
*
|
|
724
|
+
* `PACK_BUNDLE_NOT_VERIFIED` is the one row that is not a constant: it
|
|
725
|
+
* contributes the pack's own bundle verdict, which is `partial`, `failed` or
|
|
726
|
+
* `malformed` and never `verified` (the exception is emitted only when it is
|
|
727
|
+
* not). `null` marks that row, and `readCase` fills it from `status`.
|
|
728
|
+
*/
|
|
729
|
+
const EXCEPTION_VERDICT: Record<CaseExceptionKind, CaseVerdict | null> = {
|
|
730
|
+
[COUNTERPART_ABSENT]: "incomplete",
|
|
731
|
+
[COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS]: "incomplete",
|
|
732
|
+
[COUNTERPART_ABSENT_SIGNER_UNKNOWN]: "incomplete",
|
|
733
|
+
[COUNTERPART_AMBIGUOUS]: "incomplete",
|
|
734
|
+
[COUNTERPART_MALFORMED]: "incomplete",
|
|
735
|
+
[PAIR_FAILED]: "failed",
|
|
736
|
+
[RECORD_MALFORMED]: "malformed",
|
|
737
|
+
[RECORD_UNCLAIMED]: "incomplete",
|
|
738
|
+
[RECORD_BYTES_WITHHELD]: "incomplete",
|
|
739
|
+
[RECORD_BYTES_UNREADABLE]: "malformed",
|
|
740
|
+
[PACK_DECLARATION_DEVIATES]: "failed",
|
|
741
|
+
[PACK_DECLARATION_CONTENT_DEPENDENT]: "incomplete",
|
|
742
|
+
[PACK_DECLARATION_UNREADABLE]: "incomplete",
|
|
743
|
+
[PACK_UNDECLARED]: "partial",
|
|
744
|
+
[PACK_BODY_TYPE_UNKNOWN]: "incomplete",
|
|
745
|
+
[PACK_HOLDS_NO_RECORDS]: "incomplete",
|
|
746
|
+
[PACK_KEY_UNREADABLE]: "incomplete",
|
|
747
|
+
[PACK_DOES_NOT_VERIFY]: "failed",
|
|
748
|
+
[PACK_BUNDLE_NOT_VERIFIED]: null,
|
|
749
|
+
[RECORD_ENVELOPE_DOES_NOT_VERIFY]: "failed",
|
|
750
|
+
[RECORD_DOES_NOT_VERIFY_IN_ITS_PACK]: "failed",
|
|
751
|
+
[RECORD_CONTENT_MISMATCH]: "failed",
|
|
752
|
+
[RECORD_PROFILE_QUARANTINED]: "incomplete",
|
|
753
|
+
[CASE_HOLDS_NO_PACKS]: "partial",
|
|
754
|
+
[CASE_HOLDS_NO_HANDOFF_RECORDS]: "partial",
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
// ---- Inputs -----------------------------------------------------------------
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* One pack in a case, as a reader was handed it.
|
|
761
|
+
*
|
|
762
|
+
* `ref` is the reader's own name for the pack (a file name, an archive member)
|
|
763
|
+
* and is never read from the pack itself: a value inside the pack is written by
|
|
764
|
+
* the party that assembled it, and two packs are free to claim one name.
|
|
765
|
+
*
|
|
766
|
+
* A caller passes packs as it received them. `readCase` runs
|
|
767
|
+
* `verifyEvidenceBundle` over each one itself, so `HeldRecord`'s precondition
|
|
768
|
+
* (every envelope it compares has verified) is met by this module and cannot
|
|
769
|
+
* be skipped by a caller. A pack that does not verify is
|
|
770
|
+
* `PACK_DOES_NOT_VERIFY`.
|
|
771
|
+
*/
|
|
772
|
+
export interface CasePack {
|
|
773
|
+
ref: string;
|
|
774
|
+
bundle: unknown;
|
|
775
|
+
content?: SideContentMap | null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ---- Outputs ----------------------------------------------------------------
|
|
779
|
+
|
|
780
|
+
/** One named exception, as data. A surface renders its own prose or calls
|
|
781
|
+
* `renderException`. */
|
|
782
|
+
export interface CaseException {
|
|
783
|
+
kind: CaseExceptionKind;
|
|
784
|
+
/** Which pack this is about, by the reader's own name for it. */
|
|
785
|
+
packRef?: string;
|
|
786
|
+
/** Which record, by its envelope `event_id`. */
|
|
787
|
+
eventId?: string;
|
|
788
|
+
/**
|
|
789
|
+
* The counterparty key the record named, for the two counterpart kinds.
|
|
790
|
+
* Normalized (`normalizedSigningKey`), so a reader compares what this module
|
|
791
|
+
* compared.
|
|
792
|
+
*/
|
|
793
|
+
counterpartyKey?: string | null;
|
|
794
|
+
/** Which checks a formed pair failed, in 4.1's fixed order. */
|
|
795
|
+
failedChecks?: string[];
|
|
796
|
+
/** How many of a thing the exception is about, where it is about a count. */
|
|
797
|
+
count?: number;
|
|
798
|
+
/** The pack's own bundle verdict, for `PACK_BUNDLE_NOT_VERIFIED`. */
|
|
799
|
+
status?: CaseVerdict;
|
|
800
|
+
/**
|
|
801
|
+
* What the bundle verifier said, as it said it, for the kinds that carry a
|
|
802
|
+
* verifier's grading: `PACK_BUNDLE_NOT_VERIFIED` and the three record kinds
|
|
803
|
+
* graded on the record's own envelope and content.
|
|
804
|
+
*/
|
|
805
|
+
reasons?: string[];
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* 4.1's reading of one record the case holds, with the pack it came from.
|
|
810
|
+
*
|
|
811
|
+
* EVERY record every pack supplied and this reader could read gets one of
|
|
812
|
+
* these, in pack order and then in bundle order, including the ones that are
|
|
813
|
+
* not hand-off records at all (`findings` carries `not_a_handoff_record`). 4.1
|
|
814
|
+
* (v1.9, A14) requires that and names the reason: the one thing an attacker
|
|
815
|
+
* wants is for a record to be absent from the output.
|
|
816
|
+
*/
|
|
817
|
+
export interface CaseRecordReading {
|
|
818
|
+
packRef: string;
|
|
819
|
+
eventId: string;
|
|
820
|
+
verdict: string | null;
|
|
821
|
+
findings: string[];
|
|
822
|
+
failedChecks: string[];
|
|
823
|
+
counterparts: string[];
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export interface CaseReading {
|
|
827
|
+
verdict: CaseVerdict;
|
|
828
|
+
/**
|
|
829
|
+
* Every exception, in a fixed order: the pack-level ones for each pack in the
|
|
830
|
+
* order the reader was given the packs, then the record-level ones in pack
|
|
831
|
+
* order and bundle order.
|
|
832
|
+
*
|
|
833
|
+
* **An empty array is the only shape a reader may read as clean**, and it is
|
|
834
|
+
* reachable only together with `verdict === "verified"`.
|
|
835
|
+
*/
|
|
836
|
+
exceptions: CaseException[];
|
|
837
|
+
/** One reading per record the case holds and this reader could read. */
|
|
838
|
+
records: CaseRecordReading[];
|
|
839
|
+
/**
|
|
840
|
+
* Every `[contributor, verdict]` the case verdict was computed from, in
|
|
841
|
+
* order. Carried so the verdict is auditable rather than asserted, and so two
|
|
842
|
+
* kernels compare the derivation and not only its answer.
|
|
843
|
+
*/
|
|
844
|
+
contributions: [string, CaseVerdict][];
|
|
845
|
+
/** How many hand-off records the case held. Zero is its own exception. */
|
|
846
|
+
handoffRecords: number;
|
|
847
|
+
/**
|
|
848
|
+
* Whether any witness behind any pack in this case was re-fetched on chain.
|
|
849
|
+
* It is **false** in every reading this function produces, and it is here to
|
|
850
|
+
* be READ rather than inferred.
|
|
851
|
+
*
|
|
852
|
+
* Gate 4 on proof#140, finding 3. A pack is `partial` only when `anchor_ref`
|
|
853
|
+
* is ABSENT, so the honest omission is named and the unverifiable assertion
|
|
854
|
+
* is not: a holder that writes any `anchor_ref` at all reads `verified`,
|
|
855
|
+
* because this reader makes no network call. That is the standing lens
|
|
856
|
+
* inverted, and the answer is not to refuse the assertion, which no offline
|
|
857
|
+
* reader can grade. It is to say what was not checked, so nobody reads a
|
|
858
|
+
* clean case as "a witness held". Re-fetching on chain stays the caller's
|
|
859
|
+
* job, exactly as `evidence-bundle.md` 5.5's does-not-claim list says.
|
|
860
|
+
*
|
|
861
|
+
* Derived from each pack's own `EvidenceBundleResult.onChainChecked` rather
|
|
862
|
+
* than written as a constant, so it stays true of what was actually run:
|
|
863
|
+
* this reader passes no gateways, and it is the ALL of an empty list of
|
|
864
|
+
* checked packs, which is false.
|
|
865
|
+
*/
|
|
866
|
+
onChainChecked: boolean;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ---- The reading ------------------------------------------------------------
|
|
870
|
+
|
|
871
|
+
const bodyTypeOf = (bundle: unknown): unknown =>
|
|
872
|
+
typeof bundle === "object" && bundle !== null
|
|
873
|
+
? (bundle as Record<string, unknown>)["body_type"]
|
|
874
|
+
: undefined;
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* The key that SIGNED the pack wrapper, which is the key that signed the pack's
|
|
878
|
+
* declaration.
|
|
879
|
+
*
|
|
880
|
+
* The wrapper signature covers `body_hash` and `body` carries the
|
|
881
|
+
* `declaration` (`evidence-bundle.md` 5.1, 5.4.1), so this key is the party
|
|
882
|
+
* whose signed account of itself this pack is. Read from the ENVELOPE of the
|
|
883
|
+
* pack and never from `issuer`: `issuer` is a label and DL-60 keeps it out of
|
|
884
|
+
* every reading, while `public_key` is the value the signature binds.
|
|
885
|
+
*
|
|
886
|
+
* It means something only for a pack whose wrapper HOLDS, which is why
|
|
887
|
+
* `readCase` counts it only after its own `verifyEvidenceBundle` call over the
|
|
888
|
+
* pack returned `signatureOk` and `bodyHashOk`.
|
|
889
|
+
*/
|
|
890
|
+
const holderKeyOf = (bundle: unknown): unknown =>
|
|
891
|
+
typeof bundle === "object" && bundle !== null
|
|
892
|
+
? (bundle as Record<string, unknown>)["public_key"]
|
|
893
|
+
: undefined;
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Provenance, and nothing else: the wrapper signature over `body_hash` verified
|
|
897
|
+
* and `body_hash` is the hash of the body (evidence-bundle.md 5.5.1 step 1).
|
|
898
|
+
* Nothing inside the body is part of this answer.
|
|
899
|
+
*/
|
|
900
|
+
const wrapperHolds = (result: EvidenceBundleResult): boolean =>
|
|
901
|
+
result.signatureOk && result.bodyHashOk;
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Why the pack reads what it reads, as the verifier said it: its bundle errors,
|
|
905
|
+
* then each stamp's, then each checkpoint's, then each event's behind the
|
|
906
|
+
* event's own id.
|
|
907
|
+
*
|
|
908
|
+
* The event errors are here as well as on the records' own exceptions on
|
|
909
|
+
* purpose. A pack whose only defect is one record reads `failed` with no
|
|
910
|
+
* bundle-level error at all, and an exception that named the verdict and no
|
|
911
|
+
* reason would send a reader looking for a defect in the pack as a whole.
|
|
912
|
+
*/
|
|
913
|
+
const bundleReasons = (result: EvidenceBundleResult): string[] => [
|
|
914
|
+
...result.errors,
|
|
915
|
+
...(result.stamps ?? []).flatMap((stamp) => stamp.errors),
|
|
916
|
+
...result.checkpoints.flatMap((cp) => cp.errors),
|
|
917
|
+
...result.events.flatMap((ev) => ev.errors.map((e) => `${ev.eventId}: ${e}`)),
|
|
918
|
+
];
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* The `spec_version` of the envelope of `body.events[index]`, as written.
|
|
922
|
+
* `packRecords` and the bundle verifier both walk `body.events` in order, so
|
|
923
|
+
* `index` is the same record in both.
|
|
924
|
+
*/
|
|
925
|
+
function recordSpecVersion(bundle: unknown, index: number): unknown {
|
|
926
|
+
const at = (v: unknown, k: string | number): unknown =>
|
|
927
|
+
typeof v === "object" && v !== null ? (v as Record<string, unknown>)[k] : undefined;
|
|
928
|
+
const events = at(at(bundle, "body"), "events");
|
|
929
|
+
return Array.isArray(events) ? at(at(events[index], "envelope"), "spec_version") : undefined;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* `specVersion` is a quarantined profile, on the same token boundary the
|
|
934
|
+
* envelope layer's own accept-check uses (`specVersionSupported`).
|
|
935
|
+
*/
|
|
936
|
+
function underQuarantine(specVersion: unknown): boolean {
|
|
937
|
+
if (typeof specVersion !== "string") return false;
|
|
938
|
+
return CASE_QUARANTINED_PROFILES.some((m) => {
|
|
939
|
+
if (specVersion === m) return true;
|
|
940
|
+
if (!specVersion.startsWith(`${m}.`)) return false;
|
|
941
|
+
const minor = specVersion.slice(m.length + 1);
|
|
942
|
+
return minor.length > 0 && /^[0-9]+$/.test(minor);
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* The case verdict, from the contributions alone.
|
|
948
|
+
*
|
|
949
|
+
* One outcome is that outcome; more than one is `mixed` (anchor#198,
|
|
950
|
+
* criterion 4). `mixed` is in the 3 vocabulary and coins no word.
|
|
951
|
+
*
|
|
952
|
+
* THE INVARIANT, stated so a mutation test has something to kill: `verified` is
|
|
953
|
+
* reachable ONLY when `exceptions` is empty. Every exception kind carries a
|
|
954
|
+
* non-`verified` contribution (`EXCEPTION_VERDICT`), so this falls out of the
|
|
955
|
+
* table rather than being enforced by a second rule that could drift from it.
|
|
956
|
+
* The assertion is here anyway, because that is the property every one of the
|
|
957
|
+
* seventeen findings on this program violated.
|
|
958
|
+
*/
|
|
959
|
+
function verdictOf(
|
|
960
|
+
exceptions: readonly CaseException[],
|
|
961
|
+
contributions: readonly [string, CaseVerdict][],
|
|
962
|
+
): CaseVerdict {
|
|
963
|
+
// Unreachable through `readCase`, which names a case with no contributions
|
|
964
|
+
// rather than returning one. Defended anyway: an empty aggregate reading as
|
|
965
|
+
// `verified` is the defect class itself.
|
|
966
|
+
if (contributions.length === 0) return "partial";
|
|
967
|
+
const outcomes = new Set(contributions.map(([, v]) => v));
|
|
968
|
+
const verdict: CaseVerdict = outcomes.size === 1 ? [...outcomes][0] : "mixed";
|
|
969
|
+
if (verdict === "verified" && exceptions.length > 0) {
|
|
970
|
+
throw new Error(
|
|
971
|
+
`verified over a non-empty exception list: ${exceptions.map((e) => e.kind).join(", ")}`,
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
return verdict;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Read a whole case: several packs in, one verdict and its exceptions out.
|
|
979
|
+
*
|
|
980
|
+
* The order of the work, and why it is this order:
|
|
981
|
+
*
|
|
982
|
+
* 1. **Each pack is read into records**, through `packRecords`, which is the
|
|
983
|
+
* one implementation of "what does this pack hold". A pack whose `body_type`
|
|
984
|
+
* this reader does not know, or which holds no records, is NAMED here and
|
|
985
|
+
* its records are not silently zero.
|
|
986
|
+
* 2. **Each pack is graded against its own declaration**, through
|
|
987
|
+
* `checkDeclaration`, which is the one implementation of 5.4. A pack proven
|
|
988
|
+
* to deviate from its own claim fails the case.
|
|
989
|
+
* 3. **Every record every pack supplied goes into ONE pile**, with no partition
|
|
990
|
+
* by pack, by issuer or by organization, and the pile is read by
|
|
991
|
+
* `readHandoffs`, which is the one implementation of 4.1. This is where
|
|
992
|
+
* matching happens, and it is on keys and hashes alone.
|
|
993
|
+
* 4. **Each reading becomes an exception or a clean contribution**, and the two
|
|
994
|
+
* counterpart kinds are separated by asking whether a pack SIGNED by the
|
|
995
|
+
* counterparty's key is in the case, a key read from every pack's wrapper
|
|
996
|
+
* before any exit can skip it.
|
|
997
|
+
*
|
|
998
|
+
* A record whose bytes the pack did not supply never reaches the pile, because
|
|
999
|
+
* there is nothing to put in it. That is exactly the shape this module has to
|
|
1000
|
+
* refuse to pass over in silence, so it is named at step 1 and carries
|
|
1001
|
+
* `incomplete`: the reader genuinely cannot tell whether the record it cannot
|
|
1002
|
+
* see is the counterpart that is missing.
|
|
1003
|
+
*/
|
|
1004
|
+
export async function readCase(
|
|
1005
|
+
packs: readonly CasePack[],
|
|
1006
|
+
): Promise<CaseReading> {
|
|
1007
|
+
const exceptions: CaseException[] = [];
|
|
1008
|
+
const contributions: [string, CaseVerdict][] = [];
|
|
1009
|
+
|
|
1010
|
+
const note = (
|
|
1011
|
+
kind: CaseExceptionKind,
|
|
1012
|
+
fields: Omit<CaseException, "kind"> = {},
|
|
1013
|
+
) => {
|
|
1014
|
+
exceptions.push({ kind, ...fields });
|
|
1015
|
+
// `null` is PACK_BUNDLE_NOT_VERIFIED: the pack's own verdict, never verified.
|
|
1016
|
+
const contribution = EXCEPTION_VERDICT[kind] ?? (fields.status as CaseVerdict);
|
|
1017
|
+
contributions.push([fields.eventId ?? fields.packRef ?? "case", contribution]);
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
if (packs.length === 0) {
|
|
1021
|
+
note(CASE_HOLDS_NO_PACKS);
|
|
1022
|
+
return {
|
|
1023
|
+
verdict: verdictOf(exceptions, contributions),
|
|
1024
|
+
exceptions,
|
|
1025
|
+
records: [],
|
|
1026
|
+
contributions,
|
|
1027
|
+
handoffRecords: 0,
|
|
1028
|
+
onChainChecked: false,
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Step 1 and 2, pack by pack, in the order the reader was given them.
|
|
1033
|
+
// Whether every pack's witness was re-fetched on chain, ALL over the packs
|
|
1034
|
+
// this reader verified. It passes no gateways, so this stays false and the
|
|
1035
|
+
// reading says so (see `CaseReading.onChainChecked`).
|
|
1036
|
+
const witnessChecked: boolean[] = [];
|
|
1037
|
+
const held: HeldRecord[] = [];
|
|
1038
|
+
// The pack and the `event_id` behind each pile member, kept beside the pile
|
|
1039
|
+
// rather than parsed back out of a reference string. A reference is for a
|
|
1040
|
+
// human to read; a join rebuilt by splitting one is a join on a value a
|
|
1041
|
+
// holder can put a separator in.
|
|
1042
|
+
const provenance: { position: number; packRef: string; eventId: string }[] =
|
|
1043
|
+
[];
|
|
1044
|
+
// Keyed by the pack's POSITION and never by its `ref`. `ref` is the reader's
|
|
1045
|
+
// own name for a pack, and nothing stops two packs in one archive carrying
|
|
1046
|
+
// the same one: keyed by ref, the second pack's claims silently replaced the
|
|
1047
|
+
// first's and records from one pack were graded against another pack's
|
|
1048
|
+
// declaration. Found by a constructed archive rather than by reading, which
|
|
1049
|
+
// is the only way this class of defect is ever found.
|
|
1050
|
+
const claimsByPack = new Map<number, DeclarationClaims>();
|
|
1051
|
+
|
|
1052
|
+
// Every key that SIGNED A PACK in this case: the wrapper key, which is the
|
|
1053
|
+
// key that signs the pack's declaration. This is the ONE input that separates
|
|
1054
|
+
// the two counterpart kinds (see `COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS`),
|
|
1055
|
+
// and it replaces a set of every key that signed a RECORD anywhere in the
|
|
1056
|
+
// case.
|
|
1057
|
+
//
|
|
1058
|
+
// The record-key set was wrong twice over, and both times in the direction
|
|
1059
|
+
// that matters. It let the ACCUSER manufacture the damning line: one pack,
|
|
1060
|
+
// plus one unrelated record the counterparty signed at some other time, read
|
|
1061
|
+
// "the counterparty's OWN records ARE here" when the counterparty had handed
|
|
1062
|
+
// over nothing (F4). And it let the COUNTERPARTY choose the innocent line: it
|
|
1063
|
+
// was filled inside the record loop, which the `body_type` and no-records
|
|
1064
|
+
// exits `continue` past, so a counterparty that wrapped its own pack in a body
|
|
1065
|
+
// this reader does not read took its keys out of the set (F3), the third
|
|
1066
|
+
// instance of a route already closed once for withheld `event_bytes`.
|
|
1067
|
+
//
|
|
1068
|
+
// A wrapper key closes both. The accuser cannot forge one without the
|
|
1069
|
+
// counterparty's private key, and it is read HERE, first, before any exit
|
|
1070
|
+
// below, from the pack envelope and never from the body, so no body type, no
|
|
1071
|
+
// disclosure choice and no record content decides whether it is read.
|
|
1072
|
+
const holderKeys = new Set<string>();
|
|
1073
|
+
// How many packs this reader could not tie to a key: an unreadable wrapper
|
|
1074
|
+
// key, or a wrapper that does not hold. While it is not zero, "no pack in this
|
|
1075
|
+
// case is signed by the named key" is not established, so the innocent
|
|
1076
|
+
// one-sided kind is not available (COUNTERPART_ABSENT_SIGNER_UNKNOWN).
|
|
1077
|
+
let unattributed = 0;
|
|
1078
|
+
|
|
1079
|
+
for (let position = 0; position < packs.length; position += 1) {
|
|
1080
|
+
const pack = packs[position];
|
|
1081
|
+
const holder = normalizedSigningKey(holderKeyOf(pack.bundle));
|
|
1082
|
+
if (holder === null) {
|
|
1083
|
+
// Named, never skipped: a pack whose key this reader cannot read is a
|
|
1084
|
+
// pack whose signer is UNKNOWN, and not a pack nobody signed. Checked
|
|
1085
|
+
// BEFORE verification, because the two bundle verifiers disagree on some
|
|
1086
|
+
// of these spellings (proof#135) and the reading must not.
|
|
1087
|
+
note(PACK_KEY_UNREADABLE, { packRef: pack.ref });
|
|
1088
|
+
unattributed += 1;
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// The precondition `HeldRecord` states, met HERE rather than trusted to a
|
|
1093
|
+
// caller: every key this module compares is an envelope `public_key`, and
|
|
1094
|
+
// "unforgeable" is a property of a VERIFIED envelope. Offline, no
|
|
1095
|
+
// gateways: this reader makes no network call. This kernel's verifier
|
|
1096
|
+
// reads envelopes under exactly CASE_RECORD_PROFILES (pinned by a test).
|
|
1097
|
+
const verified = await verifyEvidenceBundle(pack.bundle, {
|
|
1098
|
+
content: (pack.content ?? undefined) as
|
|
1099
|
+
| Record<string, Uint8Array | string>
|
|
1100
|
+
| ReadonlyMap<string, Uint8Array | string>
|
|
1101
|
+
| undefined,
|
|
1102
|
+
});
|
|
1103
|
+
witnessChecked.push(verified.onChainChecked);
|
|
1104
|
+
|
|
1105
|
+
// WHOSE PACK IS IT. The wrapper, and only the wrapper.
|
|
1106
|
+
if (!wrapperHolds(verified)) {
|
|
1107
|
+
note(PACK_DOES_NOT_VERIFY, { packRef: pack.ref });
|
|
1108
|
+
unattributed += 1;
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
holderKeys.add(holder);
|
|
1112
|
+
|
|
1113
|
+
// WHAT IS WRONG INSIDE IT, from here on. First the pack's own assurance,
|
|
1114
|
+
// which propagates: the case never reads above its weakest pack.
|
|
1115
|
+
if (verified.status !== "verified") {
|
|
1116
|
+
note(PACK_BUNDLE_NOT_VERIFIED, {
|
|
1117
|
+
packRef: pack.ref,
|
|
1118
|
+
status: verified.status as CaseVerdict,
|
|
1119
|
+
reasons: bundleReasons(verified),
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
// The body is one the verifier could not read, so it graded no record, and
|
|
1123
|
+
// nothing below has a grading to read. Named above.
|
|
1124
|
+
if (verified.status === "malformed") continue;
|
|
1125
|
+
|
|
1126
|
+
const bodyType = bodyTypeOf(pack.bundle);
|
|
1127
|
+
if (!(CASE_BODY_TYPES as readonly unknown[]).includes(bodyType)) {
|
|
1128
|
+
// `sanning.evidence.export/v2` lands here, and it is NOT unwrapped: its
|
|
1129
|
+
// records sit under `body.source_bundle`, and reading them is a change to
|
|
1130
|
+
// which packs this reader grades, so it is a decision of its own and not
|
|
1131
|
+
// a repair (evidence-bundle.md 5.5.1 step 1 says so too). What this exit
|
|
1132
|
+
// costs is that pack's RECORDS, and it is named every time. It no longer
|
|
1133
|
+
// costs the pack's KEY: that was read above.
|
|
1134
|
+
note(PACK_BODY_TYPE_UNKNOWN, { packRef: pack.ref });
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const records = packRecords(pack.bundle, pack.content);
|
|
1139
|
+
// The per-record gradings below are joined to `records` BY INDEX, so a
|
|
1140
|
+
// verifier that graded a different number of records than the pack holds
|
|
1141
|
+
// leaves this loop with no grading to read. That is not hypothetical: a
|
|
1142
|
+
// pack whose `body_hash` does not cover its body reads `failed` with
|
|
1143
|
+
// `events` EMPTY while `packRecords` reads every record in it, and only
|
|
1144
|
+
// `wrapperHolds` above stands between that pack and this loop. A reader
|
|
1145
|
+
// must never crash on a pack a party can build, so the gap is NAMED rather
|
|
1146
|
+
// than indexed into, and the pack's records are dropped exactly as a
|
|
1147
|
+
// `malformed` pack's are (gate 4 on proof#140, finding 3: under the
|
|
1148
|
+
// mutation that dropped `bodyHashOk` from the wrapper test, this kernel
|
|
1149
|
+
// threw `Cannot read properties of undefined (reading 'envelopeOk')` here
|
|
1150
|
+
// and nothing in the ladder said so).
|
|
1151
|
+
if (verified.events.length !== records.length) {
|
|
1152
|
+
note(PACK_BUNDLE_NOT_VERIFIED, {
|
|
1153
|
+
packRef: pack.ref,
|
|
1154
|
+
status: verified.status as CaseVerdict,
|
|
1155
|
+
reasons: [
|
|
1156
|
+
`this verifier graded ${verified.events.length} of the ` +
|
|
1157
|
+
`${records.length} record(s) the pack holds, so none of them is read`,
|
|
1158
|
+
],
|
|
1159
|
+
});
|
|
1160
|
+
continue;
|
|
1161
|
+
}
|
|
1162
|
+
if (records.length === 0) {
|
|
1163
|
+
note(PACK_HOLDS_NO_RECORDS, { packRef: pack.ref });
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const check = checkDeclaration(pack.bundle, pack.content);
|
|
1168
|
+
claimsByPack.set(position, declarationClaims(pack.bundle));
|
|
1169
|
+
if (check.contentFindings.length > 0) {
|
|
1170
|
+
// BESIDE the deviation exception and never instead of it: a pack can do
|
|
1171
|
+
// both, over different records, and the two say different things. This
|
|
1172
|
+
// one is `incomplete` because whoever assembled the case chose the bytes
|
|
1173
|
+
// it turns on (proof#147).
|
|
1174
|
+
note(PACK_DECLARATION_CONTENT_DEPENDENT, {
|
|
1175
|
+
packRef: pack.ref,
|
|
1176
|
+
count: check.contentFindings.length,
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
if (check.verdict === "failed") {
|
|
1180
|
+
note(PACK_DECLARATION_DEVIATES, {
|
|
1181
|
+
packRef: pack.ref,
|
|
1182
|
+
count: check.deviations.length,
|
|
1183
|
+
});
|
|
1184
|
+
} else if (check.contentFindings.length > 0) {
|
|
1185
|
+
// Already named above. `partial` here is not "the reader cannot read the
|
|
1186
|
+
// claim": the claim was read, and part of it rests on bytes nobody
|
|
1187
|
+
// signed.
|
|
1188
|
+
} else if (check.verdict === "verified") {
|
|
1189
|
+
// A pack that agrees with its own claim contributes NOTHING, rather than
|
|
1190
|
+
// a `verified` of its own. The case verdict is over the hand-off
|
|
1191
|
+
// readings, and criterion 4's "mixed" is about mixed HAND-OFF outcomes: a
|
|
1192
|
+
// pack-level clean contribution would put `verified` into the set on
|
|
1193
|
+
// every case, so one broken pair beside two honest declarations would
|
|
1194
|
+
// read `mixed` and the failure would be the quieter half of a two-word
|
|
1195
|
+
// answer. A pack-level PROBLEM still contributes, through its exception.
|
|
1196
|
+
} else if (check.present) {
|
|
1197
|
+
// `partial` with a declaration present is 5.4.3's "a claim this reader
|
|
1198
|
+
// cannot read", which `gradePack` already carries to `incomplete`. The
|
|
1199
|
+
// same posture here, for the same reason: the field has no accept-set and
|
|
1200
|
+
// the holder writes it.
|
|
1201
|
+
note(PACK_DECLARATION_UNREADABLE, { packRef: pack.ref });
|
|
1202
|
+
} else {
|
|
1203
|
+
note(PACK_UNDECLARED, { packRef: pack.ref });
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Each record on its OWN envelope. `packRecords` and the verifier both walk
|
|
1207
|
+
// `body.events` in order and emit one entry per element, so the index is
|
|
1208
|
+
// the join, exactly as `held`, `provenance` and `readings` below.
|
|
1209
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
1210
|
+
const record = records[index];
|
|
1211
|
+
const graded = verified.events[index];
|
|
1212
|
+
if (underQuarantine(recordSpecVersion(pack.bundle, index))) {
|
|
1213
|
+
note(RECORD_PROFILE_QUARANTINED, { packRef: pack.ref, eventId: record.eventId });
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
if (!graded.envelopeOk) {
|
|
1217
|
+
note(RECORD_ENVELOPE_DOES_NOT_VERIFY, {
|
|
1218
|
+
packRef: pack.ref,
|
|
1219
|
+
eventId: record.eventId,
|
|
1220
|
+
reasons: [...graded.errors],
|
|
1221
|
+
});
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
if (!record.bytesSupplied) {
|
|
1225
|
+
note(RECORD_BYTES_WITHHELD, {
|
|
1226
|
+
packRef: pack.ref,
|
|
1227
|
+
eventId: record.eventId,
|
|
1228
|
+
});
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
if (record.record === null) {
|
|
1232
|
+
note(RECORD_BYTES_UNREADABLE, {
|
|
1233
|
+
packRef: pack.ref,
|
|
1234
|
+
eventId: record.eventId,
|
|
1235
|
+
});
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
// Named, and the record stays in the pile (see the kinds).
|
|
1239
|
+
if (graded.contentOk === false) {
|
|
1240
|
+
note(RECORD_CONTENT_MISMATCH, {
|
|
1241
|
+
packRef: pack.ref,
|
|
1242
|
+
eventId: record.eventId,
|
|
1243
|
+
reasons: [...graded.errors],
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
if (
|
|
1247
|
+
!(graded.inclusionOk && graded.checkpointBound) ||
|
|
1248
|
+
(!graded.ok && graded.contentOk !== false)
|
|
1249
|
+
) {
|
|
1250
|
+
note(RECORD_DOES_NOT_VERIFY_IN_ITS_PACK, {
|
|
1251
|
+
packRef: pack.ref,
|
|
1252
|
+
eventId: record.eventId,
|
|
1253
|
+
reasons: [...graded.errors],
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
held.push({
|
|
1257
|
+
publicKey: record.publicKey,
|
|
1258
|
+
record: record.record,
|
|
1259
|
+
ref: `${pack.ref}/${record.eventId}`,
|
|
1260
|
+
});
|
|
1261
|
+
provenance.push({ position, packRef: pack.ref, eventId: record.eventId });
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Step 3. ONE pile, no partition. This is the line anchor#198 turns on and
|
|
1266
|
+
// the line DL-60 constrains: there is nothing here that reads a tenant, an
|
|
1267
|
+
// issuer or an organization, so a pair across two organizations' packs is
|
|
1268
|
+
// read exactly as a pair inside one.
|
|
1269
|
+
const readings: HandoffReading[] = readHandoffs(held);
|
|
1270
|
+
|
|
1271
|
+
// Step 4.
|
|
1272
|
+
const records: CaseRecordReading[] = [];
|
|
1273
|
+
let handoffRecords = 0;
|
|
1274
|
+
for (let index = 0; index < readings.length; index += 1) {
|
|
1275
|
+
const reading = readings[index];
|
|
1276
|
+
const { position, packRef, eventId } = provenance[index];
|
|
1277
|
+
records.push({
|
|
1278
|
+
packRef,
|
|
1279
|
+
eventId,
|
|
1280
|
+
verdict: reading.verdict,
|
|
1281
|
+
findings: [...reading.findings],
|
|
1282
|
+
failedChecks: [...reading.failedChecks],
|
|
1283
|
+
counterparts: [...reading.candidates],
|
|
1284
|
+
});
|
|
1285
|
+
// proof#137's door, named. Read from the ONE reader of the declaration
|
|
1286
|
+
// (`declarationClaims`), never from a second parse here. Only a READABLE
|
|
1287
|
+
// declaration can make a record unclaimed: where the claim itself is absent
|
|
1288
|
+
// or unreadable, that pack already carries its own exception and naming
|
|
1289
|
+
// every record in it again would report one defect many times.
|
|
1290
|
+
//
|
|
1291
|
+
// ABOVE the two `continue`s below, deliberately. This check asks what the
|
|
1292
|
+
// PACK said about the record, which is independent of whether 4.1 governs
|
|
1293
|
+
// the record and of whether the record is well formed. Behind the
|
|
1294
|
+
// `not_a_handoff_record` exit it was a check the audited party switched off
|
|
1295
|
+
// by respelling two fields, and behind the `malformed` exit it was one a
|
|
1296
|
+
// party switched off by breaking its own record.
|
|
1297
|
+
const claims = claimsByPack.get(position);
|
|
1298
|
+
if (
|
|
1299
|
+
claims !== undefined &&
|
|
1300
|
+
claims.readable &&
|
|
1301
|
+
!claims.claimedSides.includes(eventId)
|
|
1302
|
+
) {
|
|
1303
|
+
note(RECORD_UNCLAIMED, { packRef, eventId });
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
if (reading.findings.includes(NOT_A_HANDOFF_FINDING)) {
|
|
1307
|
+
// 4.1 (v1.9, A14): information, not absence. A record 4.1 does not govern
|
|
1308
|
+
// is not a hand-off exception, and it is still in `records` and still
|
|
1309
|
+
// subject to the claim check above.
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
handoffRecords += 1;
|
|
1313
|
+
|
|
1314
|
+
if (reading.verdict === "malformed") {
|
|
1315
|
+
// Whose defect is it? `recordIsMalformed` is 4.1's own predicate, CALLED
|
|
1316
|
+
// and not restated, and it answers about THIS record. A `malformed`
|
|
1317
|
+
// aggregate over a record that is itself well formed is a malformed
|
|
1318
|
+
// CANDIDATE, so it is a different kind, it does not `continue`, and the
|
|
1319
|
+
// counterpart, ambiguity and `failedChecks` reporting below all keep
|
|
1320
|
+
// running over it.
|
|
1321
|
+
if (recordIsMalformed(held[index])) {
|
|
1322
|
+
note(RECORD_MALFORMED, { packRef, eventId });
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1325
|
+
note(COUNTERPART_MALFORMED, { packRef, eventId });
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
if (reading.findings.includes(COUNTERPART_ABSENT)) {
|
|
1329
|
+
// `held`, `provenance` and `readings` are three views of one list in one
|
|
1330
|
+
// order, built together and never filtered, so the index is the join.
|
|
1331
|
+
// Deliberately not a lookup by reference string: a reference is for a
|
|
1332
|
+
// person to read, and a join rebuilt by splitting one is a join on a
|
|
1333
|
+
// value a holder can put a separator inside.
|
|
1334
|
+
const subject = held[index];
|
|
1335
|
+
const event = (subject.record as Record<string, unknown>)["event"];
|
|
1336
|
+
const named = normalizedSigningKey(
|
|
1337
|
+
typeof event === "object" && event !== null
|
|
1338
|
+
? (event as Record<string, unknown>)["counterparty_public_key"]
|
|
1339
|
+
: undefined,
|
|
1340
|
+
);
|
|
1341
|
+
// A VERIFIED pack SIGNED by the named key is in the case, and nothing
|
|
1342
|
+
// else. Records signed by that key which some other party holds are not
|
|
1343
|
+
// that party's pack. Where some pack could not be tied to a key, "no
|
|
1344
|
+
// pack is signed by the named key" is not established and the reading
|
|
1345
|
+
// says it could not tell.
|
|
1346
|
+
const kind =
|
|
1347
|
+
named !== null && holderKeys.has(named)
|
|
1348
|
+
? COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS
|
|
1349
|
+
: unattributed > 0
|
|
1350
|
+
? COUNTERPART_ABSENT_SIGNER_UNKNOWN
|
|
1351
|
+
: COUNTERPART_ABSENT;
|
|
1352
|
+
note(kind, { packRef, eventId, counterpartyKey: named });
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
if (reading.findings.includes(COUNTERPART_AMBIGUOUS)) {
|
|
1357
|
+
note(COUNTERPART_AMBIGUOUS, {
|
|
1358
|
+
packRef,
|
|
1359
|
+
eventId,
|
|
1360
|
+
count: reading.candidates.length,
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
// ON `failedChecks` AND NOT ON THE VERDICT WORD. 4.1 keeps the UNION of
|
|
1364
|
+
// every candidate's failed checks precisely so a junk record cannot strip a
|
|
1365
|
+
// named failure out of the report (proof#133 ruling 6), and reading the
|
|
1366
|
+
// verdict instead threw that union away: `malformed` outranks `failed`, so
|
|
1367
|
+
// one junk candidate took the branch below and a proven `message_hash`
|
|
1368
|
+
// failure disappeared from the case. A named failed check is a named failed
|
|
1369
|
+
// check whatever the aggregate word is.
|
|
1370
|
+
if (reading.failedChecks.length > 0) {
|
|
1371
|
+
note(PAIR_FAILED, {
|
|
1372
|
+
packRef,
|
|
1373
|
+
eventId,
|
|
1374
|
+
failedChecks: [...reading.failedChecks],
|
|
1375
|
+
});
|
|
1376
|
+
} else if (reading.verdict === "verified") {
|
|
1377
|
+
contributions.push([`${packRef}/${eventId}`, "verified"]);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
if (handoffRecords === 0) note(CASE_HOLDS_NO_HANDOFF_RECORDS);
|
|
1382
|
+
|
|
1383
|
+
return {
|
|
1384
|
+
verdict: verdictOf(exceptions, contributions),
|
|
1385
|
+
exceptions,
|
|
1386
|
+
records,
|
|
1387
|
+
contributions,
|
|
1388
|
+
handoffRecords,
|
|
1389
|
+
onChainChecked: witnessChecked.length > 0 && witnessChecked.every((c) => c),
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// ---- Presentation (optional) ------------------------------------------------
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* The house wording for one exception.
|
|
1397
|
+
*
|
|
1398
|
+
* Offered, not imposed, exactly as `renderDeviation` is: what must not vary
|
|
1399
|
+
* between surfaces is the verdict and the set of exceptions, and those come
|
|
1400
|
+
* from `readCase`. Both kernels return this same text for the same exception.
|
|
1401
|
+
*/
|
|
1402
|
+
const because = (reasons: readonly string[] | undefined): string =>
|
|
1403
|
+
reasons !== undefined && reasons.length > 0 ? `: ${reasons.join("; ")}` : "";
|
|
1404
|
+
|
|
1405
|
+
export function renderException(exception: CaseException): string {
|
|
1406
|
+
const where = `${exception.packRef}/${exception.eventId}`;
|
|
1407
|
+
switch (exception.kind) {
|
|
1408
|
+
case COUNTERPART_ABSENT:
|
|
1409
|
+
return (
|
|
1410
|
+
`${where} has no counterpart among the records this verifier read, ` +
|
|
1411
|
+
"and no pack in this case is signed by the key it names"
|
|
1412
|
+
);
|
|
1413
|
+
case COUNTERPART_ABSENT_FROM_COUNTERPARTY_RECORDS:
|
|
1414
|
+
return (
|
|
1415
|
+
`${where} has no counterpart among the records this verifier read, ` +
|
|
1416
|
+
"and a pack signed by the key it names IS in this case and holds " +
|
|
1417
|
+
"no counterpart this verifier could read"
|
|
1418
|
+
);
|
|
1419
|
+
case COUNTERPART_ABSENT_SIGNER_UNKNOWN:
|
|
1420
|
+
return (
|
|
1421
|
+
`${where} has no counterpart among the records this verifier read, ` +
|
|
1422
|
+
"and a pack in this case could not be tied to a signing key, so " +
|
|
1423
|
+
"whether a pack signed by the key it names is in this case is " +
|
|
1424
|
+
"UNKNOWN"
|
|
1425
|
+
);
|
|
1426
|
+
case COUNTERPART_AMBIGUOUS:
|
|
1427
|
+
return (
|
|
1428
|
+
`${where} has ${exception.count} candidate counterparts, so the ` +
|
|
1429
|
+
"reading is the worst of them and no one match was chosen"
|
|
1430
|
+
);
|
|
1431
|
+
case PAIR_FAILED:
|
|
1432
|
+
return `${where} FAILED on ${(exception.failedChecks ?? []).join(", ")}`;
|
|
1433
|
+
case RECORD_MALFORMED:
|
|
1434
|
+
return `${where} is not a well formed hand-off record`;
|
|
1435
|
+
case COUNTERPART_MALFORMED:
|
|
1436
|
+
return (
|
|
1437
|
+
`${where} is well formed and a candidate counterpart of it is not, ` +
|
|
1438
|
+
"so what this record pairs with is UNKNOWN"
|
|
1439
|
+
);
|
|
1440
|
+
case RECORD_UNCLAIMED:
|
|
1441
|
+
return (
|
|
1442
|
+
`${where} is a record its own pack declares and claims in no ` +
|
|
1443
|
+
"hand-off pair, so whether the holder meant to hand it over is " +
|
|
1444
|
+
"UNKNOWN"
|
|
1445
|
+
);
|
|
1446
|
+
case RECORD_BYTES_WITHHELD:
|
|
1447
|
+
return `${where} travels with no record bytes, so what that record IS is UNKNOWN`;
|
|
1448
|
+
case RECORD_BYTES_UNREADABLE:
|
|
1449
|
+
return `${where} travels with record bytes this verifier cannot read`;
|
|
1450
|
+
case PACK_DECLARATION_DEVIATES:
|
|
1451
|
+
// "read together with" and not "deviates", flat: 5.4 grades the pack
|
|
1452
|
+
// against the content the reader was handed, and content beside a pack is
|
|
1453
|
+
// unsigned. Whoever assembles a case can swap it, so the sentence names
|
|
1454
|
+
// what was compared rather than only the pack.
|
|
1455
|
+
return (
|
|
1456
|
+
`${exception.packRef}, read together with any content handed in ` +
|
|
1457
|
+
`beside it, DEVIATES from its own declaration, in ` +
|
|
1458
|
+
`${exception.count} way(s)`
|
|
1459
|
+
);
|
|
1460
|
+
case PACK_DECLARATION_CONTENT_DEPENDENT:
|
|
1461
|
+
// Says what this verifier could not establish, and names the content
|
|
1462
|
+
// rather than the pack's holder: the bytes are unsigned and whoever
|
|
1463
|
+
// assembled the case chose them.
|
|
1464
|
+
return (
|
|
1465
|
+
`${exception.packRef} agrees with its own declaration everywhere its ` +
|
|
1466
|
+
`signature reaches, and in ${exception.count} way(s) it does not line ` +
|
|
1467
|
+
"up with the unsigned content handed in beside it, which anyone could " +
|
|
1468
|
+
"have supplied, so whether it holds what it declares is UNKNOWN"
|
|
1469
|
+
);
|
|
1470
|
+
case PACK_DECLARATION_UNREADABLE:
|
|
1471
|
+
return (
|
|
1472
|
+
`${exception.packRef} says what it holds in a declaration this ` +
|
|
1473
|
+
"verifier cannot read, so whether it is complete is UNKNOWN"
|
|
1474
|
+
);
|
|
1475
|
+
case PACK_UNDECLARED:
|
|
1476
|
+
return (
|
|
1477
|
+
`${exception.packRef} does not say what it holds, so whether it is ` +
|
|
1478
|
+
"complete is UNKNOWN"
|
|
1479
|
+
);
|
|
1480
|
+
case PACK_BODY_TYPE_UNKNOWN:
|
|
1481
|
+
return (
|
|
1482
|
+
`${exception.packRef} is a pack body this verifier does not read, so ` +
|
|
1483
|
+
"what it holds is UNKNOWN"
|
|
1484
|
+
);
|
|
1485
|
+
case PACK_HOLDS_NO_RECORDS:
|
|
1486
|
+
return `${exception.packRef} holds no records this verifier can read`;
|
|
1487
|
+
case PACK_KEY_UNREADABLE:
|
|
1488
|
+
return (
|
|
1489
|
+
`${exception.packRef} names a signing key this verifier cannot ` +
|
|
1490
|
+
"read, so who signed it is UNKNOWN and none of its records are read"
|
|
1491
|
+
);
|
|
1492
|
+
case PACK_DOES_NOT_VERIFY:
|
|
1493
|
+
return (
|
|
1494
|
+
`${exception.packRef} carries a wrapper signature over its body that ` +
|
|
1495
|
+
"does not verify, so none of its records are read and the key it " +
|
|
1496
|
+
"names is not counted as its signer"
|
|
1497
|
+
);
|
|
1498
|
+
case PACK_BUNDLE_NOT_VERIFIED: {
|
|
1499
|
+
let head =
|
|
1500
|
+
`${exception.packRef} reads ${(exception.status ?? "").toUpperCase()} as ` +
|
|
1501
|
+
"a bundle, together with any content handed in beside it";
|
|
1502
|
+
if (exception.status === "malformed") head += ", so none of its records are read";
|
|
1503
|
+
return head + because(exception.reasons);
|
|
1504
|
+
}
|
|
1505
|
+
case RECORD_ENVELOPE_DOES_NOT_VERIFY:
|
|
1506
|
+
return (
|
|
1507
|
+
`${where} is in a pack whose wrapper verifies, and its own envelope ` +
|
|
1508
|
+
"does not verify, so it is not read as a hand-off record" +
|
|
1509
|
+
because(exception.reasons)
|
|
1510
|
+
);
|
|
1511
|
+
case RECORD_DOES_NOT_VERIFY_IN_ITS_PACK:
|
|
1512
|
+
return (
|
|
1513
|
+
`${where} has an envelope that verifies and fails another check ` +
|
|
1514
|
+
"inside its pack, and it is still read as a hand-off record" +
|
|
1515
|
+
because(exception.reasons)
|
|
1516
|
+
);
|
|
1517
|
+
case RECORD_CONTENT_MISMATCH:
|
|
1518
|
+
return (
|
|
1519
|
+
`${where} is read with content that does not match what it commits ` +
|
|
1520
|
+
"to, and it is still read as a hand-off record" +
|
|
1521
|
+
because(exception.reasons)
|
|
1522
|
+
);
|
|
1523
|
+
case RECORD_PROFILE_QUARANTINED:
|
|
1524
|
+
return (
|
|
1525
|
+
`${where} is signed under a profile this case reader does not read ` +
|
|
1526
|
+
"while its admission is undecided (DL-63), so what it says is UNKNOWN"
|
|
1527
|
+
);
|
|
1528
|
+
case CASE_HOLDS_NO_PACKS:
|
|
1529
|
+
return "this case holds no packs";
|
|
1530
|
+
case CASE_HOLDS_NO_HANDOFF_RECORDS:
|
|
1531
|
+
return (
|
|
1532
|
+
"no record this verifier read in this case is a hand-off record, " +
|
|
1533
|
+
"so no hand-off was verified"
|
|
1534
|
+
);
|
|
1535
|
+
default:
|
|
1536
|
+
return exception.kind;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* One line a surface can print for a whole case.
|
|
1542
|
+
*
|
|
1543
|
+
* Both kernels print this sentence, so a reader who runs the Python command and
|
|
1544
|
+
* the TypeScript one over one case archive reads one sentence rather than two
|
|
1545
|
+
* that differ by a word.
|
|
1546
|
+
*/
|
|
1547
|
+
export function renderCaseSummary(reading: CaseReading): string {
|
|
1548
|
+
const n = reading.handoffRecords;
|
|
1549
|
+
// What was NOT checked, in the same sentence as what was, and in the
|
|
1550
|
+
// `verified` one above all (gate 4 on proof#140, finding 3). It is a
|
|
1551
|
+
// statement of SCOPE and not a doubt: every pack here was verified offline,
|
|
1552
|
+
// and no `anchor_ref` any of them names was fetched from a chain, because
|
|
1553
|
+
// this reader makes no network call. Left unsaid, a clean case reads as
|
|
1554
|
+
// though a witness had been checked, which nobody established. It is not an
|
|
1555
|
+
// exception, and it must never become one: an empty exception list is the
|
|
1556
|
+
// only shape a reader may read as clean, so a standing exception here would
|
|
1557
|
+
// put `verified` out of reach for good.
|
|
1558
|
+
const scope = reading.onChainChecked
|
|
1559
|
+
? ""
|
|
1560
|
+
: "; no anchor_ref in it was re-fetched on chain, which this reader never does";
|
|
1561
|
+
if (reading.verdict === "verified") {
|
|
1562
|
+
return (
|
|
1563
|
+
`this case verifies across ${reading.records.length} record(s): ` +
|
|
1564
|
+
`${n} hand-off record(s), every one matched, no exceptions` +
|
|
1565
|
+
scope
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1568
|
+
return (
|
|
1569
|
+
`this case reads ${reading.verdict.toUpperCase()} over ${n} hand-off ` +
|
|
1570
|
+
`record(s), with ${reading.exceptions.length} exception(s)` +
|
|
1571
|
+
scope
|
|
1572
|
+
);
|
|
1573
|
+
}
|