@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
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A file part's BYTES, and what a receiver holding them commits to.
|
|
3
|
+
*
|
|
4
|
+
* `envelope-spec.md` section 4.1 (v1.13, amendment A18, proof#145, ratified by
|
|
5
|
+
* Phil 2026-09-22). The Python half is `src/sanning_proof/message_parts.py` and
|
|
6
|
+
* the two are held together by `cross-kernel/run_parts.sh`.
|
|
7
|
+
*
|
|
8
|
+
* THE DEFECT THIS CLOSES. Section 4.1 said "Binary and file parts are committed
|
|
9
|
+
* by their own content hash inside that canonical object" and stopped, which
|
|
10
|
+
* decided half of "an altered part fails". Measured against both SDKs
|
|
11
|
+
* (anchor#219, attack 1, in both languages): a transport that keeps a part's
|
|
12
|
+
* DESCRIPTOR and swaps its BYTES produces `accepted`. No exception, no finding,
|
|
13
|
+
* and nothing in either record: the descriptor's hash and the bytes' hash sit
|
|
14
|
+
* side by side and no rule ever compared them, so both sides commit to the same
|
|
15
|
+
* message object and the pair reads `verified`. Swapping the bytes AND the
|
|
16
|
+
* descriptor was caught all along. This is the sixth instance of one class in
|
|
17
|
+
* this section, "a deviation produces nothing and a reader is obliged to call
|
|
18
|
+
* it clean", and the repair is the same shape as A13's and A14's: remove the
|
|
19
|
+
* silent branch rather than narrow it.
|
|
20
|
+
*
|
|
21
|
+
* THE RULE, which is the smallest additive one that closes it:
|
|
22
|
+
*
|
|
23
|
+
* * a file part is `kind: "file"` plus `content_hash` (64 hex characters,
|
|
24
|
+
* either case, the SHA-256 of the part's exact bytes) and `content_length`
|
|
25
|
+
* (a safe integer), and the bytes are NOT in the message;
|
|
26
|
+
* * a receiver handed a part's bytes MUST hash them, and where the digest is
|
|
27
|
+
* not the descriptor's, or the byte count is not the descriptor's, it
|
|
28
|
+
* commits to the message AS IT ACTUALLY HELD IT: that part's
|
|
29
|
+
* `content_hash` and `content_length` replaced by the held bytes' own,
|
|
30
|
+
* nothing else touched. Its `handoff.received` then commits to a different
|
|
31
|
+
* message object from the sender's, so the formed pair FAILS on
|
|
32
|
+
* `message_hash`, a failure section 4.1 already names, and the receiving
|
|
33
|
+
* side writes `outcome: "rejected"`. That is the ONE reading that moves,
|
|
34
|
+
* and it moves only for a receiver that holds the bytes;
|
|
35
|
+
* * every other deviation is named and none is silent. Three of them are
|
|
36
|
+
* REFUSALS of one supply, so a receiver's own decoding defect never becomes
|
|
37
|
+
* permanent evidence against an honest sender;
|
|
38
|
+
* * an inline part is ACCEPTED, and it is committed by the MESSAGE ITSELF
|
|
39
|
+
* (proof#157, ratified by Phil 2026-09-22). `message_hash` is SHA-256 over
|
|
40
|
+
* the canonical bytes of the message object, so a part carrying its content
|
|
41
|
+
* inside the message is already committed: alter that content and the
|
|
42
|
+
* message object changes, the hash changes, and the pair fails. None of the
|
|
43
|
+
* swapped-bytes attack applies, because that attack works only where a
|
|
44
|
+
* descriptor's hash and bytes travelling BESIDE the message are two
|
|
45
|
+
* separate things with nothing comparing them. Inline is not the risky
|
|
46
|
+
* shape, it is the safest one. Where an inline part ALSO carries a
|
|
47
|
+
* `content_hash` or a `content_length`, the receiver checks BOTH against
|
|
48
|
+
* the inline content and names a disagreement: one commitment governs, and
|
|
49
|
+
* a second one present and disagreeing is itself a finding.
|
|
50
|
+
*
|
|
51
|
+
* WHY THE HELD MESSAGE RATHER THAN A FINDING ON THE RECEIVING SIDE. A finding
|
|
52
|
+
* only the receiving side's own reader sees is the shape of every laundering
|
|
53
|
+
* path this section has closed: the party being audited publishes the pack, so
|
|
54
|
+
* a deviation that lives outside the committed record is a deviation that party
|
|
55
|
+
* chooses whether anyone reads. `message_hash` is the one value both sides
|
|
56
|
+
* publish and a reader already grades.
|
|
57
|
+
*
|
|
58
|
+
* WHAT DOES NOT MOVE. A receiver that never holds a part's bytes checks
|
|
59
|
+
* nothing, substitutes nothing and reads exactly as it did before this
|
|
60
|
+
* amendment: every file part is `PART_UNCHECKED`, and section 4.1 forbids
|
|
61
|
+
* treating an unchecked part as a checked one. No envelope field moves, no
|
|
62
|
+
* `spec_version` moves, no verdict word and no record-layer finding is coined.
|
|
63
|
+
*
|
|
64
|
+
* A REFUSAL IS SCOPED TO ITS PART, AND THE RECORD IS STILL WRITTEN (proof#156
|
|
65
|
+
* gate). The first cut of this module refused GLOBALLY: one refusal anywhere
|
|
66
|
+
* set `heldMessage` to null and dropped every substitution. That made the
|
|
67
|
+
* attack A18 exists to close launderable (a genuine swap on part 0 plus any
|
|
68
|
+
* refusal-triggering supply on part 1 wrote nothing at all), let a SENDER deny
|
|
69
|
+
* the receiver a record with an upper-case `content_hash` or a `"bytes": null`
|
|
70
|
+
* on an otherwise conformant descriptor, and contradicted section 4.1's own
|
|
71
|
+
* paragraph saying an uncheckable part is NAMED in the receiver's reading. So
|
|
72
|
+
* a problem with one part names that part, leaves the rest of the reading
|
|
73
|
+
* intact, and `heldMessage` is never null. A message-level problem is reserved
|
|
74
|
+
* for a message-level condition, and even then it produces a named result:
|
|
75
|
+
* `PARTS_NOT_AN_ARRAY`, and the entry-scoped `BYTES_FOR_UNKNOWN_PART`.
|
|
76
|
+
*
|
|
77
|
+
* WHAT THE CALLER STILL OWES, AND THE ONE FUNCTION THAT STATES IT.
|
|
78
|
+
* `substituted` decides whether a hand-off is `rejected`; it does NOT decide
|
|
79
|
+
* whether one is clean. A part that was refused reads `PART_UNCHECKED`,
|
|
80
|
+
* section 4.1 forbids a reader treating an unchecked part as a checked one,
|
|
81
|
+
* and a surface reporting a hand-off states how many parts were checked. A
|
|
82
|
+
* caller that wrote `accepted` on "nothing was substituted" alone would be
|
|
83
|
+
* calling a part it never established clean, which is the class this whole
|
|
84
|
+
* section keeps repairing.
|
|
85
|
+
*
|
|
86
|
+
* The sharp version, measured (proof#156 second gate, F5): deliver a tampered
|
|
87
|
+
* copy of a part ALONE and it is `part_mismatched`, substituted, and the pair
|
|
88
|
+
* fails on `message_hash`. Deliver the tampered copy AND the true one and it
|
|
89
|
+
* is `conflicting_part_bytes`, `part_unchecked`, nothing substituted, a
|
|
90
|
+
* `heldMessage` byte-identical to the sender's, and the pair VERIFIES. Adding
|
|
91
|
+
* data suppresses detection, and the refusal that names it lives only in
|
|
92
|
+
* `refusals`, which is a finding only the receiving side's own reader sees:
|
|
93
|
+
* the exact shape the paragraph above refuses to create.
|
|
94
|
+
* `PART_ENCODING_MISMATCH` has the same property.
|
|
95
|
+
*
|
|
96
|
+
* Until that round the obligation was PROSE IN THIS COMMENT with no mechanism,
|
|
97
|
+
* which is a rule a caller has to read to obey. `uncheckedHeldParts` is the
|
|
98
|
+
* mechanism's additive half: it names the parts the receiver HELD bytes for
|
|
99
|
+
* and did not check, so a caller writes `accepted` only where `substituted` is
|
|
100
|
+
* empty AND that list is empty, instead of re-deriving it from the refusals.
|
|
101
|
+
* It cannot make the mistake IMPOSSIBLE, because this module reads and does
|
|
102
|
+
* not write the record. What the record layer does with the list was
|
|
103
|
+
* proof#162, and Phil ratified it on 2026-09-22: `outcome: "accepted"` keeps
|
|
104
|
+
* meaning what it means today, and the count of held-but-unchecked parts
|
|
105
|
+
* rides in an ADDITIVE `context` key, REQUIRED when it is not zero. Its force
|
|
106
|
+
* is against SDKs through conformance vectors rather than against records
|
|
107
|
+
* through readers, since only the receiver knows what bytes it held, so THIS
|
|
108
|
+
* LIST IS WHAT EVERY SDK BUILT ON THE RULE WILL COUNT. A part missing from it
|
|
109
|
+
* is a part no record will ever mention (anchor#191 is the first caller).
|
|
110
|
+
*
|
|
111
|
+
* WHAT THIS MODULE DOES NOT DO. It does not canonicalize and it does not hash
|
|
112
|
+
* the message. It returns the message a receiver commits to, and the caller
|
|
113
|
+
* hashes that with the rule section 4.1 already states, so this module has no
|
|
114
|
+
* failure mode of its own for a message too deep or not serializable and there
|
|
115
|
+
* is no branch here where one kernel throws and the other returns.
|
|
116
|
+
*/
|
|
117
|
+
import { sha256Hex } from "./crypto.js";
|
|
118
|
+
/**
|
|
119
|
+
* The result of a part whose supplied bytes hash to its `content_hash` AND
|
|
120
|
+
* count to its `content_length`.
|
|
121
|
+
*/
|
|
122
|
+
export const PART_MATCHED = "part_matched";
|
|
123
|
+
/**
|
|
124
|
+
* The result of a part whose supplied bytes miss either of those, with no
|
|
125
|
+
* respelling of them that explains the digest. The held-message substitution
|
|
126
|
+
* applies to this part, and it replaces BOTH values with the held bytes' own.
|
|
127
|
+
*/
|
|
128
|
+
export const PART_MISMATCHED = "part_mismatched";
|
|
129
|
+
/**
|
|
130
|
+
* A file part whose bytes were not supplied, or whose supply was REFUSED. It is
|
|
131
|
+
* not checked and it is not clean: section 4.1 forbids a reader treating it as
|
|
132
|
+
* checked.
|
|
133
|
+
*/
|
|
134
|
+
export const PART_UNCHECKED = "part_unchecked";
|
|
135
|
+
/**
|
|
136
|
+
* A `kind: "file"` part carrying its content inside the message, and ACCEPTED
|
|
137
|
+
* (proof#157, ratified by Phil 2026-09-22). It is committed by the MESSAGE
|
|
138
|
+
* ITSELF: `message_hash` is SHA-256 over the canonical bytes of the message
|
|
139
|
+
* object, so altering that content changes the message, changes the hash, and
|
|
140
|
+
* fails the pair. Nothing is hashed against it here and nothing is substituted,
|
|
141
|
+
* because there is nothing travelling beside the message for the message to
|
|
142
|
+
* disagree with. That is why the swapped-bytes attack cannot reach this shape.
|
|
143
|
+
*/
|
|
144
|
+
export const PART_INLINE_BYTES = "part_inline_bytes";
|
|
145
|
+
/**
|
|
146
|
+
* An inline part whose `content_hash`, `content_length` or both DISAGREE with
|
|
147
|
+
* its inline content: no single reading of that content satisfies every claim
|
|
148
|
+
* the part writes beside it. The message's own commitment still governs and
|
|
149
|
+
* nothing is substituted; this names the SECOND claim that disagrees with it,
|
|
150
|
+
* which is a finding in its own right (proof#157).
|
|
151
|
+
*
|
|
152
|
+
* ONE name for both claims, exactly as `PART_MISMATCHED` is one name for both
|
|
153
|
+
* on a descriptor part. It was `part_inline_hash_mismatch` for one round of
|
|
154
|
+
* this unmerged PR, when only the hash was compared; a name that says "hash"
|
|
155
|
+
* about a result that also covers a length asserts more than its body does, so
|
|
156
|
+
* the name was re-cut with the rule (proof#156 second gate, F4).
|
|
157
|
+
*
|
|
158
|
+
* The check is deliberately permissive ACROSS readings: the raw UTF-8 of the
|
|
159
|
+
* value, its base64 decoding and its hex decoding are all tried, so this fires
|
|
160
|
+
* only where no reading agrees at all. It is not permissive BETWEEN claims: one
|
|
161
|
+
* reading has to satisfy all of them, because a hash that agrees under the
|
|
162
|
+
* base64 decoding beside a length that agrees under the raw text describes no
|
|
163
|
+
* blob that exists.
|
|
164
|
+
*/
|
|
165
|
+
export const PART_INLINE_MISMATCHED = "part_inline_mismatched";
|
|
166
|
+
/**
|
|
167
|
+
* `kind: "file"` without a conformant `content_hash` and `content_length`. A
|
|
168
|
+
* sender's defect rather than a receiver's, so it is named rather than refused:
|
|
169
|
+
* the receiver did nothing wrong by holding it.
|
|
170
|
+
*/
|
|
171
|
+
export const PART_MALFORMED_DESCRIPTOR = "part_malformed_descriptor";
|
|
172
|
+
/**
|
|
173
|
+
* Any other part. It is committed as ordinary JSON content and this rule checks
|
|
174
|
+
* nothing about it.
|
|
175
|
+
*/
|
|
176
|
+
export const PART_NOT_A_FILE = "part_not_a_file";
|
|
177
|
+
/**
|
|
178
|
+
* REFUSAL of ONE supplied entry. Bytes were supplied for no part of the
|
|
179
|
+
* message, or for a part this rule cannot check. The caller and the message
|
|
180
|
+
* disagree about what was sent, and hashing bytes against a part that is not
|
|
181
|
+
* there establishes nothing. Where the entry names a part that EXISTS but is
|
|
182
|
+
* not checkable, the refusal carries that part's index and the part keeps the
|
|
183
|
+
* result it classified to; where it names no part at all, the refusal carries
|
|
184
|
+
* the entry position and no part. Neither stops the record being written.
|
|
185
|
+
*/
|
|
186
|
+
export const BYTES_FOR_UNKNOWN_PART = "bytes_for_unknown_part";
|
|
187
|
+
/**
|
|
188
|
+
* REFUSAL. Two DIFFERENT byte strings were supplied for one part. A receiver
|
|
189
|
+
* holding two answers has not established which one it held.
|
|
190
|
+
*/
|
|
191
|
+
export const CONFLICTING_PART_BYTES = "conflicting_part_bytes";
|
|
192
|
+
/**
|
|
193
|
+
* REFUSAL of ONE supplied entry. The supplied bytes miss the descriptor, but a
|
|
194
|
+
* base64 or hex RESPELLING of them in EITHER direction hashes to it: either the
|
|
195
|
+
* bytes DECODE to the part (the receiver's stack never decoded what it was
|
|
196
|
+
* handed) or they ENCODE to it (the receiver's stack decoded bytes the sender
|
|
197
|
+
* committed in their encoded spelling, which is what a single-line DER
|
|
198
|
+
* attachment, a PEM body and a JWT all are). That is a coding defect in the
|
|
199
|
+
* receiver, not a tamper by the sender: a proto3 JSON transcoder re-encodes
|
|
200
|
+
* base64 URL-safe and unpadded while behaving honestly, and under the silence
|
|
201
|
+
* this amendment removes it produced a permanent tamper record against a sender
|
|
202
|
+
* who did nothing. Recording evidence out of a receiver's own bug is the one
|
|
203
|
+
* outcome worse than recording nothing, so BOTH directions are guarded
|
|
204
|
+
* (proof#156 gate): guarding only the decoding direction left the other half of
|
|
205
|
+
* one honest stack's behavior reading as a tamper.
|
|
206
|
+
*/
|
|
207
|
+
export const PART_ENCODING_MISMATCH = "part_encoding_mismatch";
|
|
208
|
+
/**
|
|
209
|
+
* A MESSAGE-LEVEL result. The message carries a `parts` key whose value is
|
|
210
|
+
* present and is not an array, which is how proto3 JSON encodes a map and so is
|
|
211
|
+
* reachable from the same transport family the motivating example comes from.
|
|
212
|
+
* Section 4.1 asks for one result per part; a message shaped like this has
|
|
213
|
+
* part-shaped content and no parts, and before proof#156's gate it produced
|
|
214
|
+
* zero readings and an accepted record. It is named rather than refused: the
|
|
215
|
+
* receiver did nothing wrong by holding it, and nothing in it is checked. A
|
|
216
|
+
* `parts` written as `null` is an ABSENT `parts` and is not this, on A14's
|
|
217
|
+
* ruling that an absent value and one written as `null` name one thing.
|
|
218
|
+
*/
|
|
219
|
+
export const PARTS_NOT_AN_ARRAY = "parts_not_an_array";
|
|
220
|
+
/**
|
|
221
|
+
* NOTE, not a refusal. One part supplied more than once with IDENTICAL bytes is
|
|
222
|
+
* one supply stated twice, and the part is checked normally. Different bytes for
|
|
223
|
+
* one part are `CONFLICTING_PART_BYTES`.
|
|
224
|
+
*/
|
|
225
|
+
export const PART_SUPPLIED_TWICE = "part_supplied_twice";
|
|
226
|
+
/**
|
|
227
|
+
* The order refusals are reported in, fixed so two kernels report one list
|
|
228
|
+
* rather than two orderings of it.
|
|
229
|
+
*/
|
|
230
|
+
export const REFUSAL_ORDER = [
|
|
231
|
+
BYTES_FOR_UNKNOWN_PART,
|
|
232
|
+
CONFLICTING_PART_BYTES,
|
|
233
|
+
PART_ENCODING_MISMATCH,
|
|
234
|
+
];
|
|
235
|
+
/**
|
|
236
|
+
* The order message-level results are reported in, for the same reason as
|
|
237
|
+
* `REFUSAL_ORDER`.
|
|
238
|
+
*/
|
|
239
|
+
export const MESSAGE_RESULT_ORDER = [PARTS_NOT_AN_ARRAY];
|
|
240
|
+
/**
|
|
241
|
+
* The results that put a HELD part into `uncheckedHeldParts`, written out so a
|
|
242
|
+
* third kernel reads the rule rather than inferring it from one kernel's
|
|
243
|
+
* filter (proof#156 third gate, C1).
|
|
244
|
+
*
|
|
245
|
+
* The line is whether the MESSAGE ITSELF commits the part's content. It does
|
|
246
|
+
* not for these two: `PART_UNCHECKED` is a supply the rule refused to grade,
|
|
247
|
+
* and `PART_MALFORMED_DESCRIPTOR` is a part whose content lives BESIDE the
|
|
248
|
+
* message under a claim no receiver can use, so in both the receiver holds
|
|
249
|
+
* bytes that nothing in the committed record covers.
|
|
250
|
+
*
|
|
251
|
+
* The other three results a supply can be refused against are excluded, and
|
|
252
|
+
* excluded for a reason rather than by omission. `PART_INLINE_BYTES`,
|
|
253
|
+
* `PART_INLINE_MISMATCHED` and `PART_NOT_A_FILE` all carry their content
|
|
254
|
+
* INSIDE the message, which `message_hash` already commits on both sides:
|
|
255
|
+
* bytes supplied beside one of those grade nothing and hide nothing, because
|
|
256
|
+
* a reader of either pack reads the content itself. Counting them would make
|
|
257
|
+
* the caller's obligation fire on parts no attack can reach.
|
|
258
|
+
*/
|
|
259
|
+
const HELD_UNCHECKED_RESULTS = [
|
|
260
|
+
PART_UNCHECKED,
|
|
261
|
+
PART_MALFORMED_DESCRIPTOR,
|
|
262
|
+
];
|
|
263
|
+
/**
|
|
264
|
+
* The keys that make a part carry its content INLINE, in the order they are
|
|
265
|
+
* read. Named rather than inferred: `bytes` is A2A's spelling of an inline file
|
|
266
|
+
* part, and `data` and `content` are the two other spellings in the wild. The
|
|
267
|
+
* FIRST of them present with a value that is not `null` is the inline content,
|
|
268
|
+
* so a part spelling it twice reads one way in both kernels.
|
|
269
|
+
*
|
|
270
|
+
* A key present with `null` is an ABSENT inline value, on A14's ruling that an
|
|
271
|
+
* absent value and one written as `null` name one thing. That matters: an A2A
|
|
272
|
+
* `FilePart` with `uri` set carries `"bytes": null`, and so does proto3 JSON,
|
|
273
|
+
* and reading those as inline made a conformant DESCRIPTOR part uncheckable
|
|
274
|
+
* (proof#156 gate). They fall through to the descriptor rule, which is what
|
|
275
|
+
* they are.
|
|
276
|
+
*/
|
|
277
|
+
// A conformant DESCRIPTOR part that also carries a `content` or `data` key
|
|
278
|
+
// meaning something else (a filename, a metadata blob) is reclassified by this
|
|
279
|
+
// list and never checked against supplied bytes. That is inside section 4.1's
|
|
280
|
+
// stated rule, and whether these parts ought to fall into it is proof#163.
|
|
281
|
+
export const INLINE_BYTES_KEYS = ["bytes", "content", "data"];
|
|
282
|
+
const HEX64 = /^[0-9a-fA-F]{64}$/;
|
|
283
|
+
const HEX_CHARSET = /^[0-9a-fA-F]+$/;
|
|
284
|
+
const LOWER_HEX = "0123456789abcdef";
|
|
285
|
+
const UPPER_HEX = "0123456789ABCDEF";
|
|
286
|
+
/**
|
|
287
|
+
* The largest integer a double carries exactly, the same bound section 4.1 puts
|
|
288
|
+
* on `event.sequence`: a `content_length` outside it is a value the two kernels
|
|
289
|
+
* would read as two numbers.
|
|
290
|
+
*/
|
|
291
|
+
export const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
|
|
292
|
+
const BASE64_STANDARD = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
293
|
+
const BASE64_URLSAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
294
|
+
function isPlainObject(value) {
|
|
295
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
296
|
+
}
|
|
297
|
+
function hasOwn(value, key) {
|
|
298
|
+
return Object.prototype.hasOwnProperty.call(value, key);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* 64 hex characters, in EITHER case. Phil ratified proof#135 as "pin the hex
|
|
302
|
+
* charset only": a case-only difference names the same value, and refusing
|
|
303
|
+
* upper case here would contradict a ratified decision and punish a producer
|
|
304
|
+
* who did nothing wrong. Every comparison against a digest lowercases both
|
|
305
|
+
* sides, which is what the other kernels already do.
|
|
306
|
+
*/
|
|
307
|
+
function isHex64(value) {
|
|
308
|
+
return typeof value === "string" && HEX64.test(value);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* A byte count: an integer at least zero and inside the safe range. `true` is
|
|
312
|
+
* not a number. Zero is a legal length: the empty part is a real part, and a
|
|
313
|
+
* rule that read zero as "no length" would leave it unchecked in silence.
|
|
314
|
+
*/
|
|
315
|
+
function isSafeLength(value) {
|
|
316
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
317
|
+
}
|
|
318
|
+
function isSafeIndex(value) {
|
|
319
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Distinguishes "no inline content" from an inline content of `null`, which
|
|
323
|
+
* A14's ruling says is the same thing but which a plain `null` could not be
|
|
324
|
+
* told apart from a legitimate value.
|
|
325
|
+
*/
|
|
326
|
+
const ABSENT = Symbol("absent");
|
|
327
|
+
/**
|
|
328
|
+
* The part's inline content, or `ABSENT` where it carries none: the FIRST of
|
|
329
|
+
* `INLINE_BYTES_KEYS` present with a value that is not `null`, so a part
|
|
330
|
+
* spelling it twice reads one way in both kernels.
|
|
331
|
+
*/
|
|
332
|
+
function inlineValue(part) {
|
|
333
|
+
for (const key of INLINE_BYTES_KEYS) {
|
|
334
|
+
if (hasOwn(part, key) && part[key] !== null && part[key] !== undefined) {
|
|
335
|
+
return part[key];
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return ABSENT;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Every byte string a reader can form from an inline value: the raw UTF-8 of
|
|
342
|
+
* the string, its base64 decoding and its hex decoding. Deliberately
|
|
343
|
+
* permissive: this exists only to decide whether a `content_hash` sitting
|
|
344
|
+
* BESIDE inline content agrees with it, and the message's own commitment
|
|
345
|
+
* governs either way, so the finding fires only where no reading agrees at all.
|
|
346
|
+
* A value that is not a string carries no readings, so a `content_hash` beside
|
|
347
|
+
* one has nothing to agree with.
|
|
348
|
+
*/
|
|
349
|
+
function inlineReadings(value) {
|
|
350
|
+
if (typeof value !== "string")
|
|
351
|
+
return [];
|
|
352
|
+
const encoded = new TextEncoder().encode(value);
|
|
353
|
+
const raw = new Uint8Array(encoded.length);
|
|
354
|
+
raw.set(encoded);
|
|
355
|
+
const out = [raw];
|
|
356
|
+
for (const decoded of [base64Decode(raw), hexDecode(raw)]) {
|
|
357
|
+
if (decoded !== null)
|
|
358
|
+
out.push(decoded);
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* The SHAPE a part reads with before any supplied bytes are considered, or
|
|
364
|
+
* `null` where the part is one this rule checks against them.
|
|
365
|
+
*
|
|
366
|
+
* Shape only, and synchronous on purpose: the inline hash agreement needs
|
|
367
|
+
* SHA-256, which is async here and not in Python, so refining
|
|
368
|
+
* `PART_INLINE_BYTES` to `PART_INLINE_MISMATCHED` happens in the reading
|
|
369
|
+
* loop instead. Keeping it out of here is what lets `isFilePart` mean the same
|
|
370
|
+
* thing, and have the same signature, in both kernels.
|
|
371
|
+
*/
|
|
372
|
+
function classify(part) {
|
|
373
|
+
if (!isPlainObject(part))
|
|
374
|
+
return PART_NOT_A_FILE;
|
|
375
|
+
if (part.kind !== "file")
|
|
376
|
+
return PART_NOT_A_FILE;
|
|
377
|
+
// ACCEPTED, and committed by the message itself (proof#157).
|
|
378
|
+
if (inlineValue(part) !== ABSENT)
|
|
379
|
+
return PART_INLINE_BYTES;
|
|
380
|
+
if (!isHex64(part.content_hash))
|
|
381
|
+
return PART_MALFORMED_DESCRIPTOR;
|
|
382
|
+
if (!isSafeLength(part.content_length))
|
|
383
|
+
return PART_MALFORMED_DESCRIPTOR;
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Whether the claims written BESIDE an inline part's content agree with it.
|
|
388
|
+
*
|
|
389
|
+
* Two claims can sit beside inline content, `content_hash` and
|
|
390
|
+
* `content_length`, and BOTH are read (proof#156 second gate, F4). The length
|
|
391
|
+
* was skipped for one round of this unmerged PR, on the argument that a reader
|
|
392
|
+
* holding the message can see a contradiction between the content and the
|
|
393
|
+
* claim from the message alone. That argument is true and it is also true of
|
|
394
|
+
* `content_hash`, which is compared: accepting it deletes the neighboring
|
|
395
|
+
* check. So the rule is the one section 4.1 already applies to a descriptor
|
|
396
|
+
* part, where a hash that misses and a count that misses reach one result: two
|
|
397
|
+
* claims about one blob, and reading one of them lets the other be false in
|
|
398
|
+
* silence.
|
|
399
|
+
*
|
|
400
|
+
* A claim that is ABSENT, or is not the shape the table names, is not a claim:
|
|
401
|
+
* it is not compared and it cannot disagree. A part writing neither agrees by
|
|
402
|
+
* default, because the message's own commitment governs and this only ever
|
|
403
|
+
* names a SECOND claim that contradicts it.
|
|
404
|
+
*
|
|
405
|
+
* ONE reading has to satisfy EVERY claim present, rather than each claim
|
|
406
|
+
* finding its own. Grading them separately would let a `content_hash` that
|
|
407
|
+
* agrees under the base64 decoding sit beside a `content_length` that agrees
|
|
408
|
+
* under the raw text, a pair no blob in the world satisfies, and call it clean.
|
|
409
|
+
*/
|
|
410
|
+
async function inlineClaimsAgree(part) {
|
|
411
|
+
const wanted = isHex64(part.content_hash)
|
|
412
|
+
? part.content_hash.toLowerCase()
|
|
413
|
+
: null;
|
|
414
|
+
const length = isSafeLength(part.content_length)
|
|
415
|
+
? part.content_length
|
|
416
|
+
: null;
|
|
417
|
+
if (wanted === null && length === null)
|
|
418
|
+
return true;
|
|
419
|
+
for (const reading of inlineReadings(inlineValue(part))) {
|
|
420
|
+
if (wanted !== null && (await sha256Hex(reading)) !== wanted)
|
|
421
|
+
continue;
|
|
422
|
+
if (length !== null && reading.length !== length)
|
|
423
|
+
continue;
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Whether this part is one this rule CHECKS against bytes travelling BESIDE the
|
|
430
|
+
* message: a conformant descriptor part. Exactly the three conditions section
|
|
431
|
+
* 4.1 states, in the order that keeps inline content out. An INLINE part is
|
|
432
|
+
* accepted and committed by the message itself (proof#157), so there is nothing
|
|
433
|
+
* here for supplied bytes to be checked against and this answers `false`.
|
|
434
|
+
*/
|
|
435
|
+
export function isFilePart(part) {
|
|
436
|
+
return classify(part) === null;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* The parts of a message, or none at all.
|
|
440
|
+
*
|
|
441
|
+
* A message that carries no `parts` array carries no file parts, and that is
|
|
442
|
+
* not a deviation: it is the common case, a JSON-only message. Every supplied
|
|
443
|
+
* byte string then has no part to belong to and is refused, which is the
|
|
444
|
+
* reading section 4.1 asks for and not an exception.
|
|
445
|
+
*/
|
|
446
|
+
function messageParts(message) {
|
|
447
|
+
if (!isPlainObject(message))
|
|
448
|
+
return [];
|
|
449
|
+
const parts = message.parts;
|
|
450
|
+
return Array.isArray(parts) ? [...parts] : [];
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* The message-level named results, before any part is read.
|
|
454
|
+
*
|
|
455
|
+
* One condition reaches this today: a `parts` that is present, is not null and
|
|
456
|
+
* is not an array. It gets a name rather than the silence it had (proof#156
|
|
457
|
+
* gate), because a message with part-shaped content and no parts is exactly the
|
|
458
|
+
* shape section 4.1's "one result per part" leaves nothing to say about.
|
|
459
|
+
*/
|
|
460
|
+
function messageResults(message) {
|
|
461
|
+
if (!isPlainObject(message))
|
|
462
|
+
return [];
|
|
463
|
+
if (!hasOwn(message, "parts"))
|
|
464
|
+
return [];
|
|
465
|
+
const parts = message.parts;
|
|
466
|
+
if (parts === null || parts === undefined || Array.isArray(parts))
|
|
467
|
+
return [];
|
|
468
|
+
return [PARTS_NOT_AN_ARRAY];
|
|
469
|
+
}
|
|
470
|
+
function sameBytes(left, right) {
|
|
471
|
+
if (left.length !== right.length)
|
|
472
|
+
return false;
|
|
473
|
+
for (let i = 0; i < left.length; i++) {
|
|
474
|
+
if (left[i] !== right[i])
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
function asAscii(data) {
|
|
480
|
+
let out = "";
|
|
481
|
+
for (const byte of data)
|
|
482
|
+
out += String.fromCharCode(byte);
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Strict ASCII hex. Written out rather than delegated, because Python's
|
|
487
|
+
* `bytes.fromhex` skips ASCII whitespace and a charset test does not: a decoder
|
|
488
|
+
* the two kernels disagree about would put a record on one side of
|
|
489
|
+
* `part_encoding_mismatch` in one language and the other side of it in the
|
|
490
|
+
* other.
|
|
491
|
+
*
|
|
492
|
+
* EVERY GUARD HERE AND IN `base64Decode` IS PINNED BY A CORPUS VECTOR CARRYING
|
|
493
|
+
* A HAND-WRITTEN EXPECTATION (proof#156 second gate, F3), named in the comment
|
|
494
|
+
* beside it. Until that round, six of the seven produced no signal when
|
|
495
|
+
* deleted: the whole ladder and the whole leg stayed green, so a third kernel
|
|
496
|
+
* that delegated to `atob` or `base64.b64decode` reproduced the entire corpus
|
|
497
|
+
* byte for byte, was conformant under `architecture.md` K3, and still put a
|
|
498
|
+
* record on the other side of `part_encoding_mismatch`. An agreement case
|
|
499
|
+
* cannot close that: the leg grades two kernels against each other, so a guard
|
|
500
|
+
* deleted from both is invisible to it. Only a hand-written `expected` block
|
|
501
|
+
* states which side of the boundary an input falls on.
|
|
502
|
+
*/
|
|
503
|
+
function hexDecode(data) {
|
|
504
|
+
// An odd number of characters is not hex. Deleting this line lets `"ab0"`
|
|
505
|
+
// read as `0xab 0x00`, so a receiver handed a truncated hex string writes
|
|
506
|
+
// `part_encoding_mismatch` where it owes `part_mismatched` and a
|
|
507
|
+
// substitution: `parts-decoder-hex-odd-length-01`.
|
|
508
|
+
if (data.length === 0 || data.length % 2 !== 0)
|
|
509
|
+
return null;
|
|
510
|
+
const text = asAscii(data);
|
|
511
|
+
if (!HEX_CHARSET.test(text))
|
|
512
|
+
return null;
|
|
513
|
+
const out = new Uint8Array(data.length / 2);
|
|
514
|
+
for (let i = 0; i < out.length; i++) {
|
|
515
|
+
out[i] = Number.parseInt(text.slice(i * 2, i * 2 + 2), 16);
|
|
516
|
+
}
|
|
517
|
+
return out;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Strict base64, standard OR URL-safe, padded or not, canonical only.
|
|
521
|
+
*
|
|
522
|
+
* Written out for the same reason as `hexDecode`: Python's `base64.b64decode`
|
|
523
|
+
* and JavaScript's `atob` disagree about padding, about the URL-safe alphabet
|
|
524
|
+
* and about non-zero trailing bits, and each of those disagreements decides
|
|
525
|
+
* whether a receiver refuses its own decoding defect or records a tamper
|
|
526
|
+
* against an honest sender. The two alphabets are not mixed: a string carrying
|
|
527
|
+
* one character from each is neither spelling and decodes to nothing.
|
|
528
|
+
*
|
|
529
|
+
* TWO GUARDS THAT STOOD HERE WERE DELETED IN THE PROOF#156 SECOND GATE'S
|
|
530
|
+
* ROUND, because they could not change a decode for any input and so read as
|
|
531
|
+
* protection that was not there:
|
|
532
|
+
*
|
|
533
|
+
* * `pad > 0 && text.length % 4 !== 0` is the SAME PREDICATE as the
|
|
534
|
+
* `(body.length + pad) % 4` test below it. The strip loop above makes
|
|
535
|
+
* `text` exactly `body + "=".repeat(pad)`, so the two lengths are equal by
|
|
536
|
+
* construction and the two conditions are one condition written twice.
|
|
537
|
+
* Each masked the other, which is why deleting either alone changed
|
|
538
|
+
* nothing;
|
|
539
|
+
* * `body.includes("=")` is subsumed by the alphabet test three lines below
|
|
540
|
+
* it: `=` is in neither alphabet, so a body carrying one is refused there.
|
|
541
|
+
*
|
|
542
|
+
* Checked exhaustively over every byte string of length 0 to 2 across all 256
|
|
543
|
+
* byte values, exhaustively over lengths 3 to 6 on the 16-character alphabet
|
|
544
|
+
* that reaches these branches, and over 500,000 random byte strings: zero
|
|
545
|
+
* decodes move.
|
|
546
|
+
* The remaining guards each carry the vector that pins them.
|
|
547
|
+
*/
|
|
548
|
+
function base64Decode(data) {
|
|
549
|
+
if (data.length === 0)
|
|
550
|
+
return null;
|
|
551
|
+
const text = asAscii(data);
|
|
552
|
+
let body = text;
|
|
553
|
+
let pad = 0;
|
|
554
|
+
while (body.endsWith("=")) {
|
|
555
|
+
body = body.slice(0, -1);
|
|
556
|
+
pad += 1;
|
|
557
|
+
}
|
|
558
|
+
// At most two padding characters. Deleting this line lets `"===="` and any
|
|
559
|
+
// aligned body followed by four `=` decode as though the padding were not
|
|
560
|
+
// there: `parts-decoder-base64-four-pad-chars-01`.
|
|
561
|
+
if (pad > 2)
|
|
562
|
+
return null;
|
|
563
|
+
const standard = [...body].every((c) => BASE64_STANDARD.includes(c));
|
|
564
|
+
const urlsafe = [...body].every((c) => BASE64_URLSAFE.includes(c));
|
|
565
|
+
// One alphabet or the other, never a merge of the two. `"-/AA"` is neither
|
|
566
|
+
// spelling, and deleting this line does not merge the alphabets: it picks
|
|
567
|
+
// the URL-safe one and asks it for `/`, where `indexOf` answers -1 and
|
|
568
|
+
// garbles the arithmetic into `f7 f0 00`. TWO vectors pin that, one per
|
|
569
|
+
// shape of third kernel: `parts-decoder-base64-mixed-alphabet-01` commits
|
|
570
|
+
// the bytes a kernel that MERGES the alphabets reads, and
|
|
571
|
+
// `parts-decoder-base64-mixed-alphabet-02` commits the bytes this kernel's
|
|
572
|
+
// own -1 produces, which is the one that goes red HERE (proof#156 third
|
|
573
|
+
// gate, C2: deleting this line left all 2224 TypeScript tests green).
|
|
574
|
+
if (!standard && !urlsafe)
|
|
575
|
+
return null;
|
|
576
|
+
const alphabet = standard ? BASE64_STANDARD : BASE64_URLSAFE;
|
|
577
|
+
// A lone trailing character carries six bits and no encoder emits one.
|
|
578
|
+
// Deleting this lets `"A"` read as the EMPTY blob, so any of the four
|
|
579
|
+
// characters whose low bits are zero could claim to be it:
|
|
580
|
+
// `parts-decoder-base64-single-character-01`.
|
|
581
|
+
if (body.length % 4 === 1)
|
|
582
|
+
return null;
|
|
583
|
+
// Padding that does not align the string. Deleting this lets a 36-character
|
|
584
|
+
// body followed by one `=` decode as though the `=` were not there:
|
|
585
|
+
// `parts-decoder-base64-padding-misaligned-01`.
|
|
586
|
+
if (pad > 0 && (body.length + pad) % 4 !== 0)
|
|
587
|
+
return null;
|
|
588
|
+
let bits = 0;
|
|
589
|
+
let width = 0;
|
|
590
|
+
const out = [];
|
|
591
|
+
for (const char of body) {
|
|
592
|
+
bits = bits * 64 + alphabet.indexOf(char);
|
|
593
|
+
width += 6;
|
|
594
|
+
if (width >= 8) {
|
|
595
|
+
width -= 8;
|
|
596
|
+
out.push(Math.floor(bits / 2 ** width) & 0xff);
|
|
597
|
+
bits = bits % 2 ** width;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Canonical only: the unused low bits of the last character are zero in every
|
|
601
|
+
// encoder's output, and accepting them non-zero would let two spellings of one
|
|
602
|
+
// blob decode to one value. Both `atob` and `base64.b64decode` accept them,
|
|
603
|
+
// which is the sharpest reason this decoder is written out: deleting this
|
|
604
|
+
// line lets `"QR=="` read as `"A"`, so a receiver handed one of the sixteen
|
|
605
|
+
// non-canonical spellings of a one-byte part writes `part_encoding_mismatch`
|
|
606
|
+
// where it owes `part_mismatched` and a substitution:
|
|
607
|
+
// `parts-decoder-non-canonical-tail-01`.
|
|
608
|
+
if (width > 0 && bits !== 0)
|
|
609
|
+
return null;
|
|
610
|
+
return new Uint8Array(out);
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* The inverse of `base64Decode`, written out for the same reason: `btoa` and
|
|
614
|
+
* Python's `base64` module differ in what they accept and in how they pad, and
|
|
615
|
+
* this value is compared against a digest a sender committed, so the two
|
|
616
|
+
* kernels have to emit the same characters.
|
|
617
|
+
*/
|
|
618
|
+
function base64Encode(data, alphabet, pad) {
|
|
619
|
+
let out = "";
|
|
620
|
+
for (let start = 0; start < data.length; start += 3) {
|
|
621
|
+
const chunk = data.subarray(start, start + 3);
|
|
622
|
+
const bits = (chunk[0] << 16) | ((chunk[1] ?? 0) << 8) | (chunk[2] ?? 0);
|
|
623
|
+
const chars = [18, 12, 6, 0].map((shift) => alphabet[(bits >> shift) & 0x3f]);
|
|
624
|
+
const keep = chunk.length + 1;
|
|
625
|
+
out += chars.slice(0, keep).join("");
|
|
626
|
+
if (pad && keep < 4)
|
|
627
|
+
out += "=".repeat(4 - keep);
|
|
628
|
+
}
|
|
629
|
+
return asciiBytes(out);
|
|
630
|
+
}
|
|
631
|
+
function hexEncode(data, alphabet) {
|
|
632
|
+
let out = "";
|
|
633
|
+
for (const byte of data)
|
|
634
|
+
out += alphabet[byte >> 4] + alphabet[byte & 0x0f];
|
|
635
|
+
return asciiBytes(out);
|
|
636
|
+
}
|
|
637
|
+
function asciiBytes(text) {
|
|
638
|
+
const out = new Uint8Array(text.length);
|
|
639
|
+
for (let i = 0; i < text.length; i++)
|
|
640
|
+
out[i] = text.charCodeAt(i) & 0xff;
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Every spelling of `data` an honest stack might have been handed.
|
|
645
|
+
*
|
|
646
|
+
* The ENCODING direction of the encoding guard. A receiver whose stack decoded
|
|
647
|
+
* what it was given holds the decoded bytes while the sender committed the
|
|
648
|
+
* encoded text, which is what a single-line DER attachment, a PEM body and a
|
|
649
|
+
* JWT each are: the part's bytes ARE the base64 text. Guarding only the
|
|
650
|
+
* decoding direction told that receiver it held a tamper and fired the
|
|
651
|
+
* substitution, which is the one outcome this module calls worse than recording
|
|
652
|
+
* nothing.
|
|
653
|
+
*
|
|
654
|
+
* The list is fixed and short, the same six spellings the decoder accepts, so
|
|
655
|
+
* the two kernels try the same candidates in the same order and the cost is
|
|
656
|
+
* linear in the length of the supply.
|
|
657
|
+
*
|
|
658
|
+
* A `data.length === 0` guard stood here and was deleted in the proof#156
|
|
659
|
+
* second gate's round, for the same reason as the two in `base64Decode`: it
|
|
660
|
+
* could not change an answer. This is reached only from `respellingExplains`,
|
|
661
|
+
* and only after `sha256(data)` has already been compared to `contentHash` and
|
|
662
|
+
* missed. Every spelling of the empty byte string is the empty byte string, so
|
|
663
|
+
* the one candidate it would have produced is the digest that just failed. An
|
|
664
|
+
* optimization written as a guard reads as protection that is not there.
|
|
665
|
+
*/
|
|
666
|
+
function respellings(data) {
|
|
667
|
+
return [
|
|
668
|
+
base64Encode(data, BASE64_STANDARD, true),
|
|
669
|
+
base64Encode(data, BASE64_STANDARD, false),
|
|
670
|
+
base64Encode(data, BASE64_URLSAFE, true),
|
|
671
|
+
base64Encode(data, BASE64_URLSAFE, false),
|
|
672
|
+
hexEncode(data, LOWER_HEX),
|
|
673
|
+
hexEncode(data, UPPER_HEX),
|
|
674
|
+
];
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Whether a base64 or hex respelling of `data`, in EITHER direction, hashes to
|
|
678
|
+
* `contentHash`. `contentHash` arrives lowercased: the charset is pinned and
|
|
679
|
+
* the case is not (proof#135), so every comparison in this module is made on
|
|
680
|
+
* one spelling.
|
|
681
|
+
*/
|
|
682
|
+
async function respellingExplains(data, contentHash) {
|
|
683
|
+
for (const decoded of [base64Decode(data), hexDecode(data)]) {
|
|
684
|
+
if (decoded !== null && (await sha256Hex(decoded)) === contentHash)
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
for (const encoded of respellings(data)) {
|
|
688
|
+
if ((await sha256Hex(encoded)) === contentHash)
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* What a receiver commits to, given the message and the bytes it holds.
|
|
695
|
+
*
|
|
696
|
+
* `supplied` is the bytes the receiver actually holds, each named by the index
|
|
697
|
+
* of the part it belongs to. An empty list is the common case, a receiver that
|
|
698
|
+
* holds no part's bytes, and it reads exactly as section 4.1 read before this
|
|
699
|
+
* amendment: every file part is `PART_UNCHECKED` and the message a receiver
|
|
700
|
+
* commits to is the message it was given.
|
|
701
|
+
*
|
|
702
|
+
* Where a part's bytes hash to something other than its `content_hash`, or
|
|
703
|
+
* where their count is not its `content_length`, the returned `heldMessage`
|
|
704
|
+
* carries that part's `content_hash` and `content_length` replaced by the held
|
|
705
|
+
* bytes' own. The receiver commits `event.message_hash` over THAT, so its
|
|
706
|
+
* record and the sender's disagree and the pair fails on `message_hash`.
|
|
707
|
+
*
|
|
708
|
+
* A refusal is scoped to the supplied entry it is about: the part it names is
|
|
709
|
+
* not checked and not substituted, every other part reads as it would have, and
|
|
710
|
+
* `heldMessage` is returned either way. Nothing a caller or a sender can do
|
|
711
|
+
* denies the receiver a record.
|
|
712
|
+
*/
|
|
713
|
+
export async function commitHeldMessage(message, supplied = []) {
|
|
714
|
+
const parts = messageParts(message);
|
|
715
|
+
const classifications = parts.map(classify);
|
|
716
|
+
// The inline hash agreement, refined out of the shape pass because it hashes.
|
|
717
|
+
for (let i = 0; i < parts.length; i++) {
|
|
718
|
+
if (classifications[i] !== PART_INLINE_BYTES)
|
|
719
|
+
continue;
|
|
720
|
+
if (!(await inlineClaimsAgree(parts[i]))) {
|
|
721
|
+
classifications[i] = PART_INLINE_MISMATCHED;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
const refusals = [];
|
|
725
|
+
const notes = [];
|
|
726
|
+
const byPart = new Map();
|
|
727
|
+
supplied.forEach((item, entry) => {
|
|
728
|
+
const index = item.part;
|
|
729
|
+
if (!isSafeIndex(index) || index >= parts.length) {
|
|
730
|
+
// No part of this message: the only genuinely message-level refusal, and
|
|
731
|
+
// it still names itself rather than going quiet.
|
|
732
|
+
refusals.push({ name: BYTES_FOR_UNKNOWN_PART, part: null, entry });
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (classifications[index] !== null) {
|
|
736
|
+
// A part that EXISTS and cannot be checked. Scoped to that part, so a
|
|
737
|
+
// sender cannot buy silence by emitting one uncheckable part beside the
|
|
738
|
+
// part it altered.
|
|
739
|
+
refusals.push({ name: BYTES_FOR_UNKNOWN_PART, part: index, entry });
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const key = index;
|
|
743
|
+
const existing = byPart.get(key);
|
|
744
|
+
if (existing === undefined)
|
|
745
|
+
byPart.set(key, [{ entry, data: item.data }]);
|
|
746
|
+
else
|
|
747
|
+
existing.push({ entry, data: item.data });
|
|
748
|
+
});
|
|
749
|
+
const readings = [];
|
|
750
|
+
const substituted = [];
|
|
751
|
+
const replacements = new Map();
|
|
752
|
+
for (let index = 0; index < parts.length; index++) {
|
|
753
|
+
const pre = classifications[index];
|
|
754
|
+
if (pre !== null) {
|
|
755
|
+
readings.push({ index, result: pre });
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
const entries = byPart.get(index) ?? [];
|
|
759
|
+
if (entries.length === 0) {
|
|
760
|
+
readings.push({ index, result: PART_UNCHECKED });
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
const first = entries[0];
|
|
764
|
+
const conflicting = entries.slice(1).find((e) => !sameBytes(e.data, first.data));
|
|
765
|
+
if (conflicting !== undefined) {
|
|
766
|
+
refusals.push({ name: CONFLICTING_PART_BYTES, part: index, entry: conflicting.entry });
|
|
767
|
+
readings.push({ index, result: PART_UNCHECKED });
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
if (entries.length > 1) {
|
|
771
|
+
notes.push({ name: PART_SUPPLIED_TWICE, part: index, entry: entries[1].entry });
|
|
772
|
+
}
|
|
773
|
+
const part = parts[index];
|
|
774
|
+
const contentHash = part.content_hash.toLowerCase();
|
|
775
|
+
const contentLength = part.content_length;
|
|
776
|
+
const digest = await sha256Hex(first.data);
|
|
777
|
+
if (digest === contentHash) {
|
|
778
|
+
// The LENGTH is compared too, and a disagreement substitutes exactly as a
|
|
779
|
+
// hash disagreement does (proof#156 gate). Before that, a part whose
|
|
780
|
+
// stated length the receiver could see was false read `part_matched`, so
|
|
781
|
+
// the receiver certified the part and committed the false length with it.
|
|
782
|
+
if (first.data.length === contentLength) {
|
|
783
|
+
readings.push({ index, result: PART_MATCHED });
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
readings.push({ index, result: PART_MISMATCHED });
|
|
787
|
+
substituted.push(index);
|
|
788
|
+
replacements.set(index, { contentHash: digest, contentLength: first.data.length });
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
if (await respellingExplains(first.data, contentHash)) {
|
|
792
|
+
refusals.push({ name: PART_ENCODING_MISMATCH, part: index, entry: first.entry });
|
|
793
|
+
readings.push({ index, result: PART_UNCHECKED });
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
readings.push({ index, result: PART_MISMATCHED });
|
|
797
|
+
substituted.push(index);
|
|
798
|
+
replacements.set(index, { contentHash: digest, contentLength: first.data.length });
|
|
799
|
+
}
|
|
800
|
+
const rank = (r) => [
|
|
801
|
+
REFUSAL_ORDER.indexOf(r.name),
|
|
802
|
+
r.part === null ? -1 : r.part,
|
|
803
|
+
r.entry === null ? -1 : r.entry,
|
|
804
|
+
];
|
|
805
|
+
refusals.sort((a, b) => {
|
|
806
|
+
const [an, ap, ae] = rank(a);
|
|
807
|
+
const [bn, bp, be] = rank(b);
|
|
808
|
+
return an - bn || ap - bp || ae - be;
|
|
809
|
+
});
|
|
810
|
+
notes.sort((a, b) => {
|
|
811
|
+
if (a.name !== b.name)
|
|
812
|
+
return a.name < b.name ? -1 : 1;
|
|
813
|
+
return (a.part === null ? -1 : a.part) - (b.part === null ? -1 : b.part);
|
|
814
|
+
});
|
|
815
|
+
let held = message;
|
|
816
|
+
if (replacements.size > 0) {
|
|
817
|
+
// Rebuilt rather than mutated in place: the message a caller handed in is
|
|
818
|
+
// the caller's, and a reading that edits its input is a reading that
|
|
819
|
+
// changes what the caller commits to on the honest path too.
|
|
820
|
+
const heldParts = parts.map((part, index) => {
|
|
821
|
+
const replacement = replacements.get(index);
|
|
822
|
+
if (replacement === undefined)
|
|
823
|
+
return part;
|
|
824
|
+
return {
|
|
825
|
+
...part,
|
|
826
|
+
content_hash: replacement.contentHash,
|
|
827
|
+
content_length: replacement.contentLength,
|
|
828
|
+
};
|
|
829
|
+
});
|
|
830
|
+
held = { ...message, parts: heldParts };
|
|
831
|
+
}
|
|
832
|
+
const order = MESSAGE_RESULT_ORDER;
|
|
833
|
+
return {
|
|
834
|
+
parts: readings,
|
|
835
|
+
messageResults: messageResults(message).sort((a, b) => order.indexOf(a) - order.indexOf(b)),
|
|
836
|
+
refusals,
|
|
837
|
+
notes,
|
|
838
|
+
heldMessage: held,
|
|
839
|
+
substituted,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* The parts the receiver HELD bytes for and did NOT check.
|
|
844
|
+
*
|
|
845
|
+
* The gap between "nothing was substituted" and "every part is clean", named
|
|
846
|
+
* so a caller does not have to re-derive it from the refusal list (proof#156
|
|
847
|
+
* second gate, F5). A part is in here where a refusal NAMES it and its reading
|
|
848
|
+
* is one of `HELD_UNCHECKED_RESULTS`: the receiver had bytes for that part,
|
|
849
|
+
* the rule declined to grade them, and NOTHING about that reaches the
|
|
850
|
+
* committed record.
|
|
851
|
+
*
|
|
852
|
+
* THE RULE, in one sentence, because a filter is not a rule. A part is in here
|
|
853
|
+
* where the receiver held bytes for it AND the message itself does not commit
|
|
854
|
+
* its content. `PART_UNCHECKED` is a refused supply;
|
|
855
|
+
* `PART_MALFORMED_DESCRIPTOR` is a part whose content travels BESIDE the
|
|
856
|
+
* message under a claim no receiver can use, so the receiver's bytes are
|
|
857
|
+
* exactly as ungraded and the record says as little. The first cut of this
|
|
858
|
+
* function omitted the second, which the third gate measured: a `kind: "file"`
|
|
859
|
+
* part with a non-conformant `content_hash` or `content_length`, bytes held,
|
|
860
|
+
* read `[]` here, so a caller following the rule this module prescribes wrote
|
|
861
|
+
* `accepted` over a part it held bytes for and never checked. That is the
|
|
862
|
+
* class the mechanism exists to close, and Phil named it in proof#162's
|
|
863
|
+
* ratification as the gap that decision inherits unless it is closed here.
|
|
864
|
+
*
|
|
865
|
+
* Three results are excluded ON PURPOSE. An inline part (`PART_INLINE_BYTES`,
|
|
866
|
+
* `PART_INLINE_MISMATCHED`) and a non-file part (`PART_NOT_A_FILE`) carry
|
|
867
|
+
* their content INSIDE the message, and `message_hash` commits it on both
|
|
868
|
+
* sides: bytes supplied beside one of those grade nothing and hide nothing,
|
|
869
|
+
* because a reader of either pack reads the content itself. Naming them would
|
|
870
|
+
* fire the caller's obligation on parts the swapped-bytes attack cannot reach
|
|
871
|
+
* at all.
|
|
872
|
+
*
|
|
873
|
+
* Why it is worth a function rather than a note. Delivering a tampered copy of
|
|
874
|
+
* a part alone is `part_mismatched`, is substituted, and fails the pair on
|
|
875
|
+
* `message_hash`. Delivering the tampered copy AND the true one is
|
|
876
|
+
* `conflicting_part_bytes`, is never substituted, and the pair VERIFIES, so
|
|
877
|
+
* adding data suppresses detection and the only trace is a refusal the
|
|
878
|
+
* receiving side publishes at its own discretion.
|
|
879
|
+
*
|
|
880
|
+
* NOT the same as "this part reads `PART_UNCHECKED`". A receiver that holds no
|
|
881
|
+
* bytes at all reads every file part that way, and that is the COMMON CASE,
|
|
882
|
+
* unchanged by A18 and not a reason to withhold `accepted`. Only a part whose
|
|
883
|
+
* bytes the receiver actually held and could not grade is in here.
|
|
884
|
+
*
|
|
885
|
+
* What this does NOT do: it cannot stop a caller writing `accepted` anyway.
|
|
886
|
+
* This module reads and does not write the record. proof#162 ratified what the
|
|
887
|
+
* record layer does instead (Phil, 2026-09-22): `accepted` keeps its meaning
|
|
888
|
+
* and the COUNT of these parts rides in an additive `context` key, REQUIRED
|
|
889
|
+
* when it is not zero, enforced against SDKs through conformance vectors. So a
|
|
890
|
+
* part missing from this list is a part no record built on that rule will ever
|
|
891
|
+
* mention.
|
|
892
|
+
*/
|
|
893
|
+
export function uncheckedHeldParts(reading) {
|
|
894
|
+
const unchecked = new Set(reading.parts
|
|
895
|
+
.filter((p) => HELD_UNCHECKED_RESULTS.includes(p.result))
|
|
896
|
+
.map((p) => p.index));
|
|
897
|
+
const named = new Set();
|
|
898
|
+
for (const refusal of reading.refusals) {
|
|
899
|
+
if (refusal.part !== null && unchecked.has(refusal.part))
|
|
900
|
+
named.add(refusal.part);
|
|
901
|
+
}
|
|
902
|
+
return [...named].sort((a, b) => a - b);
|
|
903
|
+
}
|