@sanning/proof 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +598 -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 +674 -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
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
// The declaration: what a pack SAYS it holds, checked against what it does.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS.
|
|
4
|
+
// A pack is a selection out of an interval. Every per-record check in this
|
|
5
|
+
// kernel is a check on a record that is PRESENT: signatures, payload bindings,
|
|
6
|
+
// inclusion proofs, content hashes. Drop half the records and every survivor
|
|
7
|
+
// still verifies perfectly, so an auditor holding a pack cannot tell a full
|
|
8
|
+
// hand-over from a trimmed one. `completenessNotes` in `./grading.ts` closes
|
|
9
|
+
// one narrow case of that, where a checkpoint's own signed record commits to a
|
|
10
|
+
// leaf count. Nothing closes it for a hand-over pack built out of a stamp, and
|
|
11
|
+
// nothing at all says which records were withheld ON PURPOSE.
|
|
12
|
+
//
|
|
13
|
+
// The declaration is the pack's own signed statement of what it was meant to
|
|
14
|
+
// contain: which agents, which period, which case, the records expected, which
|
|
15
|
+
// of those are disclosed and which are withheld deliberately, and the hand-off
|
|
16
|
+
// pairs it claims. It rides INSIDE `body`, so the bundle's signature covers it
|
|
17
|
+
// and `body_hash` commits to it: the claim has an author, and re-declaring a
|
|
18
|
+
// different selection later means signing a different pack.
|
|
19
|
+
//
|
|
20
|
+
// ── the rule ────────────────────────────────────────────────────────────────
|
|
21
|
+
//
|
|
22
|
+
// verified the pack holds exactly what the declaration says it holds. A
|
|
23
|
+
// record declared `content_withheld` is in scope for its
|
|
24
|
+
// signature and its commitment only, and its absent bytes are
|
|
25
|
+
// not a deviation, because the declaration said so in advance.
|
|
26
|
+
// failed ANY deviation, and EVERY deviation is named. Declared and
|
|
27
|
+
// absent, present and undeclared, the pack carrying one
|
|
28
|
+
// `event_id` twice, withheld without being declared, disclosed
|
|
29
|
+
// after being declared withheld, or a claimed hand-off naming a
|
|
30
|
+
// record the declaration never declared.
|
|
31
|
+
// partial no declaration, or one the reader cannot interpret as this
|
|
32
|
+
// version. The pack makes no claim this reader can check, so
|
|
33
|
+
// what it was meant to contain is UNKNOWN. Never `verified`,
|
|
34
|
+
// because there is nothing to check against, and never `failed`,
|
|
35
|
+
// because nothing is wrong: absence is not failure.
|
|
36
|
+
//
|
|
37
|
+
// A deviation is a FAILURE and is NAMED. It must never surface as an absence
|
|
38
|
+
// a reader is left to interpret. That is the shape the hand-off record
|
|
39
|
+
// contract caught in review (anchor#189): a check made a condition of pairing
|
|
40
|
+
// turns a deviation into two records that did not pair for no stated reason,
|
|
41
|
+
// and a reader reads a missing thing as an innocent one.
|
|
42
|
+
//
|
|
43
|
+
// ── the bytes that did not travel ────────────────────────────────────
|
|
44
|
+
//
|
|
45
|
+
// One case inside rule 3 is not a selection and is not graded as one. When NO
|
|
46
|
+
// record declared `disclosed` supplied bytes, nothing was selected: either the
|
|
47
|
+
// bytes travelled separately or the reader was handed the JSON alone. A
|
|
48
|
+
// hostile holder gains nothing from that shape, because declaring those
|
|
49
|
+
// records `content_withheld` grades `verified` today and is cheaper, so the
|
|
50
|
+
// only party who reaches it is the honest one who forgot `--logs`. This check
|
|
51
|
+
// emits a NAMED note instead of a deviation, and the pack's missing bytes
|
|
52
|
+
// carry it to `incomplete` through the disclosure account that already counts
|
|
53
|
+
// them.
|
|
54
|
+
//
|
|
55
|
+
// PARTIAL disclosure is a selection and stays a deviation: nineteen of twenty
|
|
56
|
+
// declared-disclosed records supplying bytes and one not is a choice about
|
|
57
|
+
// which record, and it fails.
|
|
58
|
+
//
|
|
59
|
+
// ── the seam ────────────────────────────────────────────────────────────────
|
|
60
|
+
//
|
|
61
|
+
// PURE, like `./grading.ts`: data in, data out, no I/O and no key lifecycle.
|
|
62
|
+
// It reads the bundle it is handed and, optionally, the same side-content map
|
|
63
|
+
// `verifyEvidenceBundle` takes, because "the holder supplied bytes for this
|
|
64
|
+
// record" has exactly one definition in this kernel (`./side-content.ts`) and
|
|
65
|
+
// this module calls it rather than restating it.
|
|
66
|
+
//
|
|
67
|
+
// ── what this check does NOT claim ──────────────────────────────────────────
|
|
68
|
+
//
|
|
69
|
+
// It compares the declaration to the pack. It does not read a record's
|
|
70
|
+
// internals and cross-check `agents`, `period` or `case_id` against them, and
|
|
71
|
+
// it does not verify that a claimed hand-off PAIR is a real pair: the hashes
|
|
72
|
+
// match, the sequence holds, the two sides name each other's keys. Those are
|
|
73
|
+
// the CASE verdict, across packs and across organizations, and they are
|
|
74
|
+
// anchor#198. Doing half of that here would put two implementations of one
|
|
75
|
+
// rule in the stack, which is the defect `./grading.ts` was brought into the
|
|
76
|
+
// kernel to end.
|
|
77
|
+
//
|
|
78
|
+
// It does now check that each SIDE of a claimed hand-off is a conformant
|
|
79
|
+
// hand-off record (proof#136, ratified by Phil 2026-09-22), and it asks
|
|
80
|
+
// `./handoff.ts` rather than restating that rule. `handoffs[]` carried the
|
|
81
|
+
// claim and checked nothing about it, and that is what made an unreadable
|
|
82
|
+
// record useful: a receiving side respelled one code point of `event_type`
|
|
83
|
+
// and one of `event.counterparty_public_key`, which put the record outside
|
|
84
|
+
// envelope-spec.md 4.1 by both of its scope clauses, then declared it and
|
|
85
|
+
// claimed it as the received half. The record layer said nothing and the pack
|
|
86
|
+
// layer said `verified`. A record cannot be unreadable now and usable later.
|
|
87
|
+
//
|
|
88
|
+
// What that does NOT close (proof#137, ratified by Phil 2026-09-22). The
|
|
89
|
+
// sentence deleted from the spec beside this one said the other door was
|
|
90
|
+
// closed too: omit the record from `handoffs[]` and it is a
|
|
91
|
+
// present-and-undeclared record. It is not. `handoffs[]` MAY be empty, so a
|
|
92
|
+
// holder declares the record in `records[]` and claims it nowhere, and then
|
|
93
|
+
// rule 2 cannot reach it because it is declared and rule 7 cannot reach it
|
|
94
|
+
// because it is unclaimed: this check reads `verified`, exactly as it does
|
|
95
|
+
// over the honest pack. The corpus pins that pack as EXPECTED
|
|
96
|
+
// (`declaration/declaration-unclaimed-record-01`). A pack a party assembles
|
|
97
|
+
// about itself is not self-proving about what that party LEFT OUT, because
|
|
98
|
+
// the omission happens before any reader runs; the closure is the case
|
|
99
|
+
// verdict across BOTH parties' packs, anchor#198.
|
|
100
|
+
//
|
|
101
|
+
// ── the three defects the sixth hunt landed on this check (proof#137) ───────
|
|
102
|
+
//
|
|
103
|
+
// Three. The fourth door the hunt found, whether a pack a party assembles
|
|
104
|
+
// about itself can prove what it LEFT OUT, is a founder call and is not
|
|
105
|
+
// answered here.
|
|
106
|
+
//
|
|
107
|
+
// ONE PARSER, NOT TWO BUILT-INS. The two kernels read one `event_bytes`
|
|
108
|
+
// differently, three of the four ways verdict-level on identical bytes:
|
|
109
|
+
// Python's `json.loads` accepts `NaN`, `Infinity` and `-Infinity` and this
|
|
110
|
+
// kernel's `JSON.parse` rejects them, and Python's `.decode("utf-8")` keeps
|
|
111
|
+
// a leading byte-order mark where `TextDecoder` eats it. One pack read
|
|
112
|
+
// `verified` here and `failed[pair-record-not-a-handoff]` there. The rule is
|
|
113
|
+
// now stated rather than delegated: bytes that are not RFC 8785 JSON are not
|
|
114
|
+
// a conformant record, so a leading BOM and each of the three non-finite
|
|
115
|
+
// literals are rejected in BOTH kernels, spelled out in each, exactly as the
|
|
116
|
+
// hex charset already was.
|
|
117
|
+
//
|
|
118
|
+
// ONE `event_id`, TWO RECORDS. Both kernels built a map by id, which is
|
|
119
|
+
// last-write-wins in both languages, so a second record under a declared id
|
|
120
|
+
// was graded by neither rule 2 nor rule 7 and was never reported as surplus.
|
|
121
|
+
// FIRST in bundle order is now the record every other rule grades, and every
|
|
122
|
+
// record after it under the same id is a `duplicate-event-id` deviation.
|
|
123
|
+
//
|
|
124
|
+
// MINIMUM DISCLOSURE IS NOT A DEVIATION. A claimed side whose `event_bytes`
|
|
125
|
+
// the pack withholds was graded "not a conformant hand-off record", so an
|
|
126
|
+
// honest holder exercising the disclosure posture this product sells proved
|
|
127
|
+
// a deviation, and the cheapest escape from that was to stop claiming pairs
|
|
128
|
+
// at all. A withheld side now gets its OWN reading, distinct from both
|
|
129
|
+
// conformant and non-conformant, and is a NAMED NOTE.
|
|
130
|
+
|
|
131
|
+
// The kernel's one reader for bytes a producer signed: strict UTF-8, no leading
|
|
132
|
+
// byte-order mark, no non-finite literal. `jcs_loads` in
|
|
133
|
+
// `src/sanning_proof/canonicalize.py` is the twin (proof#140 gate 5).
|
|
134
|
+
import { hexToBytes } from "./crypto.js";
|
|
135
|
+
import { jcsParse } from "./verifier.js";
|
|
136
|
+
import { isConformantHandoffRecord, type HandoffRecord } from "./handoff.js";
|
|
137
|
+
import { hasSideContent, inBodyContentOffered, type SideContentMap } from "./side-content.js";
|
|
138
|
+
|
|
139
|
+
/** The declaration schema this reader knows. */
|
|
140
|
+
export const DECLARATION_VERSION = "sanning.declaration/v1";
|
|
141
|
+
|
|
142
|
+
/** The three states a declaration check can reach, from the §3 vocabulary. */
|
|
143
|
+
export type DeclarationVerdict = "verified" | "failed" | "partial";
|
|
144
|
+
|
|
145
|
+
export type DeclarationDeviationKind =
|
|
146
|
+
/** The declaration is written to this version and is not readable as one. */
|
|
147
|
+
| "declaration-malformed"
|
|
148
|
+
/** A record the declaration expects is not in the pack. */
|
|
149
|
+
| "declared-absent"
|
|
150
|
+
/** A record in the pack the declaration never expected. */
|
|
151
|
+
| "undeclared-present"
|
|
152
|
+
/**
|
|
153
|
+
* A record declared `content_withheld` whose bytes the pack supplies INSIDE
|
|
154
|
+
* its own signed body, in `events[].content`.
|
|
155
|
+
*/
|
|
156
|
+
| "withheld-but-disclosed"
|
|
157
|
+
/** The pack carries one `event_id` on two or more records. */
|
|
158
|
+
| "duplicate-event-id"
|
|
159
|
+
/** A claimed hand-off names a record the declaration does not declare. */
|
|
160
|
+
| "pair-record-undeclared"
|
|
161
|
+
/**
|
|
162
|
+
* A claimed hand-off names a record that is not a conformant hand-off
|
|
163
|
+
* record of the side it is claimed on (envelope-spec.md 4.1).
|
|
164
|
+
*/
|
|
165
|
+
| "pair-record-not-a-handoff";
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Every way a pack and the UNSIGNED content handed in beside it fail to line
|
|
169
|
+
* up, as a closed set. Neither of these is a deviation, and neither of them
|
|
170
|
+
* fails (proof#147, ratified by Phil 2026-09-22).
|
|
171
|
+
*
|
|
172
|
+
* WHY THEY ARE NOT DEVIATIONS. 5.4 grades a pack's declaration partly against
|
|
173
|
+
* content the reader was HANDED, and that content is not signed by anybody.
|
|
174
|
+
* `events[].content` rides inside `body`, so the wrapper signature and
|
|
175
|
+
* `body_hash` cover it and what it says about the holder is the holder's own
|
|
176
|
+
* word. The side-content map is the other channel, and whoever assembles a
|
|
177
|
+
* case fills it in: an accuser could put an intact pack into
|
|
178
|
+
* `pack_declaration_deviates`, which contributed `failed`, WITH NO KEY AT
|
|
179
|
+
* ALL, by dropping the bytes of a record the pack declared `disclosed` or by
|
|
180
|
+
* adding bytes for one it declared `content_withheld`. The pack was intact,
|
|
181
|
+
* its wrapper verified, and its holder had done nothing.
|
|
182
|
+
*
|
|
183
|
+
* This is the mirror of proof#143. There a counterparty makes itself look
|
|
184
|
+
* better by choosing what it hands over; here an accuser makes a counterparty
|
|
185
|
+
* look worse the same way. Both now read as what the READER established, so
|
|
186
|
+
* `failed` stays for what a signature covers.
|
|
187
|
+
*
|
|
188
|
+
* WHAT A HOSTILE HOLDER GAINS FROM THE WEAKER WORD: nothing, because a
|
|
189
|
+
* cheaper move already dominates it. Declaring a record `content_withheld` is
|
|
190
|
+
* legal and grades `verified`, which is strictly better than the `incomplete`
|
|
191
|
+
* these carry.
|
|
192
|
+
*/
|
|
193
|
+
export type DeclarationContentKind =
|
|
194
|
+
/**
|
|
195
|
+
* A record declared `disclosed` whose bytes reached this reader from
|
|
196
|
+
* neither channel. ALWAYS content-dependent: 5.4.1 lets those bytes travel
|
|
197
|
+
* beside the pack as `logs/<event_id>.json`, so "no bytes arrived" is a
|
|
198
|
+
* fact about what the READER was handed and never a fact about what the
|
|
199
|
+
* holder supplied. No signature covers the absence of a file nobody signed.
|
|
200
|
+
*/
|
|
201
|
+
| "content-dependent-withholding"
|
|
202
|
+
/**
|
|
203
|
+
* A record declared `content_withheld` whose bytes reached this reader ONLY
|
|
204
|
+
* from the unsigned side-content map. The pack's own body supplies none, so
|
|
205
|
+
* the holder committed to no such disclosure. The in-body half of the same
|
|
206
|
+
* rule is the `withheld-but-disclosed` DEVIATION and still fails.
|
|
207
|
+
*/
|
|
208
|
+
| "content-dependent-disclosure";
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* One way the pack and the unsigned content beside it do not line up. Named
|
|
212
|
+
* like a deviation and weighed like an absence.
|
|
213
|
+
*/
|
|
214
|
+
export interface DeclarationContentFinding {
|
|
215
|
+
kind: DeclarationContentKind;
|
|
216
|
+
/** The record this is about. Always one: both kinds are per-record. */
|
|
217
|
+
eventId?: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Everything this check reports that is NOT a deviation, as a closed set. */
|
|
221
|
+
export type DeclarationNoteKind =
|
|
222
|
+
/**
|
|
223
|
+
* Every record declared `disclosed` supplied no bytes, so nothing was
|
|
224
|
+
* selected: the bytes did not travel with the pack.
|
|
225
|
+
*/
|
|
226
|
+
| "disclosure-did-not-travel"
|
|
227
|
+
/**
|
|
228
|
+
* A claimed hand-off side whose `event_bytes` the pack withholds. The reader
|
|
229
|
+
* cannot see what the record is either way, so it is neither a conformant
|
|
230
|
+
* hand-off record nor a non-conformant one.
|
|
231
|
+
*/
|
|
232
|
+
| "pair-record-withheld";
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* One named non-deviation finding, as data.
|
|
236
|
+
*
|
|
237
|
+
* A note never moves the declaration verdict. It exists so the one shape rule
|
|
238
|
+
* 3 deliberately does not fail is still SAID, rather than leaving a reader to
|
|
239
|
+
* notice nothing was reported.
|
|
240
|
+
*/
|
|
241
|
+
export interface DeclarationNote {
|
|
242
|
+
kind: DeclarationNoteKind;
|
|
243
|
+
/** How many records the note is about, where it is about a count. */
|
|
244
|
+
count?: number;
|
|
245
|
+
/** The record this is about, where it is about one. */
|
|
246
|
+
eventId?: string;
|
|
247
|
+
/** Which side of a claimed hand-off named it, for `pair-record-withheld`. */
|
|
248
|
+
side?: "sent" | "received";
|
|
249
|
+
/** Which claimed hand-off, by position, for `pair-record-withheld`. */
|
|
250
|
+
pairIndex?: number;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** One deviation, as data. A surface renders its own prose or calls `renderDeviation`. */
|
|
254
|
+
export interface DeclarationDeviation {
|
|
255
|
+
kind: DeclarationDeviationKind;
|
|
256
|
+
/** The record this is about, where it is about one. */
|
|
257
|
+
eventId?: string;
|
|
258
|
+
/** Which side of a claimed hand-off named it, for the two `pair-record-` deviations. */
|
|
259
|
+
side?: "sent" | "received";
|
|
260
|
+
/** Which claimed hand-off, by position, for the two `pair-record-` deviations. */
|
|
261
|
+
pairIndex?: number;
|
|
262
|
+
/** What is wrong with the declaration itself, for `declaration-malformed`. */
|
|
263
|
+
reason?: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface DeclarationCheck {
|
|
267
|
+
verdict: DeclarationVerdict;
|
|
268
|
+
/** Every deviation, in a fixed order. Empty is only a pass when the verdict says so. */
|
|
269
|
+
deviations: DeclarationDeviation[];
|
|
270
|
+
/**
|
|
271
|
+
* Every way the pack and the unsigned content beside it do not line up, in
|
|
272
|
+
* a fixed order. These are NOT deviations: they carry the check to
|
|
273
|
+
* `partial` and the pack to `incomplete`, never to `failed`.
|
|
274
|
+
*/
|
|
275
|
+
contentFindings: DeclarationContentFinding[];
|
|
276
|
+
/** Named findings that are NOT deviations, in a fixed order. */
|
|
277
|
+
notes: DeclarationNote[];
|
|
278
|
+
/**
|
|
279
|
+
* Whether the body carried a `declaration` member at all, WHATEVER its
|
|
280
|
+
* shape. A string, an array or a `null` under that key is a claim the holder
|
|
281
|
+
* made and this reader cannot read, which is not the same thing as a pack
|
|
282
|
+
* that claimed nothing: `gradePack` grades the two differently.
|
|
283
|
+
*/
|
|
284
|
+
present: boolean;
|
|
285
|
+
/** `event_id`s the declaration expects, in declaration order. */
|
|
286
|
+
declared: string[];
|
|
287
|
+
/** Of those, the ones declared withheld on purpose. */
|
|
288
|
+
declaredWithheld: string[];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---- Reading the pack --------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
interface DeclarationBundleLike {
|
|
294
|
+
body?: {
|
|
295
|
+
declaration?: unknown;
|
|
296
|
+
events?: {
|
|
297
|
+
envelope?: { event_id?: unknown; public_key?: unknown };
|
|
298
|
+
content?: unknown;
|
|
299
|
+
event_bytes?: unknown;
|
|
300
|
+
}[];
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The record committed inside one `events[]` entry, or `null`.
|
|
306
|
+
*
|
|
307
|
+
* `null` is "this reader cannot read the record", which is not the same as
|
|
308
|
+
* "the record is fine": a claimed hand-off side whose bytes the pack supplied
|
|
309
|
+
* and this reader cannot read is a deviation, because the claim is that it IS
|
|
310
|
+
* a hand-off record and nothing here supports that. It is not the same as "the
|
|
311
|
+
* pack supplied no bytes" either: see `bytesSupplied`.
|
|
312
|
+
*
|
|
313
|
+
* ONE RULE, SPELLED OUT, BECAUSE TWO BUILT-INS ARE NOT ONE RULE. The bytes are
|
|
314
|
+
* `JCS(event record)` (RFC 8785) or they are not a conformant record. Three
|
|
315
|
+
* checks carry that: lowercase-or-uppercase hex in pairs, no leading byte-order
|
|
316
|
+
* mark, and no non-finite literal. The hex is `hexToBytes`, because it and
|
|
317
|
+
* `bytes.fromhex` disagree about embedded whitespace (proof#135); the rest is
|
|
318
|
+
* `jcsParse`, which every site in either kernel that reads signed bytes now
|
|
319
|
+
* shares (proof#140 gate 5). Both halves were a hand-rolled copy in this module
|
|
320
|
+
* until proof#146, and both are the shared reader now: the answer on every
|
|
321
|
+
* input is the same, and there is one fewer place for the rule to drift. The
|
|
322
|
+
* cross-kernel declaration leg and `declaration.test.ts` hold both kernels to
|
|
323
|
+
* the same answer on the same bytes rather than to either built-in.
|
|
324
|
+
*/
|
|
325
|
+
function committedRecord(raw: unknown): HandoffRecord | null {
|
|
326
|
+
let parsed: unknown;
|
|
327
|
+
try {
|
|
328
|
+
parsed = jcsParse(hexToBytes(raw as string));
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
return isObject(parsed) ? (parsed as HandoffRecord) : null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Which record type each side of a claimed hand-off has to be. A pack that
|
|
336
|
+
// names its receipt as the sent side has mislabeled a record, and the reading
|
|
337
|
+
// layer grades the two types differently.
|
|
338
|
+
const SIDE_EVENT_TYPES = { sent: "handoff.sent", received: "handoff.received" } as const;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The three readings a claimed hand-off side can get. THREE, not two
|
|
342
|
+
* (proof#137, defect 3). A reader that has only "conformant" and "not
|
|
343
|
+
* conformant" has to put a record whose `event_bytes` the pack withholds into
|
|
344
|
+
* one of them, and both are wrong: `event_bytes` is OPTIONAL and withholding
|
|
345
|
+
* it is minimum disclosure, which the corpus calls a customer's right and not
|
|
346
|
+
* a defect. Grading it "not a conformant hand-off record" made an honest
|
|
347
|
+
* minimum-disclosure pack prove a deviation, and the cheapest way out of that
|
|
348
|
+
* was to stop claiming pairs at all.
|
|
349
|
+
*/
|
|
350
|
+
type SideReading = "conformant" | "withheld" | "not-a-handoff";
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* How a claimed side reads: conformant, withheld, or not a hand-off record.
|
|
354
|
+
*
|
|
355
|
+
* The definition of conformant is 4.1's own (`isConformantHandoffRecord`),
|
|
356
|
+
* read once and never restated here, plus the record type the side names.
|
|
357
|
+
*
|
|
358
|
+
* WITHHELD IS ITS OWN READING. The pack supplied no `event_bytes`, so this
|
|
359
|
+
* reader cannot see what the record is in either direction. Saying it is not a
|
|
360
|
+
* hand-off record is a claim the reader cannot support, and it punishes the
|
|
361
|
+
* disclosure posture the product sells.
|
|
362
|
+
*/
|
|
363
|
+
function sideReading(
|
|
364
|
+
p: {
|
|
365
|
+
eventId: string;
|
|
366
|
+
publicKey: string;
|
|
367
|
+
record: HandoffRecord | null;
|
|
368
|
+
bytesSupplied: boolean;
|
|
369
|
+
},
|
|
370
|
+
side: "sent" | "received",
|
|
371
|
+
): SideReading {
|
|
372
|
+
if (!p.bytesSupplied) return "withheld";
|
|
373
|
+
if (p.record === null) return "not-a-handoff";
|
|
374
|
+
return isConformantHandoffRecord({ publicKey: p.publicKey, record: p.record, ref: p.eventId }) &&
|
|
375
|
+
p.record.event_type === SIDE_EVENT_TYPES[side]
|
|
376
|
+
? "conformant"
|
|
377
|
+
: "not-a-handoff";
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const bodyOf = (bundle: unknown) => (bundle as DeclarationBundleLike | null)?.body;
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* One of the pack's own records: the id, and whether the pack supplied its
|
|
384
|
+
* bytes.
|
|
385
|
+
*
|
|
386
|
+
* An event with no readable `event_id` is given a positional name, the same
|
|
387
|
+
* `#n` convention `accountDisclosure` uses, so it is still reported rather
|
|
388
|
+
* than dropped. An unnameable record cannot be declared, so it can only ever
|
|
389
|
+
* be surplus, which is a deviation and is named as one.
|
|
390
|
+
*/
|
|
391
|
+
export interface PackRecord {
|
|
392
|
+
eventId: string;
|
|
393
|
+
nameable: boolean;
|
|
394
|
+
/**
|
|
395
|
+
* Whether bytes for this record reached the reader at ALL, from either
|
|
396
|
+
* channel. This is what the disclosure account counts.
|
|
397
|
+
*/
|
|
398
|
+
disclosed: boolean;
|
|
399
|
+
/**
|
|
400
|
+
* Whether the bytes came from `events[].content`, INSIDE the body the
|
|
401
|
+
* wrapper signed and `body_hash` commits to. The two channels are kept
|
|
402
|
+
* apart because only this one is the holder's own word: the side-content
|
|
403
|
+
* map is filled in by whoever assembled the case (proof#147).
|
|
404
|
+
*/
|
|
405
|
+
disclosedInBody: boolean;
|
|
406
|
+
publicKey: string;
|
|
407
|
+
record: HandoffRecord | null;
|
|
408
|
+
/**
|
|
409
|
+
* Whether the pack SUPPLIED `event_bytes` for this record at all.
|
|
410
|
+
* `event_bytes` is OPTIONAL (evidence-bundle.md 5.1 and 5.3), and
|
|
411
|
+
* withholding it is minimum disclosure rather than a defect, so "the pack
|
|
412
|
+
* supplied bytes this reader cannot read" and "the pack supplied none" are
|
|
413
|
+
* two different readings and never one.
|
|
414
|
+
*/
|
|
415
|
+
bytesSupplied: boolean;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The pack's own records, in bundle order. Exported so the CASE layer
|
|
420
|
+
* (`./case.ts`, anchor#198) reads a pack through the SAME parser that grades
|
|
421
|
+
* it here, rather than through a second one beside it. A validation copied
|
|
422
|
+
* next to the one that enforces it drifts, and a copy that is looser blesses
|
|
423
|
+
* a value the real one then rejects.
|
|
424
|
+
*/
|
|
425
|
+
export function packRecords(
|
|
426
|
+
bundle: unknown,
|
|
427
|
+
content: SideContentMap | null | undefined,
|
|
428
|
+
): PackRecord[] {
|
|
429
|
+
const events = bodyOf(bundle)?.events;
|
|
430
|
+
if (!Array.isArray(events)) return [];
|
|
431
|
+
return events.map((ev, i) => {
|
|
432
|
+
const raw = ev?.envelope?.event_id;
|
|
433
|
+
const nameable = typeof raw === "string" && raw.length > 0;
|
|
434
|
+
const eventId = nameable ? (raw as string) : `#${i + 1}`;
|
|
435
|
+
// One definition of "the holder supplied bytes", in both of the two
|
|
436
|
+
// shapes a pack can supply them: in the body, or beside it. Both are
|
|
437
|
+
// `./side-content.ts`, never restated here.
|
|
438
|
+
const disclosedInBody = inBodyContentOffered(ev?.content);
|
|
439
|
+
const disclosed = disclosedInBody || (nameable && hasSideContent(content, eventId));
|
|
440
|
+
// The key comes from the ENVELOPE and never from inside the record, which
|
|
441
|
+
// is 4.1's whole point: a value that names no key makes the record
|
|
442
|
+
// `malformed` there, so a claimed side carrying one is a deviation here.
|
|
443
|
+
const key = ev?.envelope?.public_key;
|
|
444
|
+
// The family convention for "the holder supplied this hex field": a
|
|
445
|
+
// non-empty string. Anything else, an absent key, a `null` or a number, is
|
|
446
|
+
// the field not travelling, which is minimum disclosure and not a record
|
|
447
|
+
// this reader was handed and could not read.
|
|
448
|
+
const rawBytes = ev?.event_bytes;
|
|
449
|
+
return {
|
|
450
|
+
eventId,
|
|
451
|
+
nameable,
|
|
452
|
+
disclosed,
|
|
453
|
+
disclosedInBody,
|
|
454
|
+
publicKey: typeof key === "string" ? key : "",
|
|
455
|
+
record: committedRecord(rawBytes),
|
|
456
|
+
bytesSupplied: typeof rawBytes === "string" && rawBytes.length > 0,
|
|
457
|
+
};
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---- Reading the declaration -------------------------------------------------
|
|
462
|
+
|
|
463
|
+
const DISCLOSURE_STATES = new Set(["disclosed", "content_withheld"]);
|
|
464
|
+
|
|
465
|
+
// RFC 3339 `date-time` with an uppercase `Z`, the family's convention for an
|
|
466
|
+
// instant. Format only: this is not an ordering check and not a calendar
|
|
467
|
+
// check, and it deliberately does not restate the `signed_at` rule of
|
|
468
|
+
// envelope-spec §2, which grades a different field on a different layer.
|
|
469
|
+
const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
|
|
470
|
+
|
|
471
|
+
const isObject = (v: unknown): v is Record<string, unknown> =>
|
|
472
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
473
|
+
|
|
474
|
+
const isId = (v: unknown): v is string => typeof v === "string" && v.length > 0;
|
|
475
|
+
|
|
476
|
+
interface ReadDeclaration {
|
|
477
|
+
records: { eventId: string; withheld: boolean }[];
|
|
478
|
+
pairs: { sent: string | null; received: string | null }[];
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Read a declaration written to the version this reader knows, or say what is
|
|
483
|
+
* wrong with it.
|
|
484
|
+
*
|
|
485
|
+
* Every malformation is collected, not the first: a declaration is a claim,
|
|
486
|
+
* and a reader that names one defect in a claim with four sends its holder
|
|
487
|
+
* round the loop four times.
|
|
488
|
+
*/
|
|
489
|
+
function readDeclaration(
|
|
490
|
+
declaration: Record<string, unknown>,
|
|
491
|
+
): { ok: true; read: ReadDeclaration } | { ok: false; reasons: string[] } {
|
|
492
|
+
const reasons: string[] = [];
|
|
493
|
+
const note = (r: string) => reasons.push(r);
|
|
494
|
+
|
|
495
|
+
if (!isId(declaration["case_id"])) note("case_id is not a non-empty string");
|
|
496
|
+
|
|
497
|
+
const agents = declaration["agents"];
|
|
498
|
+
if (!Array.isArray(agents) || agents.length === 0) {
|
|
499
|
+
note("agents is not a non-empty array");
|
|
500
|
+
} else if (!agents.every(isId)) {
|
|
501
|
+
note("agents holds an entry that is not a non-empty string");
|
|
502
|
+
} else if (new Set(agents as string[]).size !== agents.length) {
|
|
503
|
+
note("agents names the same agent twice");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const period = declaration["period"];
|
|
507
|
+
if (!isObject(period)) note("period is not an object");
|
|
508
|
+
else if (typeof period["start"] !== "string" || !INSTANT.test(period["start"]))
|
|
509
|
+
note("period.start is not an RFC 3339 instant ending in Z");
|
|
510
|
+
else if (typeof period["end"] !== "string" || !INSTANT.test(period["end"]))
|
|
511
|
+
note("period.end is not an RFC 3339 instant ending in Z");
|
|
512
|
+
|
|
513
|
+
const records: { eventId: string; withheld: boolean }[] = [];
|
|
514
|
+
const raw = declaration["records"];
|
|
515
|
+
if (!Array.isArray(raw)) note("records is not an array");
|
|
516
|
+
else {
|
|
517
|
+
const seen = new Set<string>();
|
|
518
|
+
for (const [i, r] of raw.entries()) {
|
|
519
|
+
if (!isObject(r) || !isId(r["event_id"])) {
|
|
520
|
+
note(`records[${i}] carries no event_id`);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const eventId = r["event_id"] as string;
|
|
524
|
+
const disclosure = r["disclosure"];
|
|
525
|
+
if (typeof disclosure !== "string" || !DISCLOSURE_STATES.has(disclosure)) {
|
|
526
|
+
note(`records[${i}] (${eventId}) has no disclosure of "disclosed" or "content_withheld"`);
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (seen.has(eventId)) {
|
|
530
|
+
note(`records[${i}] declares ${eventId} a second time`);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
seen.add(eventId);
|
|
534
|
+
records.push({ eventId, withheld: disclosure === "content_withheld" });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const pairs: { sent: string | null; received: string | null }[] = [];
|
|
539
|
+
const rawPairs = declaration["handoffs"];
|
|
540
|
+
if (!Array.isArray(rawPairs)) note("handoffs is not an array");
|
|
541
|
+
else {
|
|
542
|
+
for (const [i, p] of rawPairs.entries()) {
|
|
543
|
+
if (!isObject(p)) {
|
|
544
|
+
note(`handoffs[${i}] is not an object`);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const sent = p["sent_event_id"] ?? null;
|
|
548
|
+
const received = p["received_event_id"] ?? null;
|
|
549
|
+
const legal = (v: unknown) => v === null || isId(v);
|
|
550
|
+
if (!legal(sent) || !legal(received)) {
|
|
551
|
+
note(`handoffs[${i}] names a side that is neither null nor a non-empty string`);
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (sent === null && received === null) {
|
|
555
|
+
note(`handoffs[${i}] claims a hand-off with neither side`);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
pairs.push({ sent: sent as string | null, received: received as string | null });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return reasons.length > 0 ? { ok: false, reasons } : { ok: true, read: { records, pairs } };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ---- The check ---------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
const noDeclaration = (): DeclarationCheck => ({
|
|
568
|
+
verdict: "partial",
|
|
569
|
+
deviations: [],
|
|
570
|
+
contentFindings: [],
|
|
571
|
+
notes: [],
|
|
572
|
+
present: false,
|
|
573
|
+
declared: [],
|
|
574
|
+
declaredWithheld: [],
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Check a pack against the declaration inside its body.
|
|
579
|
+
*
|
|
580
|
+
* `content` is the same side-content map `verifyEvidenceBundle` takes, and is
|
|
581
|
+
* optional: a pack that discloses its bytes in the body needs no side input,
|
|
582
|
+
* and a pack that discloses them beside itself passes the map a consumer
|
|
583
|
+
* already built.
|
|
584
|
+
*
|
|
585
|
+
* The verdict is this check's own. It does not restate the bundle verdict and
|
|
586
|
+
* it does not read it: a declaration can match perfectly over a pack whose
|
|
587
|
+
* signatures are forged, and saying so is `gradePack`'s job, which takes both.
|
|
588
|
+
*/
|
|
589
|
+
export function checkDeclaration(
|
|
590
|
+
bundle: unknown,
|
|
591
|
+
content?: SideContentMap | null,
|
|
592
|
+
): DeclarationCheck {
|
|
593
|
+
// `body.declaration` as WRITTEN, not as hoped. The key being absent and the
|
|
594
|
+
// key holding a string, an array or `null` are graded differently and must
|
|
595
|
+
// not collapse into one another: a body with no `declaration` key claimed
|
|
596
|
+
// nothing, which every pack built before §5.4 did, while a body whose
|
|
597
|
+
// `declaration` will not read DID claim something. Reading the second as the
|
|
598
|
+
// first is how a one-field edit to a re-sealed pack graded clean.
|
|
599
|
+
const body = bodyOf(bundle);
|
|
600
|
+
if (!isObject(body) || !("declaration" in body)) return noDeclaration();
|
|
601
|
+
const declaration = (body as Record<string, unknown>)["declaration"];
|
|
602
|
+
|
|
603
|
+
// A declaration this reader cannot interpret as `sanning.declaration/v1` is
|
|
604
|
+
// UNKNOWN and not an accusation: the same posture §4 step 1 takes for an
|
|
605
|
+
// unknown `body_type`, one layer down. `present` says a declaration was
|
|
606
|
+
// THERE, so a surface can tell "nothing was claimed" from "something was
|
|
607
|
+
// claimed in a language I do not speak", and `gradePack` carries the second
|
|
608
|
+
// to `incomplete`.
|
|
609
|
+
//
|
|
610
|
+
// Every shape that lands here is one field away from a valid declared pack
|
|
611
|
+
// the holder can re-seal with its own key: an unknown `declaration_version`,
|
|
612
|
+
// a deleted one, a null one, and a `declaration` that is a string, an array
|
|
613
|
+
// or null. Each of those hides the evidence of a trim inside a signed body,
|
|
614
|
+
// so none of them may reach a clean pack verdict.
|
|
615
|
+
if (!isObject(declaration) || declaration["declaration_version"] !== DECLARATION_VERSION) {
|
|
616
|
+
return { ...noDeclaration(), present: true };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const read = readDeclaration(declaration);
|
|
620
|
+
if (!read.ok) {
|
|
621
|
+
return {
|
|
622
|
+
verdict: "failed",
|
|
623
|
+
deviations: read.reasons.map((reason) => ({ kind: "declaration-malformed", reason })),
|
|
624
|
+
contentFindings: [],
|
|
625
|
+
notes: [],
|
|
626
|
+
present: true,
|
|
627
|
+
declared: [],
|
|
628
|
+
declaredWithheld: [],
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const { records, pairs } = read.read;
|
|
633
|
+
const declared = records.map((r) => r.eventId);
|
|
634
|
+
const declaredSet = new Set(declared);
|
|
635
|
+
const pack = packRecords(bundle, content);
|
|
636
|
+
// FIRST WINS, and the second record under one id is named below. A map built
|
|
637
|
+
// by id is last-write-wins in both languages, so a pack carrying one
|
|
638
|
+
// `event_id` twice had its second record graded by rule 7 and its first by
|
|
639
|
+
// nothing, or the reverse once the order flipped, and neither record was
|
|
640
|
+
// ever reported as surplus (proof#137, defect 2). First in bundle order is
|
|
641
|
+
// the record every other rule grades, and it does not depend on how the
|
|
642
|
+
// holder ordered `events[]`.
|
|
643
|
+
const byId = new Map<string, (typeof pack)[number]>();
|
|
644
|
+
for (const p of pack) {
|
|
645
|
+
if (p.nameable && !byId.has(p.eventId)) byId.set(p.eventId, p);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const deviations: DeclarationDeviation[] = [];
|
|
649
|
+
|
|
650
|
+
// Declared and absent. The trimmed hand-over, which is the whole reason a
|
|
651
|
+
// declaration exists: nothing else in the pack can see it.
|
|
652
|
+
for (const r of records) {
|
|
653
|
+
if (!byId.has(r.eventId)) deviations.push({ kind: "declared-absent", eventId: r.eventId });
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Present and undeclared, and the pack carrying one id twice.
|
|
657
|
+
//
|
|
658
|
+
// An unnameable record is present-and-undeclared, because a record the
|
|
659
|
+
// declaration could not have named is surplus by construction.
|
|
660
|
+
//
|
|
661
|
+
// A record under an id the pack already carries is named SEPARATELY, in
|
|
662
|
+
// bundle order, and is not also reported as surplus: one defect, one
|
|
663
|
+
// finding. 5.4.1 says declaring an id twice is a malformed declaration and
|
|
664
|
+
// said nothing about the PACK carrying one twice, so a second record under a
|
|
665
|
+
// declared id was graded by no rule at all and was never reported
|
|
666
|
+
// (proof#137, defect 2). A declaration names records by id, so an id that
|
|
667
|
+
// names two records names neither.
|
|
668
|
+
const seenInPack = new Set<string>();
|
|
669
|
+
for (const p of pack) {
|
|
670
|
+
if (p.nameable && seenInPack.has(p.eventId)) {
|
|
671
|
+
deviations.push({ kind: "duplicate-event-id", eventId: p.eventId });
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
if (p.nameable) seenInPack.add(p.eventId);
|
|
675
|
+
if (!p.nameable || !declaredSet.has(p.eventId)) {
|
|
676
|
+
deviations.push({ kind: "undeclared-present", eventId: p.eventId });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// The bytes that did not travel. Over the records declared `disclosed` that
|
|
681
|
+
// the pack actually holds: if NONE of them supplied bytes, no record was
|
|
682
|
+
// selected over another and the honest reading is that the bytes travelled
|
|
683
|
+
// separately, so this is a note and not a deviation. One of them supplying
|
|
684
|
+
// bytes and another not IS a selection, and every shortfall is named.
|
|
685
|
+
const disclosedHeld = records
|
|
686
|
+
.filter((r) => !r.withheld)
|
|
687
|
+
.map((r) => byId.get(r.eventId))
|
|
688
|
+
.filter((p): p is NonNullable<typeof p> => p !== undefined);
|
|
689
|
+
const nothingTravelled = disclosedHeld.length > 0 && !disclosedHeld.some((p) => p.disclosed);
|
|
690
|
+
const notes: DeclarationNote[] = nothingTravelled
|
|
691
|
+
? [{ kind: "disclosure-did-not-travel", count: disclosedHeld.length }]
|
|
692
|
+
: [];
|
|
693
|
+
|
|
694
|
+
// The two disclosure rules, in declaration order, over the records the pack
|
|
695
|
+
// actually holds: an absent record is already named above, and naming it
|
|
696
|
+
// twice would report one defect as two.
|
|
697
|
+
//
|
|
698
|
+
// EACH OF THEM SPLITS ON WHICH CHANNEL THE BYTES CAME FROM (proof#147,
|
|
699
|
+
// ratified by Phil 2026-09-22). `events[].content` is inside the signed
|
|
700
|
+
// body, so what it says is the holder's own word and a mismatch with the
|
|
701
|
+
// holder's own declaration is a proven deviation. The side-content map is
|
|
702
|
+
// unsigned and is filled in by whoever assembled the case, so a reading
|
|
703
|
+
// that rests on it says what the READER established and not what the holder
|
|
704
|
+
// did. The weaker readings are named, and they carry the check to `partial`
|
|
705
|
+
// and the pack to `incomplete`.
|
|
706
|
+
const contentFindings: DeclarationContentFinding[] = [];
|
|
707
|
+
for (const r of records) {
|
|
708
|
+
const p = byId.get(r.eventId);
|
|
709
|
+
if (p === undefined) continue;
|
|
710
|
+
if (!r.withheld && !p.disclosed) {
|
|
711
|
+
if (nothingTravelled) continue;
|
|
712
|
+
// ALWAYS content-dependent. 5.4.1 lets a record's bytes travel beside
|
|
713
|
+
// the pack, so no pack can prove it supplied them and no reader can
|
|
714
|
+
// prove it did not: an accuser holding a twenty-file drop reaches this
|
|
715
|
+
// reading by deleting one file.
|
|
716
|
+
contentFindings.push({ kind: "content-dependent-withholding", eventId: r.eventId });
|
|
717
|
+
} else if (r.withheld && p.disclosed) {
|
|
718
|
+
if (p.disclosedInBody) {
|
|
719
|
+
deviations.push({ kind: "withheld-but-disclosed", eventId: r.eventId });
|
|
720
|
+
} else {
|
|
721
|
+
contentFindings.push({ kind: "content-dependent-disclosure", eventId: r.eventId });
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// A claimed hand-off that names a record the declaration does not declare,
|
|
727
|
+
// or names one that is not a conformant hand-off record of that side.
|
|
728
|
+
//
|
|
729
|
+
// The PAIR itself, that the hashes match and the sequence holds, is the case
|
|
730
|
+
// verdict (anchor#198) and is deliberately not checked here. Each SIDE is
|
|
731
|
+
// checked, and that is the belt for 4.1's own repair: a record respelled out
|
|
732
|
+
// of 4.1's scope was unreadable at the record layer and still usable here,
|
|
733
|
+
// because this array carried the claim and checked nothing about it
|
|
734
|
+
// (proof#136). A record declared and absent is already named above and is
|
|
735
|
+
// not named twice.
|
|
736
|
+
//
|
|
737
|
+
// A claimed side whose `event_bytes` the pack WITHHELD is a NAMED NOTE and
|
|
738
|
+
// not a deviation (proof#137, defect 3). `event_bytes` is OPTIONAL, and a
|
|
739
|
+
// holder that withholds it is exercising minimum disclosure, which this
|
|
740
|
+
// corpus calls a customer's right and not a defect. Grading it "not a
|
|
741
|
+
// conformant hand-off record" made an honest pack prove a deviation over a
|
|
742
|
+
// record the reader cannot see in either direction, and the cheapest escape
|
|
743
|
+
// from that was to stop claiming pairs, which is the door proof#137 is
|
|
744
|
+
// about. A withheld side is still not a clean claim: the pack's own
|
|
745
|
+
// disclosure account already carries those absent bytes to `incomplete`.
|
|
746
|
+
for (const [i, pair] of pairs.entries()) {
|
|
747
|
+
for (const side of ["sent", "received"] as const) {
|
|
748
|
+
const id = pair[side];
|
|
749
|
+
if (id === null) continue;
|
|
750
|
+
if (!declaredSet.has(id)) {
|
|
751
|
+
deviations.push({ kind: "pair-record-undeclared", eventId: id, side, pairIndex: i });
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
const p = byId.get(id);
|
|
755
|
+
if (p === undefined) continue;
|
|
756
|
+
const reading = sideReading(p, side);
|
|
757
|
+
if (reading === "withheld") {
|
|
758
|
+
notes.push({ kind: "pair-record-withheld", eventId: id, side, pairIndex: i });
|
|
759
|
+
} else if (reading === "not-a-handoff") {
|
|
760
|
+
deviations.push({ kind: "pair-record-not-a-handoff", eventId: id, side, pairIndex: i });
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// A proven deviation wins: it is a mismatch between what the pack claimed
|
|
766
|
+
// and what its own signed body carries, and a failing check always wins.
|
|
767
|
+
// With no deviation and a content-dependent finding, the pack lines up with
|
|
768
|
+
// its claim everywhere a signature reaches and does not line up somewhere
|
|
769
|
+
// only unsigned bytes could settle, which is UNDETERMINED: `partial` is
|
|
770
|
+
// 3's word for that, and it adds none.
|
|
771
|
+
const verdict: DeclarationVerdict =
|
|
772
|
+
deviations.length > 0 ? "failed" : contentFindings.length > 0 ? "partial" : "verified";
|
|
773
|
+
|
|
774
|
+
return {
|
|
775
|
+
verdict,
|
|
776
|
+
deviations,
|
|
777
|
+
contentFindings,
|
|
778
|
+
notes,
|
|
779
|
+
present: true,
|
|
780
|
+
declared,
|
|
781
|
+
declaredWithheld: records.filter((r) => r.withheld).map((r) => r.eventId),
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ---- Presentation (optional) -------------------------------------------------
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* The house wording for one deviation.
|
|
789
|
+
*
|
|
790
|
+
* Offered, not imposed, exactly as `renderCause` is: what must not vary
|
|
791
|
+
* between surfaces is the verdict and the set of deviations, and those come
|
|
792
|
+
* from `checkDeclaration`. Both kernels return this same text for the same
|
|
793
|
+
* deviation, and `cross-kernel/run_cli.sh` holds the two commands to it.
|
|
794
|
+
*/
|
|
795
|
+
/**
|
|
796
|
+
* One line a surface can print beside the pack verdict: what the pack claimed
|
|
797
|
+
* to hold, and whether it does.
|
|
798
|
+
*
|
|
799
|
+
* Both kernels' commands print this sentence, so a reader who runs
|
|
800
|
+
* `sanning-proof verify` and `npx @sanning/proof verify` over one pack reads
|
|
801
|
+
* one sentence rather than two that differ by a word.
|
|
802
|
+
*
|
|
803
|
+
* `partial` is spelled out rather than printed as a word, because "partial" on
|
|
804
|
+
* its own beside a green pack reads as a hedge. What it means is that nobody
|
|
805
|
+
* said what the pack was meant to contain, and a reader deciding whether this
|
|
806
|
+
* is a full hand-over needs that in words.
|
|
807
|
+
*/
|
|
808
|
+
/**
|
|
809
|
+
* What a pack's declaration CLAIMS, for a reader one layer out.
|
|
810
|
+
*
|
|
811
|
+
* `checkDeclaration` answers "does this pack agree with its own claim". A case
|
|
812
|
+
* reader (`./case.ts`, anchor#198) needs a different thing from the same
|
|
813
|
+
* object: which records the holder claimed as a hand-off side at all, so that
|
|
814
|
+
* a conformant hand-off record the holder claimed NOWHERE is a named finding
|
|
815
|
+
* rather than a silence. That is the door proof#137 ratified open at this
|
|
816
|
+
* layer: `handoffs[]` MAY be empty, so rule 2 cannot reach a declared record
|
|
817
|
+
* and rule 7 cannot reach an unclaimed one.
|
|
818
|
+
*
|
|
819
|
+
* It is read HERE, from the same two functions `checkDeclaration` reads,
|
|
820
|
+
* rather than restated in the case layer. A second reader of one object drifts
|
|
821
|
+
* from the first, and a looser copy blesses a claim the real one rejects.
|
|
822
|
+
*/
|
|
823
|
+
export interface DeclarationClaims {
|
|
824
|
+
/**
|
|
825
|
+
* Whether the body carried a `declaration` member at all, whatever its
|
|
826
|
+
* shape. The same field `DeclarationCheck` carries, and the same meaning.
|
|
827
|
+
*/
|
|
828
|
+
present: boolean;
|
|
829
|
+
/**
|
|
830
|
+
* Whether this reader could read the claim as `DECLARATION_VERSION` AND the
|
|
831
|
+
* object satisfies 5.4.1. `false` covers an absent declaration, an unknown
|
|
832
|
+
* version and a malformed object, and a case reader must not read any of the
|
|
833
|
+
* three as "the holder claimed nothing to check".
|
|
834
|
+
*/
|
|
835
|
+
readable: boolean;
|
|
836
|
+
/** The case the declaration names, where it names one. */
|
|
837
|
+
caseId: string | null;
|
|
838
|
+
/**
|
|
839
|
+
* Every `event_id` a `handoffs[]` entry names on either side, in declaration
|
|
840
|
+
* order, with no duplicates. Empty where the holder claimed no pair, which
|
|
841
|
+
* is legal and is exactly the shape a case reader has to name.
|
|
842
|
+
*/
|
|
843
|
+
claimedSides: string[];
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Read the claim a pack's declaration makes, without grading the pack.
|
|
848
|
+
*
|
|
849
|
+
* Deliberately total: a bundle this reader cannot read comes back with
|
|
850
|
+
* `readable: false` rather than throwing, because a case reader grades what it
|
|
851
|
+
* was handed and a reader that throws on a hostile pack cannot report that the
|
|
852
|
+
* pack is hostile.
|
|
853
|
+
*/
|
|
854
|
+
export function declarationClaims(bundle: unknown): DeclarationClaims {
|
|
855
|
+
const none: DeclarationClaims = {
|
|
856
|
+
present: false,
|
|
857
|
+
readable: false,
|
|
858
|
+
caseId: null,
|
|
859
|
+
claimedSides: [],
|
|
860
|
+
};
|
|
861
|
+
const body = bodyOf(bundle);
|
|
862
|
+
if (!isObject(body) || !("declaration" in body)) return none;
|
|
863
|
+
const declaration = (body as Record<string, unknown>)["declaration"];
|
|
864
|
+
if (!isObject(declaration) || declaration["declaration_version"] !== DECLARATION_VERSION) {
|
|
865
|
+
return { ...none, present: true };
|
|
866
|
+
}
|
|
867
|
+
const read = readDeclaration(declaration);
|
|
868
|
+
if (!read.ok) return { ...none, present: true };
|
|
869
|
+
|
|
870
|
+
const claimedSides: string[] = [];
|
|
871
|
+
for (const pair of read.read.pairs) {
|
|
872
|
+
for (const eventId of [pair.sent, pair.received]) {
|
|
873
|
+
if (eventId !== null && !claimedSides.includes(eventId)) claimedSides.push(eventId);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
const caseId = declaration["case_id"];
|
|
877
|
+
return {
|
|
878
|
+
present: true,
|
|
879
|
+
readable: true,
|
|
880
|
+
caseId: typeof caseId === "string" ? caseId : null,
|
|
881
|
+
claimedSides,
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
export function renderDeclarationSummary(check: DeclarationCheck): string {
|
|
886
|
+
if (!check.present) {
|
|
887
|
+
return "this pack does not say what it holds, so whether it is complete is UNKNOWN";
|
|
888
|
+
}
|
|
889
|
+
if (check.verdict === "failed") {
|
|
890
|
+
// READ BEFORE THE CONTENT-FINDINGS BRANCH BELOW. A failing check always
|
|
891
|
+
// wins (section 5.4.3: a pack with one deviation and one content-dependent
|
|
892
|
+
// finding is `failed`, and the finding is still named), so a pack that
|
|
893
|
+
// deviates gets the deviating sentence even when it also carries findings.
|
|
894
|
+
// Ordered the other way round, a pack holding both printed "this pack
|
|
895
|
+
// agrees with its own declaration everywhere its signature reaches" with
|
|
896
|
+
// its own named deviations listed underneath, which is the reading this
|
|
897
|
+
// section exists to prevent: a deviation surfacing as something a reader
|
|
898
|
+
// is left to interpret.
|
|
899
|
+
if (check.contentFindings.length > 0) {
|
|
900
|
+
// BOTH counts, because the two facts are about different records and a
|
|
901
|
+
// reader shown one of them would look in the wrong place.
|
|
902
|
+
return (
|
|
903
|
+
`this pack DEVIATES from its own declaration, in ${check.deviations.length} way(s), ` +
|
|
904
|
+
`and in ${check.contentFindings.length} further way(s) it does not line up with ` +
|
|
905
|
+
"the unsigned content handed in beside it, which anyone could have supplied"
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
return `this pack DEVIATES from its own declaration, in ${check.deviations.length} way(s)`;
|
|
909
|
+
}
|
|
910
|
+
if (check.contentFindings.length > 0) {
|
|
911
|
+
// Read before the `partial` branch below, because both reach this verdict
|
|
912
|
+
// and they are two different sentences. This one says what the reader
|
|
913
|
+
// could not establish; the one below says the reader could not read the
|
|
914
|
+
// claim at all.
|
|
915
|
+
return (
|
|
916
|
+
"this pack agrees with its own declaration everywhere its signature reaches, " +
|
|
917
|
+
`and in ${check.contentFindings.length} way(s) it does not line up with the ` +
|
|
918
|
+
"unsigned content handed in beside it, which anyone could have supplied"
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
if (check.verdict === "partial") {
|
|
922
|
+
return (
|
|
923
|
+
"this pack says what it holds in a declaration this verifier cannot read, " +
|
|
924
|
+
"so whether it is complete is UNKNOWN"
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
return (
|
|
928
|
+
`this pack holds exactly what it declares: ${check.declared.length} record(s), ` +
|
|
929
|
+
`${check.declaredWithheld.length} withheld on purpose`
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* The house wording for one note. Offered, not imposed, as `renderDeviation`
|
|
935
|
+
* is.
|
|
936
|
+
*/
|
|
937
|
+
export function renderNote(note: DeclarationNote): string {
|
|
938
|
+
switch (note.kind) {
|
|
939
|
+
case "disclosure-did-not-travel":
|
|
940
|
+
return (
|
|
941
|
+
`this pack declares ${note.count} record(s) disclosed and supplies no bytes for ` +
|
|
942
|
+
"any of them: the bytes did not travel with the pack, so their content is UNKNOWN"
|
|
943
|
+
);
|
|
944
|
+
case "pair-record-withheld":
|
|
945
|
+
return (
|
|
946
|
+
`the claimed hand-off at position ${note.pairIndex} names ${note.eventId} on its ` +
|
|
947
|
+
`${note.side} side, whose record bytes this pack withholds, so what that record ` +
|
|
948
|
+
"IS is UNKNOWN"
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* The house wording for one content-dependent finding.
|
|
955
|
+
*
|
|
956
|
+
* EVERY SENTENCE HERE NAMES THE CONTENT AND NOT THE HOLDER. That is the whole
|
|
957
|
+
* point of the kind: the bytes are unsigned and whoever assembled the case
|
|
958
|
+
* chose them, so a sentence that reads as an accusation would put the weaker
|
|
959
|
+
* word beside the stronger claim and an auditor would read the claim.
|
|
960
|
+
*/
|
|
961
|
+
export function renderContentFinding(finding: DeclarationContentFinding): string {
|
|
962
|
+
switch (finding.kind) {
|
|
963
|
+
case "content-dependent-withholding":
|
|
964
|
+
return (
|
|
965
|
+
`${finding.eventId} is declared disclosed and no bytes for it reached this ` +
|
|
966
|
+
"verifier, from the pack or from beside it, so whether the holder supplied " +
|
|
967
|
+
"them is UNKNOWN"
|
|
968
|
+
);
|
|
969
|
+
case "content-dependent-disclosure":
|
|
970
|
+
return (
|
|
971
|
+
`${finding.eventId} is declared withheld and bytes for it were handed in ` +
|
|
972
|
+
"beside this pack, which the pack does not carry and nobody signed, so " +
|
|
973
|
+
"whether the holder disclosed them is UNKNOWN"
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
export function renderDeviation(deviation: DeclarationDeviation): string {
|
|
979
|
+
switch (deviation.kind) {
|
|
980
|
+
case "declaration-malformed":
|
|
981
|
+
return `the declaration is not readable as ${DECLARATION_VERSION}: ${deviation.reason}`;
|
|
982
|
+
case "declared-absent":
|
|
983
|
+
return `${deviation.eventId} is declared and is NOT in this pack`;
|
|
984
|
+
case "undeclared-present":
|
|
985
|
+
return `${deviation.eventId} is in this pack and is NOT declared`;
|
|
986
|
+
case "withheld-but-disclosed":
|
|
987
|
+
return (
|
|
988
|
+
`${deviation.eventId} is declared withheld and this pack carries its bytes ` +
|
|
989
|
+
"in its own signed body"
|
|
990
|
+
);
|
|
991
|
+
case "duplicate-event-id":
|
|
992
|
+
return `this pack carries ${deviation.eventId} more than once`;
|
|
993
|
+
case "pair-record-undeclared":
|
|
994
|
+
return (
|
|
995
|
+
`the claimed hand-off at position ${deviation.pairIndex} names ` +
|
|
996
|
+
`${deviation.eventId} on its ${deviation.side} side, which the declaration does not declare`
|
|
997
|
+
);
|
|
998
|
+
case "pair-record-not-a-handoff":
|
|
999
|
+
return (
|
|
1000
|
+
`the claimed hand-off at position ${deviation.pairIndex} names ` +
|
|
1001
|
+
`${deviation.eventId} on its ${deviation.side} side, which is not a conformant ` +
|
|
1002
|
+
`${SIDE_EVENT_TYPES[deviation.side ?? "sent"]} record`
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
}
|