@sanning/proof 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/agent-proof.js +8 -2
- package/dist/case.d.ts +745 -0
- package/dist/case.js +1318 -0
- package/dist/chain.js +20 -1
- package/dist/cli.js +181 -27
- package/dist/crypto.d.ts +1 -0
- package/dist/crypto.js +29 -0
- package/dist/declaration.d.ts +284 -0
- package/dist/declaration.js +674 -0
- package/dist/evidence.js +296 -73
- package/dist/grading.d.ts +65 -4
- package/dist/grading.js +112 -6
- package/dist/handoff.d.ts +209 -0
- package/dist/handoff.js +617 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +50 -0
- package/dist/message-parts.d.ts +283 -0
- package/dist/message-parts.js +903 -0
- package/dist/verifier.d.ts +36 -0
- package/dist/verifier.js +150 -3
- package/package.json +1 -1
- package/src/agent-proof.ts +8 -2
- package/src/case.ts +1573 -0
- package/src/chain.ts +20 -1
- package/src/cli.ts +213 -29
- package/src/crypto.ts +28 -0
- package/src/declaration.ts +1005 -0
- package/src/evidence.ts +306 -71
- package/src/grading.ts +140 -6
- package/src/handoff.ts +694 -0
- package/src/index.ts +127 -0
- package/src/message-parts.ts +998 -0
- package/src/verifier.ts +150 -3
- package/vectors/README.md +36 -1
- package/vectors/jcs-reader.json +166 -0
- package/vectors/pack-grading.json +131 -3
package/src/handoff.ts
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
// The hand-off pairing rule of specs/envelope-spec.md §4.1 (v1.9).
|
|
2
|
+
//
|
|
3
|
+
// Two agents exchange a message and each side records what it held. This module
|
|
4
|
+
// is the TypeScript half of the reference reading of those two records: which
|
|
5
|
+
// records are candidates to be the counterpart of a given one, how a candidate
|
|
6
|
+
// set of more than one is ordered, and what a formed pair grades to. The
|
|
7
|
+
// Python half is src/sanning_proof/handoff.py, and the two have to answer
|
|
8
|
+
// identically on identical bytes.
|
|
9
|
+
//
|
|
10
|
+
// WHY IT IS WRITTEN THIS WAY (proof#132, ratified by Phil 2026-09-21)
|
|
11
|
+
//
|
|
12
|
+
// §4.1 as ratified at v1.7 formed a pair from three conjuncts, all three chosen
|
|
13
|
+
// by the party being audited and none of them graded: `context.case_id`, the
|
|
14
|
+
// mutual naming of each other's key in `event.counterparty_public_key`, and the
|
|
15
|
+
// two record types. A receiving side that had tampered with a message could
|
|
16
|
+
// change ONE of them, form no pair at all, and have its deviation surface as
|
|
17
|
+
// two absences, which §4.1 obliges a reader to treat as innocent.
|
|
18
|
+
//
|
|
19
|
+
// The amended rule turns on the one thing no party can choose for another:
|
|
20
|
+
//
|
|
21
|
+
// - Candidates come from who SIGNED. The candidates for a `handoff.sent` are
|
|
22
|
+
// the `handoff.received` records signed by the key it named, read from each
|
|
23
|
+
// envelope's own `public_key`, which is the binding of §2 and cannot be
|
|
24
|
+
// forged. Apart from which of the two types it is, nothing a candidate
|
|
25
|
+
// asserts about itself takes it out of that set.
|
|
26
|
+
// - Narrowing orders and annotates, and never reduces what is graded.
|
|
27
|
+
// `context.case_id` and `event.sequence` put the candidate a reader is most
|
|
28
|
+
// likely to want first in the report and remove none of them from grading.
|
|
29
|
+
// A narrowing that could drop a candidate was itself a laundering path:
|
|
30
|
+
// publish the tampered receipt with one of those two fields changed beside
|
|
31
|
+
// a clean decoy that carries them, and the step that "leaves at least one
|
|
32
|
+
// candidate" leaves the decoy and drops the tamper.
|
|
33
|
+
// - More than one candidate is a named finding, never a free choice: a reader
|
|
34
|
+
// permitted to take one match can be handed an honest decoy beside a
|
|
35
|
+
// tampered record. The per-candidate readings travel with the report, so
|
|
36
|
+
// naming the record and the check that failed survives a junk record that
|
|
37
|
+
// outranks it.
|
|
38
|
+
// - A record missing a REQUIRED field of its own type is `malformed`, so a
|
|
39
|
+
// pair that commits to nothing never reaches the check that would have
|
|
40
|
+
// compared absent to absent and found them equal.
|
|
41
|
+
// - A pair whose two envelope signing keys are equal does not verify.
|
|
42
|
+
// - Every hand-off record a reader holds is read, not only the ones that pair.
|
|
43
|
+
// - A formed pair verifies only when each record names the other's signer
|
|
44
|
+
// (v1.16, A21), or it fails `counterparty_named`. Up to 0.11.0 grading
|
|
45
|
+
// never read whom either record names.
|
|
46
|
+
//
|
|
47
|
+
// THE THREE ATTACKS THE ADVERSARIAL GATE LANDED (proof#133, rulings 1 to 3),
|
|
48
|
+
// and they are one root cause: candidacy and the self-hand-off check compared
|
|
49
|
+
// the envelope `public_key` as a STRING, and nothing constrains its spelling,
|
|
50
|
+
// so the party being audited chose it.
|
|
51
|
+
//
|
|
52
|
+
// - A key is compared as a KEY (`normalizedSigningKey`). The record side of a
|
|
53
|
+
// key is pinned to 64-char lowercase hex and the envelope side is pinned
|
|
54
|
+
// nowhere, so a receiving side that spelled its own envelope key in upper
|
|
55
|
+
// case decided whether its own record was a candidate at all: one character
|
|
56
|
+
// class on one field put a tampered receipt beside a clean decoy back at
|
|
57
|
+
// `verified` with no ambiguity named, and read a self-hand-off signed by
|
|
58
|
+
// one key as a transfer between two parties. The normalization sits on the
|
|
59
|
+
// `HeldRecord` boundary so a reader cannot skip it. Whether the ENVELOPE
|
|
60
|
+
// layer starts rejecting a non-lowercase `public_key` is a
|
|
61
|
+
// wire-compatibility question and a founder call (proof#135); nothing here
|
|
62
|
+
// changes what an envelope accepts.
|
|
63
|
+
// - The reserved namespace bites on any spelling a reader would take for a
|
|
64
|
+
// hand-off record (`isHandoffNamespace`). An exact `handoff.` prefix let
|
|
65
|
+
// `HANDOFF.RECEIVED`, `sanning.handoff.received`, `handoff-received` and a
|
|
66
|
+
// non-string `event_type` disappear from the reading entirely, not even as
|
|
67
|
+
// malformed, which composed with a decoy reads `verified` over proven
|
|
68
|
+
// tampering from one edit.
|
|
69
|
+
//
|
|
70
|
+
// THE TWO THE FOURTH HUNT LANDED ON THAT REPAIR (proof#133, rulings 1 and 2),
|
|
71
|
+
// and they are one root cause too: each test above was an allow-list over a
|
|
72
|
+
// value the audited party writes. Candidacy turned on the spelling of a key and
|
|
73
|
+
// scope turned on the spelling of a name, so narrowing the allow-list only
|
|
74
|
+
// moved the boundary and the next spelling walked around it.
|
|
75
|
+
//
|
|
76
|
+
// - Scope turns on the STRUCTURE (`isHandoffNamespace`). A record carrying
|
|
77
|
+
// `event.counterparty_public_key`, the field §4.1 defines and no other
|
|
78
|
+
// section does, is read whatever its `event_type` says, which closes the
|
|
79
|
+
// `ff` ligature, full-width letters, a Cyrillic `a`, `hand-off`,
|
|
80
|
+
// `handoffs`, a zero-width space, a BOM and a non-breaking space in one
|
|
81
|
+
// rule rather than by chasing Unicode. The folded name stays as an
|
|
82
|
+
// additional way in for a record that omits the field.
|
|
83
|
+
// - A key that does not parse makes the record `malformed`, never absent
|
|
84
|
+
// (`recordIsMalformed`). The rule it replaces let such a record drop out
|
|
85
|
+
// of candidacy in silence, and the other side then read
|
|
86
|
+
// `counterpart_absent`, which §4.1 obliges a reader to call innocent.
|
|
87
|
+
// Nine whitespace spellings of a real key produce a VERIFIED envelope in
|
|
88
|
+
// exactly that state in the Python kernel, so the first attack came back
|
|
89
|
+
// whole THROUGH the repair. Nothing here changes what an envelope accepts,
|
|
90
|
+
// and the rule holds either way proof#135 goes.
|
|
91
|
+
//
|
|
92
|
+
// WHAT THE FIFTH HUNT LANDED ON THAT REPAIR, AND WHY THIS ONE IS DIFFERENT
|
|
93
|
+
// (proof#136, ratified by Phil 2026-09-22). Scope was two clauses and both
|
|
94
|
+
// operands are written by the party being audited: an exact test for the key
|
|
95
|
+
// name `counterparty_public_key`, and an ASCII fold of `event_type`. One code
|
|
96
|
+
// point on each (`counterparty_public_k\u0435y` with a Cyrillic `\u0435`,
|
|
97
|
+
// `hando\ufb00.received` with a ligature) put the record in scope by NEITHER
|
|
98
|
+
// route, so it had no reading at all and the pile read `verified`.
|
|
99
|
+
//
|
|
100
|
+
// - Scope stops existing. `readHandoffs` returns a reading for EVERY record
|
|
101
|
+
// it is given, in the order given, and a record that is not a conformant
|
|
102
|
+
// hand-off reads with `NOT_A_HANDOFF_FINDING` rather than producing
|
|
103
|
+
// silence. Any rule with a silent branch is a rule the audited party
|
|
104
|
+
// steers into: five repairs narrowed which values reached the branch and
|
|
105
|
+
// five hunts found a value outside it, so the branch is removed rather
|
|
106
|
+
// than narrowed a sixth time.
|
|
107
|
+
// - The declaration names the hand-offs (`isConformantHandoffRecord`, read
|
|
108
|
+
// by `./declaration.ts`). The gain was that a record could be unreadable
|
|
109
|
+
// NOW and usable LATER; a claimed side that is not a conformant hand-off
|
|
110
|
+
// record now fails at the pack layer.
|
|
111
|
+
// - Missing and explicit null name one thing (`namedCase`). Python's missing
|
|
112
|
+
// value is `None` and this kernel's is `undefined`, so a candidate
|
|
113
|
+
// carrying `"case_id": null` was "same case" in one kernel and not in the
|
|
114
|
+
// other: same verdict, different candidate ORDER, on a value the audited
|
|
115
|
+
// party writes.
|
|
116
|
+
//
|
|
117
|
+
// No verdict word is coined: `verified`, `failed` and `malformed` are already in
|
|
118
|
+
// use, and `counterpart_absent`, `counterpart_ambiguous` and
|
|
119
|
+
// `not_a_handoff_record` are findings reported beside a verdict rather than
|
|
120
|
+
// verdicts.
|
|
121
|
+
//
|
|
122
|
+
// THE COST OF GRADING EVERY CANDIDATE, stated rather than left to be found. A
|
|
123
|
+
// reader whose pile holds two or more records of the complementary type signed
|
|
124
|
+
// by the counterparty key can no longer reach `verified`: every one of them is
|
|
125
|
+
// graded and the worst of them is the reading. Two agents that exchange more
|
|
126
|
+
// than one message land there. That is the price of never letting a value the
|
|
127
|
+
// audited party writes decide what reaches a check, and the belt is the pack
|
|
128
|
+
// layer: the declaration (evidence-bundle.md 5.4) says which records a pack
|
|
129
|
+
// claims are a pair.
|
|
130
|
+
|
|
131
|
+
export const CANDIDATE_NAMESPACE = "handoff.";
|
|
132
|
+
export const DEFINED_EVENT_TYPES = ["handoff.sent", "handoff.received"] as const;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The finding a record that is not a hand-off record at all reads with.
|
|
136
|
+
*
|
|
137
|
+
* A FINDING and not a verdict: the ratified verdict words are closed
|
|
138
|
+
* (anchor#188) and this reading names an outcome beside one, exactly as
|
|
139
|
+
* `counterpart_absent`, `counterpart_ambiguous` and `malformed_record` do. It
|
|
140
|
+
* exists because 4.1 had a SILENT branch, and a rule with a silent branch is a
|
|
141
|
+
* rule the party being audited steers into (proof#136). A record TRYING to be
|
|
142
|
+
* a hand-off and getting it wrong reads `malformed`; a record that is not a
|
|
143
|
+
* hand-off at all reads this. Both are readings. Neither is silence.
|
|
144
|
+
*/
|
|
145
|
+
export const NOT_A_HANDOFF_FINDING = "not_a_handoff_record";
|
|
146
|
+
const OUTCOMES = ["accepted", "rejected", "failed"];
|
|
147
|
+
const ID_PATTERN = /^[A-Za-z0-9_.:-]+$/;
|
|
148
|
+
const HEX64 = /^[0-9a-fA-F]{64}$/;
|
|
149
|
+
const LOWER_HEX64 = /^[0-9a-f]{64}$/;
|
|
150
|
+
|
|
151
|
+
// The order a reader names the checks a formed pair failed, fixed so two
|
|
152
|
+
// kernels report one list rather than two orderings of it.
|
|
153
|
+
const CHECK_ORDER = ["message_hash", "sequence", "self_handoff", "counterparty_named"] as const;
|
|
154
|
+
const VERDICT_RANK: Record<string, number> = { verified: 0, failed: 1, malformed: 2 };
|
|
155
|
+
|
|
156
|
+
// ASCII whitespace, listed rather than delegated: `String.trim()` and Python's
|
|
157
|
+
// `str.strip()` do not strip the same set (`U+FEFF` is whitespace to one and
|
|
158
|
+
// not to the other), and a fold the two kernels disagree about is a divergence
|
|
159
|
+
// on identical bytes (proof#133 review, ruling 3).
|
|
160
|
+
const ASCII_WHITESPACE = new Set([" ", "\t", "\n", "\r", "\v", "\f"]);
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* An envelope `public_key` as a KEY rather than as a string: the 32 key bytes
|
|
164
|
+
* written as lowercase hex, or `null` where the value is not 64 characters of
|
|
165
|
+
* hex and so names no key this reading can compare.
|
|
166
|
+
*
|
|
167
|
+
* Case is the whole point: §2 states lowercase as a PRODUCER convention, the
|
|
168
|
+
* envelope layer accepts either case in every kernel, and the party being
|
|
169
|
+
* audited is the producer (proof#133 adversarial gate, ruling 1). Nothing
|
|
170
|
+
* wider is folded: whitespace inside the value is not stripped, because
|
|
171
|
+
* Python's `bytes.fromhex` skips ASCII whitespace and this kernel's charset
|
|
172
|
+
* check does not, a live divergence at the ENVELOPE layer and a separate
|
|
173
|
+
* founder call (proof#135). A reading rule that silently repaired it would
|
|
174
|
+
* hide it.
|
|
175
|
+
*/
|
|
176
|
+
export function normalizedSigningKey(value: unknown): string | null {
|
|
177
|
+
if (typeof value !== "string" || !HEX64.test(value)) return null;
|
|
178
|
+
return [...value]
|
|
179
|
+
.map((c) => (c >= "A" && c <= "Z" ? String.fromCharCode(c.charCodeAt(0) + 32) : c))
|
|
180
|
+
.join("");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether two envelope keys are the SAME KEY. Two values that name a key are
|
|
185
|
+
* equal when their key bytes are equal.
|
|
186
|
+
*
|
|
187
|
+
* Where either side names no key, the question cannot be answered from key
|
|
188
|
+
* bytes and the answer is the conservative one: the two are reported as one
|
|
189
|
+
* key, so the pair reads `failed[self_handoff]` rather than clean. Exact
|
|
190
|
+
* string equality was the answer here and it was the wrong way round: one key
|
|
191
|
+
* that signed both ends, spelled `abcd...` on one envelope and `abcd ...` on
|
|
192
|
+
* the other, is one party, and string equality called it two and read the
|
|
193
|
+
* self-hand-off `verified` (proof#133 fourth hunt, finding 1). A record whose
|
|
194
|
+
* own envelope key this reading cannot read is `malformed` under
|
|
195
|
+
* `recordIsMalformed` and a formed pair reaches `malformed` before it reaches
|
|
196
|
+
* this line, so the two rules are belt and braces rather than one rule stated
|
|
197
|
+
* twice.
|
|
198
|
+
*/
|
|
199
|
+
export function sameSigningKey(left: unknown, right: unknown): boolean {
|
|
200
|
+
const leftKey = normalizedSigningKey(left);
|
|
201
|
+
const rightKey = normalizedSigningKey(right);
|
|
202
|
+
if (leftKey === null || rightKey === null) return true;
|
|
203
|
+
return leftKey === rightKey;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The spelling of an `event_type`, folded for the namespace test only: ASCII
|
|
208
|
+
* whitespace off each end, ASCII upper case down to lower case, and `-` and
|
|
209
|
+
* `_` read as the `.` separator. Every step is spelled out rather than
|
|
210
|
+
* delegated to `toLowerCase()` or `trim()`, which do not agree with Python on
|
|
211
|
+
* non-ASCII input.
|
|
212
|
+
*/
|
|
213
|
+
function foldEventType(value: string): string {
|
|
214
|
+
const characters = [...value];
|
|
215
|
+
let start = 0;
|
|
216
|
+
let end = characters.length;
|
|
217
|
+
while (start < end && ASCII_WHITESPACE.has(characters[start]!)) start += 1;
|
|
218
|
+
while (end > start && ASCII_WHITESPACE.has(characters[end - 1]!)) end -= 1;
|
|
219
|
+
return characters
|
|
220
|
+
.slice(start, end)
|
|
221
|
+
.map((c) => {
|
|
222
|
+
if (c === "-" || c === "_") return ".";
|
|
223
|
+
return c >= "A" && c <= "Z" ? String.fromCharCode(c.charCodeAt(0) + 32) : c;
|
|
224
|
+
})
|
|
225
|
+
.join("");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export type HandoffVerdict = "verified" | "failed" | "malformed";
|
|
229
|
+
|
|
230
|
+
export interface HandoffRecord {
|
|
231
|
+
event_type?: unknown;
|
|
232
|
+
event?: unknown;
|
|
233
|
+
context?: unknown;
|
|
234
|
+
metadata?: unknown;
|
|
235
|
+
[key: string]: unknown;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* One hand-off record as a reader holds it: the committed record, plus the
|
|
240
|
+
* `public_key` of the envelope that carries it. The key comes from the ENVELOPE
|
|
241
|
+
* and never from the record, which is the whole point of the amended rule.
|
|
242
|
+
*
|
|
243
|
+
* PRECONDITION: THE ENVELOPE MUST ALREADY HAVE VERIFIED. "Unforgeable" is a
|
|
244
|
+
* property of a VERIFIED envelope and of nothing else. The key on an envelope
|
|
245
|
+
* whose signature has not been checked is an assertion like any other, and
|
|
246
|
+
* every reading in this module is then worth exactly as much as that
|
|
247
|
+
* assertion. A reader builds a `HeldRecord` only from an envelope that
|
|
248
|
+
* `verifyEnvelope` returned `ok` for, bound to the canonical bytes of this
|
|
249
|
+
* record. Nothing in this module can check that for itself, which is why it is
|
|
250
|
+
* stated here (proof#133 adversarial gate, ruling 3), and why candidacy and
|
|
251
|
+
* the self-hand-off check read `normalizedSigningKey(publicKey)` rather than
|
|
252
|
+
* the spelling the audited party chose.
|
|
253
|
+
*/
|
|
254
|
+
export interface HeldRecord {
|
|
255
|
+
publicKey: string;
|
|
256
|
+
record: HandoffRecord;
|
|
257
|
+
ref: string;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Whether a reader reads this record as a hand-off record at all.
|
|
262
|
+
*
|
|
263
|
+
* §4.1 says a reader reads EVERY hand-off record it holds, and an exact
|
|
264
|
+
* `handoff.` prefix did not deliver that: `HANDOFF.RECEIVED`,
|
|
265
|
+
* `sanning.handoff.received`, `handoff-received` and a non-string `event_type`
|
|
266
|
+
* all vanished from the reading entirely, not even as malformed, so a receipt
|
|
267
|
+
* typed one character outside the prefix was never read and, beside a clean decoy,
|
|
268
|
+
* published `verified` over proven tampering (proof#133 adversarial gate,
|
|
269
|
+
* ruling 2).
|
|
270
|
+
*
|
|
271
|
+
* The rule is checkable rather than a judgement, and it is exactly two
|
|
272
|
+
* clauses. The FIRST is the one the adversary cannot evade: the record's
|
|
273
|
+
* `event` carries `counterparty_public_key`, the field §4.1 defines and no
|
|
274
|
+
* other section does, WHATEVER its `event_type` says. The second keeps a
|
|
275
|
+
* record that omits the field in scope: an `event_type` that is a string whose
|
|
276
|
+
* folded form has `handoff` as one of its `.`-separated segments, which leaves
|
|
277
|
+
* `asset_missing` and the other families alone.
|
|
278
|
+
*
|
|
279
|
+
* The order is the fix, not decoration. Every earlier version of this test was
|
|
280
|
+
* an ALLOW-LIST OVER A NAME THE AUDITED PARTY WRITES, so each repair narrowed
|
|
281
|
+
* the class and the next spelling walked around it: an exact `handoff.` prefix
|
|
282
|
+
* fell to `HANDOFF.RECEIVED`, and the ASCII fold that replaced it fell to the
|
|
283
|
+
* `ff` ligature, full-width letters, a Cyrillic `a`, `hand-off`, `handoffs`, a
|
|
284
|
+
* zero-width space, a BOM and a non-breaking space, each of which left a
|
|
285
|
+
* tampered receipt with no reading at all beside a clean decoy (proof#133
|
|
286
|
+
* fourth hunt, finding 2). The structural field closes all of them in one rule
|
|
287
|
+
* rather than by chasing Unicode, because the attack needs the field: a
|
|
288
|
+
* receipt that drops `counterparty_public_key` says nothing about who it came
|
|
289
|
+
* from.
|
|
290
|
+
*
|
|
291
|
+
* Anything in scope that is not exactly `handoff.sent` or `handoff.received`
|
|
292
|
+
* is `malformed`, so a spelling is never a way out.
|
|
293
|
+
*
|
|
294
|
+
* THIS IS NO LONGER A GATE ON BEING READ (proof#136). Both clauses are written
|
|
295
|
+
* by the party being audited, and one code point on each put a record in scope
|
|
296
|
+
* by neither, which produced silence. `readHandoffs` reads every record it
|
|
297
|
+
* holds, and this test now decides only WHICH reading a record gets: in scope
|
|
298
|
+
* and wrong reads `malformed`, out of scope reads `NOT_A_HANDOFF_FINDING`. A
|
|
299
|
+
* respelling buys a different line rather than no line.
|
|
300
|
+
*/
|
|
301
|
+
export function isHandoffNamespace(held: HeldRecord): boolean {
|
|
302
|
+
if ("counterparty_public_key" in section(held.record, "event")) return true;
|
|
303
|
+
const type = eventType(held);
|
|
304
|
+
return typeof type === "string" && foldEventType(type).split(".").includes("handoff");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* What one candidate graded to, kept beside the aggregate. 4.1 requires a
|
|
309
|
+
* reader to name the record and which check failed, and `malformed` outranks
|
|
310
|
+
* `failed`, so one junk record signed by the counterparty key would otherwise
|
|
311
|
+
* turn a named `message_hash` failure into a `malformed` with nothing named
|
|
312
|
+
* (proof#133 review, ruling 6).
|
|
313
|
+
*/
|
|
314
|
+
export interface CandidateReading {
|
|
315
|
+
ref: string;
|
|
316
|
+
verdict: HandoffVerdict;
|
|
317
|
+
failedChecks: string[];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export interface HandoffReading {
|
|
321
|
+
ref: string;
|
|
322
|
+
/** `null` where no pair formed: there is no verdict to give, and the finding says why. */
|
|
323
|
+
verdict: HandoffVerdict | null;
|
|
324
|
+
failedChecks: string[];
|
|
325
|
+
findings: string[];
|
|
326
|
+
/** Ordered, never filtered. */
|
|
327
|
+
candidates: string[];
|
|
328
|
+
/** One entry per candidate, in the same order, and nothing left out of it. */
|
|
329
|
+
candidateReadings: CandidateReading[];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function section(record: HandoffRecord, key: "event" | "context"): Record<string, unknown> {
|
|
333
|
+
const value = record[key];
|
|
334
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
335
|
+
? (value as Record<string, unknown>)
|
|
336
|
+
: {};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function eventType(held: HeldRecord): unknown {
|
|
340
|
+
return held.record.event_type;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function isSafeSequence(value: unknown): boolean {
|
|
344
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Code POINTS, not UTF-16 units. `"\u{1F600}".length` is 2 in JavaScript and
|
|
348
|
+
// `len()` of the same character is 1 in Python, so a length bound counted in
|
|
349
|
+
// units makes the two kernels disagree about identical bytes (proof#133
|
|
350
|
+
// review, ruling 3).
|
|
351
|
+
function codePoints(value: string): number {
|
|
352
|
+
return [...value].length;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// `===` rather than `==`, and the Python half now matches: `True == 1` holds in
|
|
356
|
+
// Python and `true === 1` does not in JavaScript, which let a record carrying
|
|
357
|
+
// `"sequence": true` stay in one kernel's reading and drop out of the other's.
|
|
358
|
+
function sameNumber(left: unknown, right: unknown): boolean {
|
|
359
|
+
return left === right;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* A record missing a REQUIRED field of its own type, or carrying one that is
|
|
364
|
+
* not the type §4.1 states, is malformed and fails. Stated as a rule because
|
|
365
|
+
* §4.1 had none: a pair where both sides omitted `message_hash` compared absent
|
|
366
|
+
* to absent, found them equal, and graded intact (proof#132).
|
|
367
|
+
*
|
|
368
|
+
* A record whose ENVELOPE key this reading cannot read is malformed too, and
|
|
369
|
+
* that one is a repair rather than an omission. The rule used to be "a value
|
|
370
|
+
* that is not 64 characters of hex names no key and matches nothing", so such a
|
|
371
|
+
* record dropped out of candidacy in silence and the subject read
|
|
372
|
+
* `counterpart_absent`, which §4.1 obliges a reader to call innocent. Nine
|
|
373
|
+
* whitespace spellings of a real key produce a VERIFIED envelope in exactly
|
|
374
|
+
* that state in the Python kernel, because `bytes.fromhex` skips ASCII
|
|
375
|
+
* whitespace, which reopened the whole of the first attack through the repair
|
|
376
|
+
* rather than around it (proof#133 fourth hunt, finding 1). A record a reader
|
|
377
|
+
* HOLDS, whose own key it cannot read, is a defect in the record and not an
|
|
378
|
+
* absence of one. This holds whatever the envelope layer decides about its hex
|
|
379
|
+
* charset (proof#135).
|
|
380
|
+
*/
|
|
381
|
+
export function recordIsMalformed(held: HeldRecord): boolean {
|
|
382
|
+
if (normalizedSigningKey(held.publicKey) === null) return true;
|
|
383
|
+
const type = eventType(held);
|
|
384
|
+
if (typeof type !== "string" || !(DEFINED_EVENT_TYPES as readonly string[]).includes(type)) {
|
|
385
|
+
return true; // including any other handoff.* name, which A11 forbids
|
|
386
|
+
}
|
|
387
|
+
const raw = held.record;
|
|
388
|
+
for (const key of ["event", "context"] as const) {
|
|
389
|
+
const value = raw[key];
|
|
390
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return true;
|
|
391
|
+
}
|
|
392
|
+
const event = section(raw, "event");
|
|
393
|
+
const context = section(raw, "context");
|
|
394
|
+
|
|
395
|
+
if (typeof event.message_hash !== "string" || !HEX64.test(event.message_hash)) return true;
|
|
396
|
+
const contentType = event.content_type;
|
|
397
|
+
if (
|
|
398
|
+
typeof contentType !== "string" ||
|
|
399
|
+
codePoints(contentType) === 0 ||
|
|
400
|
+
codePoints(contentType) > 128
|
|
401
|
+
) {
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
if (
|
|
405
|
+
typeof event.counterparty_public_key !== "string" ||
|
|
406
|
+
!LOWER_HEX64.test(event.counterparty_public_key)
|
|
407
|
+
) {
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
if (!isSafeSequence(event.sequence)) return true;
|
|
411
|
+
|
|
412
|
+
if (type === "handoff.received") {
|
|
413
|
+
if (typeof event.outcome !== "string" || !OUTCOMES.includes(event.outcome)) return true;
|
|
414
|
+
} else if ("outcome" in event) {
|
|
415
|
+
return true; // outcome is a handoff.received field only
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// The OPTIONAL fields are optional: absence is never malformed, and a present
|
|
419
|
+
// one still has to be what §4.1 says it is.
|
|
420
|
+
//
|
|
421
|
+
// `message_bytes_hash` is NOT checked here. Its own row in 4.1 says a reader
|
|
422
|
+
// MUST NOT match, pair or grade on it, and calling a record malformed for it
|
|
423
|
+
// is grading on it (proof#133 review, ruling 5).
|
|
424
|
+
if ("case_id" in context) {
|
|
425
|
+
const value = context.case_id;
|
|
426
|
+
if (typeof value !== "string" || codePoints(value) > 128 || !ID_PATTERN.test(value))
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
if ("protocol" in context) {
|
|
430
|
+
const value = context.protocol;
|
|
431
|
+
if (typeof value !== "string" || codePoints(value) === 0 || codePoints(value) > 64)
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
if ("in_reply_to" in context) {
|
|
435
|
+
const value = context.in_reply_to;
|
|
436
|
+
if (typeof value !== "string" || !HEX64.test(value)) return true;
|
|
437
|
+
}
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function complement(type: unknown): string {
|
|
442
|
+
return type === "handoff.sent" ? "handoff.received" : "handoff.sent";
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Who signed, and nothing a candidate says about itself.
|
|
447
|
+
*
|
|
448
|
+
* The complementary record type is the one conjunct that stays hard, and the
|
|
449
|
+
* reason is the honest two-way case rather than a preference: an honest reply is
|
|
450
|
+
* a `handoff.sent` signed by the other agent, naming this one, under the same
|
|
451
|
+
* case identifier and at the same sequence number, which is byte-for-byte the
|
|
452
|
+
* shape of a receiving side that mislabels its receipt. Admitting same-type
|
|
453
|
+
* candidates would grade an honest reply as a failed hand-off. §4.1 names that
|
|
454
|
+
* residual path, and the declaration (evidence-bundle.md §5.4) is its belt.
|
|
455
|
+
*/
|
|
456
|
+
function candidatesFor(subject: HeldRecord, held: readonly HeldRecord[]): HeldRecord[] {
|
|
457
|
+
// KEYS, never spellings of a key. Comparing the raw strings handed the
|
|
458
|
+
// audited party the set itself: an envelope key spelled in upper case names
|
|
459
|
+
// the same key, the record side is pinned to lowercase hex and the envelope
|
|
460
|
+
// side is pinned nowhere, so one character class took the tampered receipt
|
|
461
|
+
// out of the set and left the clean decoy in it (proof#133 adversarial
|
|
462
|
+
// gate, ruling 1).
|
|
463
|
+
const wanted = normalizedSigningKey(section(subject.record, "event").counterparty_public_key);
|
|
464
|
+
if (wanted === null) return []; // the subject names no key
|
|
465
|
+
const wantedType = complement(eventType(subject));
|
|
466
|
+
return held.filter(
|
|
467
|
+
(candidate) =>
|
|
468
|
+
candidate !== subject &&
|
|
469
|
+
normalizedSigningKey(candidate.publicKey) === wanted &&
|
|
470
|
+
eventType(candidate) === wantedType,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Order a candidate set. It REMOVES NOTHING.
|
|
476
|
+
*
|
|
477
|
+
* `context.case_id` and then `event.sequence` put the candidate a reader is most
|
|
478
|
+
* likely to want at the front of the report, and both are written by the party
|
|
479
|
+
* being audited, so neither decides what gets graded. A version of this function
|
|
480
|
+
* that filtered, "applying a step only where it leaves at least one candidate",
|
|
481
|
+
* was itself a laundering path: the receiving side publishes the tampered
|
|
482
|
+
* receipt with its `case_id` or its `sequence` changed and a clean decoy that
|
|
483
|
+
* carries the subject's values, the surviving decoy keeps the step alive, the
|
|
484
|
+
* tamper drops out of the set, and the reading is `verified` with no ambiguity
|
|
485
|
+
* named (proof#133 review, ruling 1).
|
|
486
|
+
*
|
|
487
|
+
* The sort is total and deterministic, so the two kernels report one order.
|
|
488
|
+
*/
|
|
489
|
+
function order(subject: HeldRecord, candidates: HeldRecord[]): HeldRecord[] {
|
|
490
|
+
const wantedCase = namedCase(section(subject.record, "context"));
|
|
491
|
+
const wantedSequence = section(subject.record, "event").sequence;
|
|
492
|
+
const rank = (c: HeldRecord): [number, number, string] => {
|
|
493
|
+
const context = section(c.record, "context");
|
|
494
|
+
const sameCase = isSameCase(namedCase(context), wantedCase);
|
|
495
|
+
const sameSequence = sameNumber(section(c.record, "event").sequence, wantedSequence);
|
|
496
|
+
return [sameCase ? 0 : 1, sameSequence ? 0 : 1, c.ref];
|
|
497
|
+
};
|
|
498
|
+
return [...candidates].sort((a, b) => {
|
|
499
|
+
const [ac, aq, ar] = rank(a);
|
|
500
|
+
const [bc, bq, br] = rank(b);
|
|
501
|
+
if (ac !== bc) return ac - bc;
|
|
502
|
+
if (aq !== bq) return aq - bq;
|
|
503
|
+
return ar < br ? -1 : ar > br ? 1 : 0;
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Which case a record names, for the ordering step only.
|
|
509
|
+
*
|
|
510
|
+
* An ABSENT `context.case_id` and one written as `null` name the same thing,
|
|
511
|
+
* which is no case. Python's missing value is `None` and this kernel's is
|
|
512
|
+
* `undefined`, so a candidate carrying `"case_id": null` was "same case" in
|
|
513
|
+
* one kernel and not in the other when the subject lawfully omits the OPTIONAL
|
|
514
|
+
* key: same verdict, DIFFERENT candidate order, and the cross-kernel gate's
|
|
515
|
+
* own contract is that the reading it compares carries the candidates in
|
|
516
|
+
* order. The party being audited writes that `null` (proof#133 fifth hunt,
|
|
517
|
+
* closed by proof#136).
|
|
518
|
+
*/
|
|
519
|
+
function namedCase(context: Record<string, unknown>): unknown {
|
|
520
|
+
const value = context.case_id;
|
|
521
|
+
return value === undefined ? null : value;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Whether two records name the same case, for the ordering step only. Two
|
|
526
|
+
* records that name NO case are in the same position, and a case identifier is
|
|
527
|
+
* a string (4.1), so anything else names no case a reader can compare. Stated
|
|
528
|
+
* in one rule in both kernels so `None` and `undefined` cannot mean two
|
|
529
|
+
* things.
|
|
530
|
+
*/
|
|
531
|
+
function isSameCase(left: unknown, right: unknown): boolean {
|
|
532
|
+
if (left === null && right === null) return true;
|
|
533
|
+
return typeof left === "string" && typeof right === "string" && left === right;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function gradePair(
|
|
537
|
+
subject: HeldRecord,
|
|
538
|
+
counterpart: HeldRecord,
|
|
539
|
+
): { verdict: HandoffVerdict; checks: string[] } {
|
|
540
|
+
if (recordIsMalformed(subject) || recordIsMalformed(counterpart)) {
|
|
541
|
+
return { verdict: "malformed", checks: [] };
|
|
542
|
+
}
|
|
543
|
+
const failed = new Set<string>();
|
|
544
|
+
// The same KEY, whatever the two envelopes spelled it as: one key signing
|
|
545
|
+
// both ends spelled upper case on one side and lower on the other read as a
|
|
546
|
+
// transfer between two parties (proof#133 adversarial gate, ruling 1).
|
|
547
|
+
if (sameSigningKey(subject.publicKey, counterpart.publicKey)) failed.add("self_handoff");
|
|
548
|
+
const subjectEvent = section(subject.record, "event");
|
|
549
|
+
const counterpartEvent = section(counterpart.record, "event");
|
|
550
|
+
const subjectHash = String(subjectEvent.message_hash ?? "").toLowerCase();
|
|
551
|
+
const counterpartHash = String(counterpartEvent.message_hash ?? "").toLowerCase();
|
|
552
|
+
if (subjectHash !== counterpartHash) failed.add("message_hash");
|
|
553
|
+
if (!sameNumber(subjectEvent.sequence, counterpartEvent.sequence)) failed.add("sequence");
|
|
554
|
+
// Each record names the OTHER's signer (v1.16, A21). Candidacy reads the key
|
|
555
|
+
// the subject names and nothing the candidate says, so up to and including
|
|
556
|
+
// 0.11.0 a sent record addressed to C paired `verified` with B's receipt of
|
|
557
|
+
// it, and a receipt naming C as its sender paired `verified` with A's send.
|
|
558
|
+
// Both halves of the naming are graded, each against the envelope key the
|
|
559
|
+
// other record was SIGNED with, compared as a key. The Python half is
|
|
560
|
+
// `_grade_pair`.
|
|
561
|
+
const [sent, received] =
|
|
562
|
+
eventType(subject) === "handoff.sent" ? [subject, counterpart] : [counterpart, subject];
|
|
563
|
+
const sentNames = normalizedSigningKey(section(sent.record, "event").counterparty_public_key);
|
|
564
|
+
const receivedNames = normalizedSigningKey(
|
|
565
|
+
section(received.record, "event").counterparty_public_key,
|
|
566
|
+
);
|
|
567
|
+
// The send names who received it.
|
|
568
|
+
if (sentNames !== normalizedSigningKey(received.publicKey)) failed.add("counterparty_named");
|
|
569
|
+
// The receipt names who sent it.
|
|
570
|
+
if (receivedNames !== normalizedSigningKey(sent.publicKey)) failed.add("counterparty_named");
|
|
571
|
+
const checks = CHECK_ORDER.filter((name) => failed.has(name));
|
|
572
|
+
return { verdict: checks.length > 0 ? "failed" : "verified", checks: [...checks] };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Read ONE record against everything the reader holds.
|
|
577
|
+
*
|
|
578
|
+
* Every record gets a reading, and the two findings are how the distinction a
|
|
579
|
+
* reader needs survives (proof#136): a record that is not a hand-off record at
|
|
580
|
+
* all reads `NOT_A_HANDOFF_FINDING` with no verdict, and a record 4.1 DOES
|
|
581
|
+
* govern and that is wrong reads `malformed`. The scope test lives HERE and
|
|
582
|
+
* not in `readHandoffs`, so the two entry points cannot answer differently
|
|
583
|
+
* about one record.
|
|
584
|
+
*/
|
|
585
|
+
export function readHandoff(subject: HeldRecord, held: readonly HeldRecord[]): HandoffReading {
|
|
586
|
+
if (!isHandoffNamespace(subject)) {
|
|
587
|
+
return {
|
|
588
|
+
ref: subject.ref,
|
|
589
|
+
verdict: null,
|
|
590
|
+
failedChecks: [],
|
|
591
|
+
findings: [NOT_A_HANDOFF_FINDING],
|
|
592
|
+
candidates: [],
|
|
593
|
+
candidateReadings: [],
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
if (recordIsMalformed(subject)) {
|
|
597
|
+
return {
|
|
598
|
+
ref: subject.ref,
|
|
599
|
+
verdict: "malformed",
|
|
600
|
+
failedChecks: [],
|
|
601
|
+
findings: ["malformed_record"],
|
|
602
|
+
candidates: [],
|
|
603
|
+
candidateReadings: [],
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const candidates = order(subject, candidatesFor(subject, held));
|
|
608
|
+
if (candidates.length === 0) {
|
|
609
|
+
return {
|
|
610
|
+
ref: subject.ref,
|
|
611
|
+
verdict: null,
|
|
612
|
+
failedChecks: [],
|
|
613
|
+
findings: ["counterpart_absent"],
|
|
614
|
+
candidates: [],
|
|
615
|
+
candidateReadings: [],
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// EVERY candidate is graded. Ordering above chose where each one is reported
|
|
620
|
+
// and took none of them out of this list.
|
|
621
|
+
const graded: CandidateReading[] = candidates.map((candidate) => {
|
|
622
|
+
const { verdict, checks } = gradePair(subject, candidate);
|
|
623
|
+
return { ref: candidate.ref, verdict, failedChecks: checks };
|
|
624
|
+
});
|
|
625
|
+
const worst = Math.max(...graded.map((g) => VERDICT_RANK[g.verdict]));
|
|
626
|
+
const verdict = (Object.keys(VERDICT_RANK).find(
|
|
627
|
+
(name) => VERDICT_RANK[name] === worst,
|
|
628
|
+
) as HandoffVerdict)!;
|
|
629
|
+
|
|
630
|
+
// Over every candidate, not only the ones at the winning verdict: `malformed`
|
|
631
|
+
// outranks `failed`, so a single junk record would otherwise strip the named
|
|
632
|
+
// `message_hash` failure out of the report (proof#133 review, ruling 6).
|
|
633
|
+
const failed = new Set<string>();
|
|
634
|
+
for (const g of graded) for (const check of g.failedChecks) failed.add(check);
|
|
635
|
+
|
|
636
|
+
const findings: string[] = [];
|
|
637
|
+
// Never a free choice: a reader permitted to take one match can be handed an
|
|
638
|
+
// honest decoy beside a tampered record.
|
|
639
|
+
if (candidates.length > 1) findings.push("counterpart_ambiguous");
|
|
640
|
+
if (verdict === "malformed") findings.push("malformed_record");
|
|
641
|
+
|
|
642
|
+
return {
|
|
643
|
+
ref: subject.ref,
|
|
644
|
+
verdict,
|
|
645
|
+
failedChecks: CHECK_ORDER.filter((name) => failed.has(name)),
|
|
646
|
+
findings,
|
|
647
|
+
candidates: candidates.map((c) => c.ref),
|
|
648
|
+
candidateReadings: graded,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* EVERY record the reader holds gets a reading, in the order given.
|
|
654
|
+
*
|
|
655
|
+
* This function does not filter, and the branch that used to decide whether a
|
|
656
|
+
* record was read at all is gone (proof#136, ratified by Phil 2026-09-22).
|
|
657
|
+
* Scope was two clauses, an exact test for the key name
|
|
658
|
+
* `counterparty_public_key` and an ASCII fold of `event_type`, and both
|
|
659
|
+
* operands are written by the party being audited: one code point on each put
|
|
660
|
+
* a record in scope by NEITHER route, so it had no reading at all and the pile
|
|
661
|
+
* read clean. Five repairs narrowed which values reached that silent branch
|
|
662
|
+
* and five hunts found a value outside it, so the branch is removed rather
|
|
663
|
+
* than narrowed again.
|
|
664
|
+
*
|
|
665
|
+
* The distinction a reader needs survives as two findings rather than as a
|
|
666
|
+
* reading and a silence: a record TRYING to be a hand-off and getting it wrong
|
|
667
|
+
* reads `malformed` with `malformed_record`, and a record that is not a
|
|
668
|
+
* hand-off record at all reads `NOT_A_HANDOFF_FINDING` with no verdict.
|
|
669
|
+
* `isHandoffNamespace` decides which of the two, in `readHandoff`, and no
|
|
670
|
+
* longer whether a record is read.
|
|
671
|
+
*
|
|
672
|
+
* THE COST, stated rather than left to be found: a reader given a mixed pile
|
|
673
|
+
* produces a line per record, including for records that have nothing to do
|
|
674
|
+
* with hand-offs. 4.1 says so too.
|
|
675
|
+
*/
|
|
676
|
+
export function readHandoffs(held: readonly HeldRecord[]): HandoffReading[] {
|
|
677
|
+
return held.map((record) => readHandoff(record, held));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Whether this record is a conformant hand-off record of 4.1: it is read as a
|
|
682
|
+
* hand-off record at all (`isHandoffNamespace`) and it is not `malformed`
|
|
683
|
+
* (`recordIsMalformed`), which between them mean it is exactly a
|
|
684
|
+
* `handoff.sent` or a `handoff.received` carrying every REQUIRED field of its
|
|
685
|
+
* own type, under an envelope key this reading can read.
|
|
686
|
+
*
|
|
687
|
+
* `./declaration.ts` reads this for evidence-bundle.md 5.4: a claimed
|
|
688
|
+
* `sent_event_id` or `received_event_id` naming a record that is not one of
|
|
689
|
+
* these is a DEVIATION, so a record cannot be unreadable at the record layer
|
|
690
|
+
* and usable at the pack layer at the same time (proof#136).
|
|
691
|
+
*/
|
|
692
|
+
export function isConformantHandoffRecord(held: HeldRecord): boolean {
|
|
693
|
+
return isHandoffNamespace(held) && !recordIsMalformed(held);
|
|
694
|
+
}
|