@sanning/proof 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -29
- package/dist/agent-record.d.ts +62 -0
- package/dist/agent-record.js +527 -0
- package/dist/agent-schemas.generated.d.ts +15 -0
- package/dist/agent-schemas.generated.js +5759 -0
- package/dist/case-file.d.ts +32 -89
- package/dist/case-file.js +41 -93
- package/dist/case.d.ts +343 -810
- package/dist/case.js +693 -1151
- package/dist/chain.d.ts +19 -47
- package/dist/chain.js +78 -205
- package/dist/claims.d.ts +211 -0
- package/dist/claims.js +1081 -0
- package/dist/cli.d.ts +45 -68
- package/dist/cli.js +618 -1022
- package/dist/compose.js +23 -65
- package/dist/crypto.d.ts +7 -8
- package/dist/crypto.js +60 -147
- package/dist/declaration.d.ts +68 -194
- package/dist/declaration.js +175 -518
- package/dist/environment.d.ts +9 -30
- package/dist/environment.js +17 -66
- package/dist/evidence.d.ts +119 -160
- package/dist/evidence.js +744 -1103
- package/dist/exit-codes.d.ts +11 -27
- package/dist/exit-codes.js +24 -59
- package/dist/file-match.d.ts +33 -79
- package/dist/file-match.js +33 -69
- package/dist/grading.d.ts +98 -135
- package/dist/grading.js +224 -380
- package/dist/handoff.d.ts +62 -160
- package/dist/handoff.js +153 -393
- package/dist/index.d.ts +14 -10
- package/dist/index.js +60 -167
- package/dist/json-schema.d.ts +29 -0
- package/dist/json-schema.js +298 -0
- package/dist/merkle.js +16 -28
- package/dist/message-parts.d.ts +56 -212
- package/dist/message-parts.js +142 -502
- package/dist/side-content.d.ts +11 -22
- package/dist/side-content.js +22 -65
- package/dist/types.d.ts +17 -0
- package/dist/types.js +19 -5
- package/dist/verifier.d.ts +40 -114
- package/dist/verifier.js +196 -535
- package/dist/witness-cache.d.ts +24 -45
- package/dist/witness-cache.js +38 -81
- package/dist/witness-lookup.d.ts +36 -57
- package/dist/witness-lookup.js +89 -139
- package/dist/zip-reader.d.ts +18 -33
- package/dist/zip-reader.js +19 -34
- package/package.json +11 -3
- package/src/agent-record.ts +608 -0
- package/src/agent-schemas.generated.ts +5764 -0
- package/src/case-file.ts +45 -109
- package/src/case.ts +848 -1322
- package/src/chain.ts +85 -224
- package/src/claims.ts +1253 -0
- package/src/cli.ts +714 -1176
- package/src/compose.ts +33 -79
- package/src/crypto.ts +60 -147
- package/src/declaration.ts +219 -656
- package/src/environment.ts +17 -65
- package/src/evidence.ts +958 -1381
- package/src/exit-codes.ts +24 -63
- package/src/file-match.ts +40 -89
- package/src/grading.ts +287 -421
- package/src/handoff.ts +175 -427
- package/src/index.ts +132 -177
- package/src/json-schema.ts +295 -0
- package/src/merkle.ts +16 -28
- package/src/message-parts.ts +155 -533
- package/src/side-content.ts +25 -68
- package/src/types.ts +55 -36
- package/src/verifier.ts +202 -526
- package/src/witness-cache.ts +45 -94
- package/src/witness-lookup.ts +107 -177
- package/src/zip-reader.ts +20 -38
- package/vectors/README.md +52 -34
- package/vectors/agent-record.json +2175 -0
- package/vectors/case-file-index.json +1 -1
- package/vectors/case-split.json +2 -2
- package/vectors/chain-reading.json +3 -3
- package/vectors/file-match.json +285 -148
- package/vectors/jcs-reader.json +6 -6
- package/vectors/pack-grading.json +102 -25
- package/vectors/sandbox-evidence.json +597 -580
- package/vectors/stamp-on-chain.json +18 -18
- package/vectors/witness-cache.json +49 -49
- package/dist/agent-proof.d.ts +0 -28
- package/dist/agent-proof.js +0 -226
- package/dist/run-adapters.d.ts +0 -10
- package/dist/run-adapters.js +0 -35
- package/dist/run.d.ts +0 -209
- package/dist/run.js +0 -1405
- package/src/agent-proof.ts +0 -264
- package/src/run-adapters.ts +0 -44
- package/src/run.ts +0 -1598
package/src/message-parts.ts
CHANGED
|
@@ -1,275 +1,115 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* `
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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.
|
|
2
|
+
* File parts of a hand-off message, and the message a receiver holding their
|
|
3
|
+
* bytes commits to (envelope-spec.md §4.1). Conformance:
|
|
4
|
+
* `test-vectors/handoff/parts/`.
|
|
5
|
+
*
|
|
6
|
+
* A file part is `kind: "file"` plus `content_hash` (SHA-256 of the exact
|
|
7
|
+
* bytes, 64 hex in either case) and `content_length` (a safe integer), with
|
|
8
|
+
* the bytes outside the message. A receiver handed a part's bytes must hash
|
|
9
|
+
* them. Where the digest or the length differs from the descriptor, the
|
|
10
|
+
* receiver commits to the message as it actually held it: that part's
|
|
11
|
+
* `content_hash` and `content_length` replaced by the held bytes' own. Its
|
|
12
|
+
* `message_hash` then differs from the sender's and the pair fails, so a
|
|
13
|
+
* transport that keeps the descriptor and swaps the bytes is caught in the
|
|
14
|
+
* committed record, not in a finding only the receiver's reader sees.
|
|
15
|
+
*
|
|
16
|
+
* An inline part (content inside the message) is committed by `message_hash`
|
|
17
|
+
* itself. If it also carries a `content_hash` or `content_length`, both are
|
|
18
|
+
* checked against the inline content and a disagreement is named.
|
|
19
|
+
*
|
|
20
|
+
* A refusal is scoped to its part and never stops the record being written:
|
|
21
|
+
* `heldMessage` is never null, so neither a sender nor a bad supply can deny
|
|
22
|
+
* the receiver a record or launder a swap on another part.
|
|
23
|
+
*
|
|
24
|
+
* `substituted` decides `rejected`; it does not make a hand-off clean. A caller
|
|
25
|
+
* writes `accepted` only when `substituted` and `uncheckedHeldParts` are both
|
|
26
|
+
* empty. Supplying a tampered copy alongside the true one reads
|
|
27
|
+
* `conflicting_part_bytes` with nothing substituted, so held-but-unchecked parts
|
|
28
|
+
* must be counted in the record.
|
|
29
|
+
*
|
|
30
|
+
* This module does not canonicalize or hash the message; the caller hashes
|
|
31
|
+
* `heldMessage` as §4.1 states.
|
|
116
32
|
*/
|
|
117
33
|
import { sha256Hex } from "./crypto.js";
|
|
118
34
|
|
|
119
|
-
/**
|
|
120
|
-
* The result of a part whose supplied bytes hash to its `content_hash` AND
|
|
121
|
-
* count to its `content_length`.
|
|
122
|
-
*/
|
|
35
|
+
/** The supplied bytes match both `content_hash` and `content_length`. */
|
|
123
36
|
export const PART_MATCHED = "part_matched";
|
|
124
37
|
|
|
125
38
|
/**
|
|
126
|
-
* The
|
|
127
|
-
*
|
|
128
|
-
* applies to this part, and it replaces BOTH values with the held bytes' own.
|
|
39
|
+
* The supplied bytes miss either value, and no respelling explains the digest.
|
|
40
|
+
* The held message replaces both values with the held bytes' own.
|
|
129
41
|
*/
|
|
130
42
|
export const PART_MISMATCHED = "part_mismatched";
|
|
131
43
|
|
|
132
|
-
/**
|
|
133
|
-
* A file part whose bytes were not supplied, or whose supply was REFUSED. It is
|
|
134
|
-
* not checked and it is not clean: section 4.1 forbids a reader treating it as
|
|
135
|
-
* checked.
|
|
136
|
-
*/
|
|
44
|
+
/** Bytes not supplied, or the supply was refused. Not checked, and not clean. */
|
|
137
45
|
export const PART_UNCHECKED = "part_unchecked";
|
|
138
46
|
|
|
139
47
|
/**
|
|
140
|
-
* A
|
|
141
|
-
*
|
|
142
|
-
* ITSELF: `message_hash` is SHA-256 over the canonical bytes of the message
|
|
143
|
-
* object, so altering that content changes the message, changes the hash, and
|
|
144
|
-
* fails the pair. Nothing is hashed against it here and nothing is substituted,
|
|
145
|
-
* because there is nothing travelling beside the message for the message to
|
|
146
|
-
* disagree with. That is why the swapped-bytes attack cannot reach this shape.
|
|
48
|
+
* A file part with its content inside the message, committed by
|
|
49
|
+
* `message_hash` itself. Nothing is hashed or substituted.
|
|
147
50
|
*/
|
|
148
51
|
export const PART_INLINE_BYTES = "part_inline_bytes";
|
|
149
52
|
|
|
150
53
|
/**
|
|
151
|
-
* An inline part whose `content_hash
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* nothing is substituted; this names the SECOND claim that disagrees with it,
|
|
155
|
-
* which is a finding in its own right (proof#157).
|
|
156
|
-
*
|
|
157
|
-
* ONE name for both claims, exactly as `PART_MISMATCHED` is one name for both
|
|
158
|
-
* on a descriptor part. It was `part_inline_hash_mismatch` for one round of
|
|
159
|
-
* this unmerged PR, when only the hash was compared; a name that says "hash"
|
|
160
|
-
* about a result that also covers a length asserts more than its body does, so
|
|
161
|
-
* the name was re-cut with the rule (proof#156 second gate, F4).
|
|
162
|
-
*
|
|
163
|
-
* The check is deliberately permissive ACROSS readings: the raw UTF-8 of the
|
|
164
|
-
* value, its base64 decoding and its hex decoding are all tried, so this fires
|
|
165
|
-
* only where no reading agrees at all. It is not permissive BETWEEN claims: one
|
|
166
|
-
* reading has to satisfy all of them, because a hash that agrees under the
|
|
167
|
-
* base64 decoding beside a length that agrees under the raw text describes no
|
|
168
|
-
* blob that exists.
|
|
54
|
+
* An inline part whose `content_hash` or `content_length` disagrees with its
|
|
55
|
+
* inline content. The raw UTF-8, base64 and hex readings are all tried, but one
|
|
56
|
+
* reading must satisfy every claim present. Nothing is substituted.
|
|
169
57
|
*/
|
|
170
58
|
export const PART_INLINE_MISMATCHED = "part_inline_mismatched";
|
|
171
59
|
|
|
172
60
|
/**
|
|
173
61
|
* `kind: "file"` without a conformant `content_hash` and `content_length`. A
|
|
174
|
-
* sender's defect
|
|
175
|
-
* the receiver did nothing wrong by holding it.
|
|
62
|
+
* sender's defect, so it is named rather than refused.
|
|
176
63
|
*/
|
|
177
64
|
export const PART_MALFORMED_DESCRIPTOR = "part_malformed_descriptor";
|
|
178
65
|
|
|
179
|
-
/**
|
|
180
|
-
* Any other part. It is committed as ordinary JSON content and this rule checks
|
|
181
|
-
* nothing about it.
|
|
182
|
-
*/
|
|
66
|
+
/** Any other part: ordinary JSON content, not checked by this rule. */
|
|
183
67
|
export const PART_NOT_A_FILE = "part_not_a_file";
|
|
184
68
|
|
|
185
69
|
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* there establishes nothing. Where the entry names a part that EXISTS but is
|
|
190
|
-
* not checkable, the refusal carries that part's index and the part keeps the
|
|
191
|
-
* result it classified to; where it names no part at all, the refusal carries
|
|
192
|
-
* the entry position and no part. Neither stops the record being written.
|
|
70
|
+
* Refusal of one supplied entry: it names no part, or a part this rule cannot
|
|
71
|
+
* check. Carries that part's index when the part exists, else only the entry
|
|
72
|
+
* position.
|
|
193
73
|
*/
|
|
194
74
|
export const BYTES_FOR_UNKNOWN_PART = "bytes_for_unknown_part";
|
|
195
75
|
|
|
196
|
-
/**
|
|
197
|
-
* REFUSAL. Two DIFFERENT byte strings were supplied for one part. A receiver
|
|
198
|
-
* holding two answers has not established which one it held.
|
|
199
|
-
*/
|
|
76
|
+
/** Refusal: two different byte strings were supplied for one part. */
|
|
200
77
|
export const CONFLICTING_PART_BYTES = "conflicting_part_bytes";
|
|
201
78
|
|
|
202
79
|
/**
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
* attachment, a PEM body and a JWT all are). That is a coding defect in the
|
|
209
|
-
* receiver, not a tamper by the sender: a proto3 JSON transcoder re-encodes
|
|
210
|
-
* base64 URL-safe and unpadded while behaving honestly, and under the silence
|
|
211
|
-
* this amendment removes it produced a permanent tamper record against a sender
|
|
212
|
-
* who did nothing. Recording evidence out of a receiver's own bug is the one
|
|
213
|
-
* outcome worse than recording nothing, so BOTH directions are guarded
|
|
214
|
-
* (proof#156 gate): guarding only the decoding direction left the other half of
|
|
215
|
-
* one honest stack's behavior reading as a tamper.
|
|
80
|
+
* Refusal of one supplied entry: the bytes miss the descriptor, but a base64
|
|
81
|
+
* or hex respelling in either direction hashes to it. That is a receiver's
|
|
82
|
+
* coding defect (an undecoded supply, or a decoded PEM, DER or JWT the sender
|
|
83
|
+
* committed as text), not a sender's tamper, so it must not produce a tamper
|
|
84
|
+
* record.
|
|
216
85
|
*/
|
|
217
86
|
export const PART_ENCODING_MISMATCH = "part_encoding_mismatch";
|
|
218
87
|
|
|
219
88
|
/**
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* reachable from the same transport family the motivating example comes from.
|
|
223
|
-
* Section 4.1 asks for one result per part; a message shaped like this has
|
|
224
|
-
* part-shaped content and no parts, and before proof#156's gate it produced
|
|
225
|
-
* zero readings and an accepted record. It is named rather than refused: the
|
|
226
|
-
* receiver did nothing wrong by holding it, and nothing in it is checked. A
|
|
227
|
-
* `parts` written as `null` is an ABSENT `parts` and is not this, on A14's
|
|
228
|
-
* ruling that an absent value and one written as `null` name one thing.
|
|
89
|
+
* Message-level: `parts` is present, not null and not an array (proto3 JSON
|
|
90
|
+
* encodes a map this way). Named, not refused. A `parts` of `null` is absent.
|
|
229
91
|
*/
|
|
230
92
|
export const PARTS_NOT_AN_ARRAY = "parts_not_an_array";
|
|
231
93
|
|
|
232
|
-
/**
|
|
233
|
-
* NOTE, not a refusal. One part supplied more than once with IDENTICAL bytes is
|
|
234
|
-
* one supply stated twice, and the part is checked normally. Different bytes for
|
|
235
|
-
* one part are `CONFLICTING_PART_BYTES`.
|
|
236
|
-
*/
|
|
94
|
+
/** Note, not a refusal: one part supplied more than once with identical bytes. */
|
|
237
95
|
export const PART_SUPPLIED_TWICE = "part_supplied_twice";
|
|
238
96
|
|
|
239
|
-
/**
|
|
240
|
-
* The order refusals are reported in, fixed so two kernels report one list
|
|
241
|
-
* rather than two orderings of it.
|
|
242
|
-
*/
|
|
97
|
+
/** The fixed order refusals are reported in. */
|
|
243
98
|
export const REFUSAL_ORDER = [
|
|
244
99
|
BYTES_FOR_UNKNOWN_PART,
|
|
245
100
|
CONFLICTING_PART_BYTES,
|
|
246
101
|
PART_ENCODING_MISMATCH,
|
|
247
102
|
] as const;
|
|
248
103
|
|
|
249
|
-
/**
|
|
250
|
-
* The order message-level results are reported in, for the same reason as
|
|
251
|
-
* `REFUSAL_ORDER`.
|
|
252
|
-
*/
|
|
104
|
+
/** The fixed order message-level results are reported in. */
|
|
253
105
|
export const MESSAGE_RESULT_ORDER = [PARTS_NOT_AN_ARRAY] as const;
|
|
254
106
|
|
|
255
107
|
/**
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
* not for these two: `PART_UNCHECKED` is a supply the rule refused to grade,
|
|
262
|
-
* and `PART_MALFORMED_DESCRIPTOR` is a part whose content lives BESIDE the
|
|
263
|
-
* message under a claim no receiver can use, so in both the receiver holds
|
|
264
|
-
* bytes that nothing in the committed record covers.
|
|
265
|
-
*
|
|
266
|
-
* The other three results a supply can be refused against are excluded, and
|
|
267
|
-
* excluded for a reason rather than by omission. `PART_INLINE_BYTES`,
|
|
268
|
-
* `PART_INLINE_MISMATCHED` and `PART_NOT_A_FILE` all carry their content
|
|
269
|
-
* INSIDE the message, which `message_hash` already commits on both sides:
|
|
270
|
-
* bytes supplied beside one of those grade nothing and hide nothing, because
|
|
271
|
-
* a reader of either pack reads the content itself. Counting them would make
|
|
272
|
-
* the caller's obligation fire on parts no attack can reach.
|
|
108
|
+
* Results that put a held part into `uncheckedHeldParts`: those whose content
|
|
109
|
+
* the message itself does not commit. Inline parts and non-file parts with no
|
|
110
|
+
* URL are excluded because `message_hash` already commits their content; a URL
|
|
111
|
+
* part (`isUrlPart`) counts, because the message commits the URL and not the
|
|
112
|
+
* file behind it.
|
|
273
113
|
*/
|
|
274
114
|
const HELD_UNCHECKED_RESULTS: readonly string[] = [
|
|
275
115
|
PART_UNCHECKED,
|
|
@@ -277,33 +117,10 @@ const HELD_UNCHECKED_RESULTS: readonly string[] = [
|
|
|
277
117
|
];
|
|
278
118
|
|
|
279
119
|
/**
|
|
280
|
-
* The
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* inline spelling either reference kernel has read from a real producer.
|
|
284
|
-
* `content` and `data` were added on inference rather than on an observed
|
|
285
|
-
* wire shape, and sanning-io/anchor's `filePart()` and `file_part()` SDK
|
|
286
|
-
* helpers, the only producer this plane ships, confirm the inference was
|
|
287
|
-
* wrong: both emit a descriptor part, `kind: "file"` plus `content_hash` and
|
|
288
|
-
* `content_length`, and neither ever writes a `content` or `data` wire key
|
|
289
|
-
* for inline bytes. None is A2A 1.0.0's either: its `Part` has no `kind` and
|
|
290
|
-
* carries a file as `raw` or `url`, so a literal A2A part reads
|
|
291
|
-
* `part_not_a_file` (envelope-spec §4.1, A19). The one key present with a
|
|
292
|
-
* value that is not `null` is the inline content.
|
|
293
|
-
*
|
|
294
|
-
* A key present with `null` is an ABSENT inline value, on A14's ruling that an
|
|
295
|
-
* absent value and one written as `null` name one thing. That matters: a
|
|
296
|
-
* proto3 JSON encoder can write an unset field as `"bytes": null`, and reading
|
|
297
|
-
* that as inline made a conformant DESCRIPTOR part uncheckable
|
|
298
|
-
* (proof#156 gate). Such a part falls through to the descriptor rule, which is
|
|
299
|
-
* what it is.
|
|
120
|
+
* The key that makes a part carry its content inline (envelope-spec §4.1).
|
|
121
|
+
* `bytes` is A2A's spelling. A key present with `null` is absent, so a proto3
|
|
122
|
+
* `"bytes": null` on a descriptor part still reads as a descriptor.
|
|
300
123
|
*/
|
|
301
|
-
// Before A31 this list also carried `content` and `data`, and a conformant
|
|
302
|
-
// DESCRIPTOR part that happened to carry either key meaning something else (a
|
|
303
|
-
// filename, a caption) was silently reclassified as inline and never checked
|
|
304
|
-
// against supplied bytes: the swapped-bytes check A18 exists to run
|
|
305
|
-
// (proof#145) never ran on it. proof#163 is resolved by narrowing to `bytes`
|
|
306
|
-
// alone, closing that gap.
|
|
307
124
|
export const INLINE_BYTES_KEYS = ["bytes"] as const;
|
|
308
125
|
|
|
309
126
|
const HEX64 = /^[0-9a-fA-F]{64}$/;
|
|
@@ -312,9 +129,8 @@ const LOWER_HEX = "0123456789abcdef";
|
|
|
312
129
|
const UPPER_HEX = "0123456789ABCDEF";
|
|
313
130
|
|
|
314
131
|
/**
|
|
315
|
-
* The largest
|
|
316
|
-
*
|
|
317
|
-
* would read as two numbers.
|
|
132
|
+
* The largest exactly representable integer, the bound §4.1 puts on
|
|
133
|
+
* `event.sequence`. A larger `content_length` is malformed.
|
|
318
134
|
*/
|
|
319
135
|
export const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
|
|
320
136
|
|
|
@@ -322,12 +138,8 @@ const BASE64_STANDARD = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz012
|
|
|
322
138
|
const BASE64_URLSAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
323
139
|
|
|
324
140
|
/**
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
* `part` is an INDEX into the message's `parts` array. It is an index rather
|
|
328
|
-
* than a content hash on purpose: keying the supply by the value the check is
|
|
329
|
-
* about to grade would mean a receiver could only supply bytes it had already
|
|
330
|
-
* agreed with, which is the whole defect stated backwards.
|
|
141
|
+
* A byte string the receiver holds, keyed by the index of its part. Keyed by
|
|
142
|
+
* index, not by hash, so a receiver can supply bytes that disagree.
|
|
331
143
|
*/
|
|
332
144
|
export interface SuppliedPart {
|
|
333
145
|
part: unknown;
|
|
@@ -341,13 +153,9 @@ export interface PartReading {
|
|
|
341
153
|
}
|
|
342
154
|
|
|
343
155
|
/**
|
|
344
|
-
* A caller error.
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
* and `entry` is the POSITION of the offending supplied entry in the sequence
|
|
348
|
-
* the caller passed. The position is recorded rather than the value the caller
|
|
349
|
-
* named, so a caller that names a part with a float or a string cannot make two
|
|
350
|
-
* kernels serialize one reading two ways.
|
|
156
|
+
* A caller error. `part` is the part index where there is one; `entry` is the
|
|
157
|
+
* position of the supplied entry, recorded rather than the caller's value so
|
|
158
|
+
* every implementation serializes it the same way.
|
|
351
159
|
*/
|
|
352
160
|
export interface Refusal {
|
|
353
161
|
name: string;
|
|
@@ -356,29 +164,18 @@ export interface Refusal {
|
|
|
356
164
|
}
|
|
357
165
|
|
|
358
166
|
/**
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
* note as a refusal by looking only at a name.
|
|
167
|
+
* Reported, but refuses nothing. Kept in its own list so a note is never read
|
|
168
|
+
* as a refusal by name alone.
|
|
362
169
|
*/
|
|
363
170
|
export type Note = Refusal;
|
|
364
171
|
|
|
365
172
|
/**
|
|
366
|
-
*
|
|
173
|
+
* What a receiver commits to, and what it found.
|
|
367
174
|
*
|
|
368
|
-
* `heldMessage` is
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
* 4.1 already states and the reason this module does not hash anything itself.
|
|
373
|
-
*
|
|
374
|
-
* `messageResults` carries the message-level named results, in
|
|
375
|
-
* `MESSAGE_RESULT_ORDER`. It is empty for every message whose shape the rule
|
|
376
|
-
* reads.
|
|
377
|
-
*
|
|
378
|
-
* `substituted` carries the indexes of the parts whose descriptor the receiver
|
|
379
|
-
* replaced with the held bytes' own hash and length. It is empty for an honest
|
|
380
|
-
* hand-off, and where it is not empty the receiving side writes
|
|
381
|
-
* `outcome: "rejected"`.
|
|
175
|
+
* `heldMessage` is never null; the caller commits `event.message_hash` as
|
|
176
|
+
* SHA-256 over its JCS bytes. `messageResults` is in `MESSAGE_RESULT_ORDER`.
|
|
177
|
+
* `substituted` lists the parts replaced by held bytes; non-empty means the
|
|
178
|
+
* receiver writes `outcome: "rejected"`.
|
|
382
179
|
*/
|
|
383
180
|
export interface HeldMessage {
|
|
384
181
|
parts: PartReading[];
|
|
@@ -398,20 +195,15 @@ function hasOwn(value: Record<string, unknown>, key: string): boolean {
|
|
|
398
195
|
}
|
|
399
196
|
|
|
400
197
|
/**
|
|
401
|
-
* 64 hex characters
|
|
402
|
-
*
|
|
403
|
-
* upper case here would contradict a ratified decision and punish a producer
|
|
404
|
-
* who did nothing wrong. Every comparison against a digest lowercases both
|
|
405
|
-
* sides, which is what the other kernels already do.
|
|
198
|
+
* 64 hex characters in either case: the hex rule pins the charset, not the
|
|
199
|
+
* case. Every digest comparison lowercases both sides.
|
|
406
200
|
*/
|
|
407
201
|
function isHex64(value: unknown): boolean {
|
|
408
202
|
return typeof value === "string" && HEX64.test(value);
|
|
409
203
|
}
|
|
410
204
|
|
|
411
205
|
/**
|
|
412
|
-
* A
|
|
413
|
-
* not a number. Zero is a legal length: the empty part is a real part, and a
|
|
414
|
-
* rule that read zero as "no length" would leave it unchecked in silence.
|
|
206
|
+
* A non-negative safe integer. Zero is legal: the empty part is a real part.
|
|
415
207
|
*/
|
|
416
208
|
function isSafeLength(value: unknown): boolean {
|
|
417
209
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
@@ -421,17 +213,10 @@ function isSafeIndex(value: unknown): boolean {
|
|
|
421
213
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
422
214
|
}
|
|
423
215
|
|
|
424
|
-
/**
|
|
425
|
-
* Distinguishes "no inline content" from an inline content of `null`, which
|
|
426
|
-
* A14's ruling says is the same thing but which a plain `null` could not be
|
|
427
|
-
* told apart from a legitimate value.
|
|
428
|
-
*/
|
|
429
216
|
const ABSENT = Symbol("absent");
|
|
430
217
|
|
|
431
218
|
/**
|
|
432
|
-
* The
|
|
433
|
-
* `INLINE_BYTES_KEYS` present with a value that is not `null`, so a part
|
|
434
|
-
* spelling it twice reads one way in both kernels.
|
|
219
|
+
* The first of `INLINE_BYTES_KEYS` present with a non-null value, or `ABSENT`.
|
|
435
220
|
*/
|
|
436
221
|
function inlineValue(part: Record<string, unknown>): unknown | typeof ABSENT {
|
|
437
222
|
for (const key of INLINE_BYTES_KEYS) {
|
|
@@ -443,13 +228,8 @@ function inlineValue(part: Record<string, unknown>): unknown | typeof ABSENT {
|
|
|
443
228
|
}
|
|
444
229
|
|
|
445
230
|
/**
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
* permissive: this exists only to decide whether a `content_hash` sitting
|
|
449
|
-
* BESIDE inline content agrees with it, and the message's own commitment
|
|
450
|
-
* governs either way, so the finding fires only where no reading agrees at all.
|
|
451
|
-
* A value that is not a string carries no readings, so a `content_hash` beside
|
|
452
|
-
* one has nothing to agree with.
|
|
231
|
+
* The raw UTF-8, base64 and hex readings of an inline string value. A
|
|
232
|
+
* non-string has no readings.
|
|
453
233
|
*/
|
|
454
234
|
function inlineReadings(value: unknown): Uint8Array[] {
|
|
455
235
|
if (typeof value !== "string") return [];
|
|
@@ -464,19 +244,13 @@ function inlineReadings(value: unknown): Uint8Array[] {
|
|
|
464
244
|
}
|
|
465
245
|
|
|
466
246
|
/**
|
|
467
|
-
* The
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
* Shape only, and synchronous on purpose: the inline hash agreement needs
|
|
471
|
-
* SHA-256, which is async here and not in Python, so refining
|
|
472
|
-
* `PART_INLINE_BYTES` to `PART_INLINE_MISMATCHED` happens in the reading
|
|
473
|
-
* loop instead. Keeping it out of here is what lets `isFilePart` mean the same
|
|
474
|
-
* thing, and have the same signature, in both kernels.
|
|
247
|
+
* The result a part reads before supplied bytes are considered, or `null` for
|
|
248
|
+
* a checkable descriptor part. Synchronous so `isFilePart` stays synchronous;
|
|
249
|
+
* the inline hash check, which needs async SHA-256, runs in the reading loop.
|
|
475
250
|
*/
|
|
476
251
|
function classify(part: unknown): string | null {
|
|
477
252
|
if (!isPlainObject(part)) return PART_NOT_A_FILE;
|
|
478
253
|
if (part.kind !== "file") return PART_NOT_A_FILE;
|
|
479
|
-
// ACCEPTED, and committed by the message itself (proof#157).
|
|
480
254
|
if (inlineValue(part) !== ABSENT) return PART_INLINE_BYTES;
|
|
481
255
|
if (!isHex64(part.content_hash)) return PART_MALFORMED_DESCRIPTOR;
|
|
482
256
|
if (!isSafeLength(part.content_length)) return PART_MALFORMED_DESCRIPTOR;
|
|
@@ -484,28 +258,10 @@ function classify(part: unknown): string | null {
|
|
|
484
258
|
}
|
|
485
259
|
|
|
486
260
|
/**
|
|
487
|
-
* Whether the
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
* was skipped for one round of this unmerged PR, on the argument that a reader
|
|
492
|
-
* holding the message can see a contradiction between the content and the
|
|
493
|
-
* claim from the message alone. That argument is true and it is also true of
|
|
494
|
-
* `content_hash`, which is compared: accepting it deletes the neighboring
|
|
495
|
-
* check. So the rule is the one section 4.1 already applies to a descriptor
|
|
496
|
-
* part, where a hash that misses and a count that misses reach one result: two
|
|
497
|
-
* claims about one blob, and reading one of them lets the other be false in
|
|
498
|
-
* silence.
|
|
499
|
-
*
|
|
500
|
-
* A claim that is ABSENT, or is not the shape the table names, is not a claim:
|
|
501
|
-
* it is not compared and it cannot disagree. A part writing neither agrees by
|
|
502
|
-
* default, because the message's own commitment governs and this only ever
|
|
503
|
-
* names a SECOND claim that contradicts it.
|
|
504
|
-
*
|
|
505
|
-
* ONE reading has to satisfy EVERY claim present, rather than each claim
|
|
506
|
-
* finding its own. Grading them separately would let a `content_hash` that
|
|
507
|
-
* agrees under the base64 decoding sit beside a `content_length` that agrees
|
|
508
|
-
* under the raw text, a pair no blob in the world satisfies, and call it clean.
|
|
261
|
+
* Whether the `content_hash` and `content_length` beside an inline part agree
|
|
262
|
+
* with its content. An absent or malformed claim is not compared. One reading
|
|
263
|
+
* must satisfy every claim present: a hash agreeing under base64 beside a
|
|
264
|
+
* length agreeing under raw text describes no real blob.
|
|
509
265
|
*/
|
|
510
266
|
async function inlineClaimsAgree(part: Record<string, unknown>): Promise<boolean> {
|
|
511
267
|
const wanted = isHex64(part.content_hash)
|
|
@@ -524,23 +280,16 @@ async function inlineClaimsAgree(part: Record<string, unknown>): Promise<boolean
|
|
|
524
280
|
}
|
|
525
281
|
|
|
526
282
|
/**
|
|
527
|
-
* Whether
|
|
528
|
-
*
|
|
529
|
-
* 4.1 states, in the order that keeps inline content out. An INLINE part is
|
|
530
|
-
* accepted and committed by the message itself (proof#157), so there is nothing
|
|
531
|
-
* here for supplied bytes to be checked against and this answers `false`.
|
|
283
|
+
* Whether bytes beside the message are checked against this part: true only
|
|
284
|
+
* for a conformant descriptor part, never for an inline one.
|
|
532
285
|
*/
|
|
533
286
|
export function isFilePart(part: unknown): boolean {
|
|
534
287
|
return classify(part) === null;
|
|
535
288
|
}
|
|
536
289
|
|
|
537
290
|
/**
|
|
538
|
-
* The parts
|
|
539
|
-
*
|
|
540
|
-
* A message that carries no `parts` array carries no file parts, and that is
|
|
541
|
-
* not a deviation: it is the common case, a JSON-only message. Every supplied
|
|
542
|
-
* byte string then has no part to belong to and is refused, which is the
|
|
543
|
-
* reading section 4.1 asks for and not an exception.
|
|
291
|
+
* The message's `parts`, or none. A message without parts is the common
|
|
292
|
+
* JSON-only case; every supplied entry is then refused.
|
|
544
293
|
*/
|
|
545
294
|
function messageParts(message: unknown): unknown[] {
|
|
546
295
|
if (!isPlainObject(message)) return [];
|
|
@@ -548,14 +297,7 @@ function messageParts(message: unknown): unknown[] {
|
|
|
548
297
|
return Array.isArray(parts) ? [...parts] : [];
|
|
549
298
|
}
|
|
550
299
|
|
|
551
|
-
/**
|
|
552
|
-
* The message-level named results, before any part is read.
|
|
553
|
-
*
|
|
554
|
-
* One condition reaches this today: a `parts` that is present, is not null and
|
|
555
|
-
* is not an array. It gets a name rather than the silence it had (proof#156
|
|
556
|
-
* gate), because a message with part-shaped content and no parts is exactly the
|
|
557
|
-
* shape section 4.1's "one result per part" leaves nothing to say about.
|
|
558
|
-
*/
|
|
300
|
+
/** Message-level results, before any part is read. */
|
|
559
301
|
function messageResults(message: unknown): string[] {
|
|
560
302
|
if (!isPlainObject(message)) return [];
|
|
561
303
|
if (!hasOwn(message, "parts")) return [];
|
|
@@ -579,28 +321,14 @@ function asAscii(data: Uint8Array): string {
|
|
|
579
321
|
}
|
|
580
322
|
|
|
581
323
|
/**
|
|
582
|
-
* Strict ASCII hex
|
|
583
|
-
* `bytes.fromhex` skips
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
* other.
|
|
587
|
-
*
|
|
588
|
-
* EVERY GUARD HERE AND IN `base64Decode` IS PINNED BY A CORPUS VECTOR CARRYING
|
|
589
|
-
* A HAND-WRITTEN EXPECTATION (proof#156 second gate, F3), named in the comment
|
|
590
|
-
* beside it. Until that round, six of the seven produced no signal when
|
|
591
|
-
* deleted: the whole ladder and the whole leg stayed green, so a third kernel
|
|
592
|
-
* that delegated to `atob` or `base64.b64decode` reproduced the entire corpus
|
|
593
|
-
* byte for byte, was conformant under `architecture.md` K3, and still put a
|
|
594
|
-
* record on the other side of `part_encoding_mismatch`. An agreement case
|
|
595
|
-
* cannot close that: the leg grades two kernels against each other, so a guard
|
|
596
|
-
* deleted from both is invisible to it. Only a hand-written `expected` block
|
|
597
|
-
* states which side of the boundary an input falls on.
|
|
324
|
+
* Strict ASCII hex, written out because library decoders differ (Python's
|
|
325
|
+
* `bytes.fromhex` skips whitespace), and a difference would move a record
|
|
326
|
+
* across `part_encoding_mismatch`. Each guard here and in `base64Decode` is
|
|
327
|
+
* pinned by a hand-written vector, named beside it, so deleting it goes red.
|
|
598
328
|
*/
|
|
599
329
|
function hexDecode(data: Uint8Array): Uint8Array | null {
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
// `part_encoding_mismatch` where it owes `part_mismatched` and a
|
|
603
|
-
// substitution: `parts-decoder-hex-odd-length-01`.
|
|
330
|
+
// Odd length is not hex; otherwise "ab0" reads as 0xab 0x00
|
|
331
|
+
// (`parts-decoder-hex-odd-length-01`).
|
|
604
332
|
if (data.length === 0 || data.length % 2 !== 0) return null;
|
|
605
333
|
const text = asAscii(data);
|
|
606
334
|
if (!HEX_CHARSET.test(text)) return null;
|
|
@@ -612,33 +340,13 @@ function hexDecode(data: Uint8Array): Uint8Array | null {
|
|
|
612
340
|
}
|
|
613
341
|
|
|
614
342
|
/**
|
|
615
|
-
* Strict base64, standard
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
* and JavaScript's `atob` disagree about padding, about the URL-safe alphabet
|
|
619
|
-
* and about non-zero trailing bits, and each of those disagreements decides
|
|
620
|
-
* whether a receiver refuses its own decoding defect or records a tamper
|
|
621
|
-
* against an honest sender. The two alphabets are not mixed: a string carrying
|
|
622
|
-
* one character from each is neither spelling and decodes to nothing.
|
|
623
|
-
*
|
|
624
|
-
* TWO GUARDS THAT STOOD HERE WERE DELETED IN THE PROOF#156 SECOND GATE'S
|
|
625
|
-
* ROUND, because they could not change a decode for any input and so read as
|
|
626
|
-
* protection that was not there:
|
|
627
|
-
*
|
|
628
|
-
* * `pad > 0 && text.length % 4 !== 0` is the SAME PREDICATE as the
|
|
629
|
-
* `(body.length + pad) % 4` test below it. The strip loop above makes
|
|
630
|
-
* `text` exactly `body + "=".repeat(pad)`, so the two lengths are equal by
|
|
631
|
-
* construction and the two conditions are one condition written twice.
|
|
632
|
-
* Each masked the other, which is why deleting either alone changed
|
|
633
|
-
* nothing;
|
|
634
|
-
* * `body.includes("=")` is subsumed by the alphabet test three lines below
|
|
635
|
-
* it: `=` is in neither alphabet, so a body carrying one is refused there.
|
|
343
|
+
* Strict base64, standard or URL-safe (never mixed), padded or not, canonical
|
|
344
|
+
* only. Written out because `atob` and Python's `base64.b64decode` disagree on
|
|
345
|
+
* padding, the URL-safe alphabet and non-zero trailing bits.
|
|
636
346
|
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
* decodes move.
|
|
641
|
-
* The remaining guards each carry the vector that pins them.
|
|
347
|
+
* There is no separate `pad > 0 && text.length % 4 !== 0` or
|
|
348
|
+
* `body.includes("=")` guard: the first is the alignment test below, and `=`
|
|
349
|
+
* fails the alphabet test.
|
|
642
350
|
*/
|
|
643
351
|
function base64Decode(data: Uint8Array): Uint8Array | null {
|
|
644
352
|
if (data.length === 0) return null;
|
|
@@ -649,32 +357,19 @@ function base64Decode(data: Uint8Array): Uint8Array | null {
|
|
|
649
357
|
body = body.slice(0, -1);
|
|
650
358
|
pad += 1;
|
|
651
359
|
}
|
|
652
|
-
// At most two padding characters
|
|
653
|
-
// aligned body followed by four `=` decode as though the padding were not
|
|
654
|
-
// there: `parts-decoder-base64-four-pad-chars-01`.
|
|
360
|
+
// At most two padding characters (`parts-decoder-base64-four-pad-chars-01`).
|
|
655
361
|
if (pad > 2) return null;
|
|
656
362
|
|
|
657
363
|
const standard = [...body].every((c) => BASE64_STANDARD.includes(c));
|
|
658
364
|
const urlsafe = [...body].every((c) => BASE64_URLSAFE.includes(c));
|
|
659
|
-
// One alphabet or the other, never
|
|
660
|
-
//
|
|
661
|
-
// the URL-safe one and asks it for `/`, where `indexOf` answers -1 and
|
|
662
|
-
// garbles the arithmetic into `f7 f0 00`. TWO vectors pin that, one per
|
|
663
|
-
// shape of third kernel: `parts-decoder-base64-mixed-alphabet-01` commits
|
|
664
|
-
// the bytes a kernel that MERGES the alphabets reads, and
|
|
665
|
-
// `parts-decoder-base64-mixed-alphabet-02` commits the bytes this kernel's
|
|
666
|
-
// own -1 produces, which is the one that goes red HERE (proof#156 third
|
|
667
|
-
// gate, C2: deleting this line left all 2224 TypeScript tests green).
|
|
365
|
+
// One alphabet or the other, never both (`parts-decoder-base64-mixed-alphabet-01`
|
|
366
|
+
// and `-02`).
|
|
668
367
|
if (!standard && !urlsafe) return null;
|
|
669
368
|
const alphabet = standard ? BASE64_STANDARD : BASE64_URLSAFE;
|
|
670
|
-
// A lone trailing character carries six bits
|
|
671
|
-
//
|
|
672
|
-
// characters whose low bits are zero could claim to be it:
|
|
673
|
-
// `parts-decoder-base64-single-character-01`.
|
|
369
|
+
// A lone trailing character carries six bits; otherwise "A" reads as the
|
|
370
|
+
// empty blob (`parts-decoder-base64-single-character-01`).
|
|
674
371
|
if (body.length % 4 === 1) return null;
|
|
675
|
-
// Padding
|
|
676
|
-
// body followed by one `=` decode as though the `=` were not there:
|
|
677
|
-
// `parts-decoder-base64-padding-misaligned-01`.
|
|
372
|
+
// Padding must align the string (`parts-decoder-base64-padding-misaligned-01`).
|
|
678
373
|
if (pad > 0 && (body.length + pad) % 4 !== 0) return null;
|
|
679
374
|
|
|
680
375
|
let bits = 0;
|
|
@@ -689,23 +384,15 @@ function base64Decode(data: Uint8Array): Uint8Array | null {
|
|
|
689
384
|
bits = bits % 2 ** width;
|
|
690
385
|
}
|
|
691
386
|
}
|
|
692
|
-
// Canonical only:
|
|
693
|
-
//
|
|
694
|
-
// blob decode to one value. Both `atob` and `base64.b64decode` accept them,
|
|
695
|
-
// which is the sharpest reason this decoder is written out: deleting this
|
|
696
|
-
// line lets `"QR=="` read as `"A"`, so a receiver handed one of the sixteen
|
|
697
|
-
// non-canonical spellings of a one-byte part writes `part_encoding_mismatch`
|
|
698
|
-
// where it owes `part_mismatched` and a substitution:
|
|
699
|
-
// `parts-decoder-non-canonical-tail-01`.
|
|
387
|
+
// Canonical only: non-zero unused low bits would let two spellings decode to
|
|
388
|
+
// one blob, and `atob` accepts them (`parts-decoder-non-canonical-tail-01`).
|
|
700
389
|
if (width > 0 && bits !== 0) return null;
|
|
701
390
|
return new Uint8Array(out);
|
|
702
391
|
}
|
|
703
392
|
|
|
704
393
|
/**
|
|
705
|
-
* The inverse of `base64Decode`, written out
|
|
706
|
-
*
|
|
707
|
-
* this value is compared against a digest a sender committed, so the two
|
|
708
|
-
* kernels have to emit the same characters.
|
|
394
|
+
* The inverse of `base64Decode`, written out so every implementation emits the
|
|
395
|
+
* same characters.
|
|
709
396
|
*/
|
|
710
397
|
function base64Encode(data: Uint8Array, alphabet: string, pad: boolean): Uint8Array {
|
|
711
398
|
let out = "";
|
|
@@ -734,27 +421,10 @@ function asciiBytes(text: string): Uint8Array {
|
|
|
734
421
|
}
|
|
735
422
|
|
|
736
423
|
/**
|
|
737
|
-
* Every spelling of `data` an honest stack might have been handed
|
|
738
|
-
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
* encoded text, which is what a single-line DER attachment, a PEM body and a
|
|
742
|
-
* JWT each are: the part's bytes ARE the base64 text. Guarding only the
|
|
743
|
-
* decoding direction told that receiver it held a tamper and fired the
|
|
744
|
-
* substitution, which is the one outcome this module calls worse than recording
|
|
745
|
-
* nothing.
|
|
746
|
-
*
|
|
747
|
-
* The list is fixed and short, the same six spellings the decoder accepts, so
|
|
748
|
-
* the two kernels try the same candidates in the same order and the cost is
|
|
749
|
-
* linear in the length of the supply.
|
|
750
|
-
*
|
|
751
|
-
* A `data.length === 0` guard stood here and was deleted in the proof#156
|
|
752
|
-
* second gate's round, for the same reason as the two in `base64Decode`: it
|
|
753
|
-
* could not change an answer. This is reached only from `respellingExplains`,
|
|
754
|
-
* and only after `sha256(data)` has already been compared to `contentHash` and
|
|
755
|
-
* missed. Every spelling of the empty byte string is the empty byte string, so
|
|
756
|
-
* the one candidate it would have produced is the digest that just failed. An
|
|
757
|
-
* optimization written as a guard reads as protection that is not there.
|
|
424
|
+
* Every spelling of `data` an honest stack might have been handed: the
|
|
425
|
+
* encoding direction of the encoding guard. A receiver whose stack decoded a
|
|
426
|
+
* PEM, DER or JWT holds decoded bytes while the sender committed the text.
|
|
427
|
+
* No empty-input guard: an empty `data` already missed the digest.
|
|
758
428
|
*/
|
|
759
429
|
function respellings(data: Uint8Array): Uint8Array[] {
|
|
760
430
|
return [
|
|
@@ -768,10 +438,8 @@ function respellings(data: Uint8Array): Uint8Array[] {
|
|
|
768
438
|
}
|
|
769
439
|
|
|
770
440
|
/**
|
|
771
|
-
* Whether a base64 or hex respelling of `data`, in
|
|
772
|
-
* `contentHash
|
|
773
|
-
* the case is not (proof#135), so every comparison in this module is made on
|
|
774
|
-
* one spelling.
|
|
441
|
+
* Whether a base64 or hex respelling of `data`, in either direction, hashes to
|
|
442
|
+
* `contentHash` (lowercase).
|
|
775
443
|
*/
|
|
776
444
|
async function respellingExplains(data: Uint8Array, contentHash: string): Promise<boolean> {
|
|
777
445
|
for (const decoded of [base64Decode(data), hexDecode(data)]) {
|
|
@@ -786,22 +454,11 @@ async function respellingExplains(data: Uint8Array, contentHash: string): Promis
|
|
|
786
454
|
/**
|
|
787
455
|
* What a receiver commits to, given the message and the bytes it holds.
|
|
788
456
|
*
|
|
789
|
-
* `supplied`
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
* Where a part's bytes hash to something other than its `content_hash`, or
|
|
796
|
-
* where their count is not its `content_length`, the returned `heldMessage`
|
|
797
|
-
* carries that part's `content_hash` and `content_length` replaced by the held
|
|
798
|
-
* bytes' own. The receiver commits `event.message_hash` over THAT, so its
|
|
799
|
-
* record and the sender's disagree and the pair fails on `message_hash`.
|
|
800
|
-
*
|
|
801
|
-
* A refusal is scoped to the supplied entry it is about: the part it names is
|
|
802
|
-
* not checked and not substituted, every other part reads as it would have, and
|
|
803
|
-
* `heldMessage` is returned either way. Nothing a caller or a sender can do
|
|
804
|
-
* denies the receiver a record.
|
|
457
|
+
* `supplied` names each byte string by its part index. With none, every file
|
|
458
|
+
* part is `PART_UNCHECKED` and the held message is the message given. Where a
|
|
459
|
+
* part's bytes miss its hash or length, `heldMessage` carries the held bytes'
|
|
460
|
+
* own values for that part, so the pair fails on `message_hash`. A refusal is
|
|
461
|
+
* scoped to its entry; `heldMessage` is always returned.
|
|
805
462
|
*/
|
|
806
463
|
export async function commitHeldMessage(
|
|
807
464
|
message: unknown,
|
|
@@ -809,7 +466,6 @@ export async function commitHeldMessage(
|
|
|
809
466
|
): Promise<HeldMessage> {
|
|
810
467
|
const parts = messageParts(message);
|
|
811
468
|
const classifications = parts.map(classify);
|
|
812
|
-
// The inline hash agreement, refined out of the shape pass because it hashes.
|
|
813
469
|
for (let i = 0; i < parts.length; i++) {
|
|
814
470
|
if (classifications[i] !== PART_INLINE_BYTES) continue;
|
|
815
471
|
if (!(await inlineClaimsAgree(parts[i] as Record<string, unknown>))) {
|
|
@@ -824,15 +480,13 @@ export async function commitHeldMessage(
|
|
|
824
480
|
supplied.forEach((item, entry) => {
|
|
825
481
|
const index = item.part;
|
|
826
482
|
if (!isSafeIndex(index) || (index as number) >= parts.length) {
|
|
827
|
-
// No part of this message
|
|
828
|
-
// it still names itself rather than going quiet.
|
|
483
|
+
// No part of this message.
|
|
829
484
|
refusals.push({ name: BYTES_FOR_UNKNOWN_PART, part: null, entry });
|
|
830
485
|
return;
|
|
831
486
|
}
|
|
832
487
|
if (classifications[index as number] !== null) {
|
|
833
|
-
// A part that
|
|
834
|
-
//
|
|
835
|
-
// part it altered.
|
|
488
|
+
// A part that exists but cannot be checked. Scoped to that part, so one
|
|
489
|
+
// uncheckable part cannot hide an altered one.
|
|
836
490
|
refusals.push({ name: BYTES_FOR_UNKNOWN_PART, part: index as number, entry });
|
|
837
491
|
return;
|
|
838
492
|
}
|
|
@@ -874,10 +528,7 @@ export async function commitHeldMessage(
|
|
|
874
528
|
const contentLength = part.content_length as number;
|
|
875
529
|
const digest = await sha256Hex(first.data);
|
|
876
530
|
if (digest === contentHash) {
|
|
877
|
-
//
|
|
878
|
-
// hash disagreement does (proof#156 gate). Before that, a part whose
|
|
879
|
-
// stated length the receiver could see was false read `part_matched`, so
|
|
880
|
-
// the receiver certified the part and committed the false length with it.
|
|
531
|
+
// A matching hash with a false length still substitutes.
|
|
881
532
|
if (first.data.length === contentLength) {
|
|
882
533
|
readings.push({ index, result: PART_MATCHED });
|
|
883
534
|
continue;
|
|
@@ -915,9 +566,7 @@ export async function commitHeldMessage(
|
|
|
915
566
|
|
|
916
567
|
let held: unknown = message;
|
|
917
568
|
if (replacements.size > 0) {
|
|
918
|
-
// Rebuilt
|
|
919
|
-
// the caller's, and a reading that edits its input is a reading that
|
|
920
|
-
// changes what the caller commits to on the honest path too.
|
|
569
|
+
// Rebuilt, not mutated: the caller's message is the caller's.
|
|
921
570
|
const heldParts = parts.map((part, index) => {
|
|
922
571
|
const replacement = replacements.get(index);
|
|
923
572
|
if (replacement === undefined) return part;
|
|
@@ -944,55 +593,14 @@ export async function commitHeldMessage(
|
|
|
944
593
|
}
|
|
945
594
|
|
|
946
595
|
/**
|
|
947
|
-
* The parts the receiver
|
|
948
|
-
*
|
|
949
|
-
*
|
|
950
|
-
*
|
|
951
|
-
*
|
|
952
|
-
* is one of `HELD_UNCHECKED_RESULTS`: the receiver had bytes for that part,
|
|
953
|
-
* the rule declined to grade them, and NOTHING about that reaches the
|
|
954
|
-
* committed record.
|
|
955
|
-
*
|
|
956
|
-
* THE RULE, in one sentence, because a filter is not a rule. A part is in here
|
|
957
|
-
* where the receiver held bytes for it AND the message itself does not commit
|
|
958
|
-
* its content. `PART_UNCHECKED` is a refused supply;
|
|
959
|
-
* `PART_MALFORMED_DESCRIPTOR` is a part whose content travels BESIDE the
|
|
960
|
-
* message under a claim no receiver can use, so the receiver's bytes are
|
|
961
|
-
* exactly as ungraded and the record says as little. The first cut of this
|
|
962
|
-
* function omitted the second, which the third gate measured: a `kind: "file"`
|
|
963
|
-
* part with a non-conformant `content_hash` or `content_length`, bytes held,
|
|
964
|
-
* read `[]` here, so a caller following the rule this module prescribes wrote
|
|
965
|
-
* `accepted` over a part it held bytes for and never checked. That is the
|
|
966
|
-
* class the mechanism exists to close, and Phil named it in proof#162's
|
|
967
|
-
* ratification as the gap that decision inherits unless it is closed here.
|
|
968
|
-
*
|
|
969
|
-
* Three results are excluded ON PURPOSE. An inline part (`PART_INLINE_BYTES`,
|
|
970
|
-
* `PART_INLINE_MISMATCHED`) and a non-file part (`PART_NOT_A_FILE`) carry
|
|
971
|
-
* their content INSIDE the message, and `message_hash` commits it on both
|
|
972
|
-
* sides: bytes supplied beside one of those grade nothing and hide nothing,
|
|
973
|
-
* because a reader of either pack reads the content itself. Naming them would
|
|
974
|
-
* fire the caller's obligation on parts the swapped-bytes attack cannot reach
|
|
975
|
-
* at all.
|
|
596
|
+
* The parts the receiver held bytes for and did not check: a refusal names the
|
|
597
|
+
* part and its result is in `HELD_UNCHECKED_RESULTS`, or the part is a URL part
|
|
598
|
+
* that reads `PART_NOT_A_FILE` and the refusal is `BYTES_FOR_UNKNOWN_PART`. A
|
|
599
|
+
* caller writes `accepted` only when this and `substituted` are both empty, and
|
|
600
|
+
* records the positions and their count when it is not zero.
|
|
976
601
|
*
|
|
977
|
-
*
|
|
978
|
-
*
|
|
979
|
-
* `message_hash`. Delivering the tampered copy AND the true one is
|
|
980
|
-
* `conflicting_part_bytes`, is never substituted, and the pair VERIFIES, so
|
|
981
|
-
* adding data suppresses detection and the only trace is a refusal the
|
|
982
|
-
* receiving side publishes at its own discretion.
|
|
983
|
-
*
|
|
984
|
-
* NOT the same as "this part reads `PART_UNCHECKED`". A receiver that holds no
|
|
985
|
-
* bytes at all reads every file part that way, and that is the COMMON CASE,
|
|
986
|
-
* unchanged by A18 and not a reason to withhold `accepted`. Only a part whose
|
|
987
|
-
* bytes the receiver actually held and could not grade is in here.
|
|
988
|
-
*
|
|
989
|
-
* What this does NOT do: it cannot stop a caller writing `accepted` anyway.
|
|
990
|
-
* This module reads and does not write the record. proof#162 ratified what the
|
|
991
|
-
* record layer does instead (Phil, 2026-09-22): `accepted` keeps its meaning
|
|
992
|
-
* and the COUNT of these parts rides in an additive `context` key, REQUIRED
|
|
993
|
-
* when it is not zero, enforced against SDKs through conformance vectors. So a
|
|
994
|
-
* part missing from this list is a part no record built on that rule will ever
|
|
995
|
-
* mention.
|
|
602
|
+
* Not the same as "reads `PART_UNCHECKED`": a receiver holding no bytes reads
|
|
603
|
+
* every file part that way, which is the common case.
|
|
996
604
|
*/
|
|
997
605
|
export function uncheckedHeldParts(reading: HeldMessage): number[] {
|
|
998
606
|
const unchecked = new Set(
|
|
@@ -1000,9 +608,23 @@ export function uncheckedHeldParts(reading: HeldMessage): number[] {
|
|
|
1000
608
|
.filter((p) => HELD_UNCHECKED_RESULTS.includes(p.result))
|
|
1001
609
|
.map((p) => p.index),
|
|
1002
610
|
);
|
|
611
|
+
const held = reading.heldMessage;
|
|
612
|
+
const parts = isPlainObject(held) && Array.isArray(held.parts) ? held.parts : [];
|
|
613
|
+
const urlParts = new Set(
|
|
614
|
+
reading.parts
|
|
615
|
+
.filter((p) => p.result === PART_NOT_A_FILE && p.index < parts.length && isUrlPart(parts[p.index]))
|
|
616
|
+
.map((p) => p.index),
|
|
617
|
+
);
|
|
1003
618
|
const named = new Set<number>();
|
|
1004
619
|
for (const refusal of reading.refusals) {
|
|
1005
|
-
if (refusal.part
|
|
620
|
+
if (refusal.part === null) continue;
|
|
621
|
+
if (unchecked.has(refusal.part)) named.add(refusal.part);
|
|
622
|
+
if (urlParts.has(refusal.part) && refusal.name === BYTES_FOR_UNKNOWN_PART) named.add(refusal.part);
|
|
1006
623
|
}
|
|
1007
624
|
return [...named].sort((a, b) => a - b);
|
|
1008
625
|
}
|
|
626
|
+
|
|
627
|
+
/** A part with no `kind: "file"` whose `url` member is present and not `null`. */
|
|
628
|
+
function isUrlPart(part: unknown): boolean {
|
|
629
|
+
return isPlainObject(part) && part.kind !== "file" && hasOwn(part, "url") && part.url !== null;
|
|
630
|
+
}
|