@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/dist/grading.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { DeclarationCheck, DeclarationContentKind, DeclarationDeviationKind, DeclarationVerdict } from "./declaration.js";
|
|
2
|
+
import { type SideContentMap } from "./side-content.js";
|
|
1
3
|
export type PackVerdict = "verified" | "partial" | "pending" | "not_found" | "mixed" | "failed" | "malformed" | "incomplete";
|
|
2
4
|
/**
|
|
3
5
|
* The minimum this module reads out of a verification result.
|
|
@@ -54,7 +56,13 @@ export type CompletenessNote = {
|
|
|
54
56
|
presented: null;
|
|
55
57
|
kind: "unreadable";
|
|
56
58
|
};
|
|
57
|
-
export type PackGradeCauseKind = "records-omitted" | "records-surplus" | "leaf-count-unreadable" | "no-disclosure" | "missing-disclosure" | "unreadable-disclosure" | "unaccounted-disclosure"
|
|
59
|
+
export type PackGradeCauseKind = "records-omitted" | "records-surplus" | "leaf-count-unreadable" | "no-disclosure" | "missing-disclosure" | "unreadable-disclosure" | "unaccounted-disclosure" | "declaration-deviation"
|
|
60
|
+
/**
|
|
61
|
+
* The pack and the UNSIGNED content handed in beside it do not line up
|
|
62
|
+
* (proof#147). A cause, so the pack cannot read clean over it and a reader
|
|
63
|
+
* is told which record; NOT a deviation, so it never reaches `failed`.
|
|
64
|
+
*/
|
|
65
|
+
| "declaration-content-dependent";
|
|
58
66
|
/**
|
|
59
67
|
* One reason a pack is not `verified`, as data.
|
|
60
68
|
*
|
|
@@ -79,6 +87,12 @@ export interface PackGradeCause {
|
|
|
79
87
|
firstEventId?: string;
|
|
80
88
|
/** Why one supplied disclosure could not be used. */
|
|
81
89
|
reason?: string;
|
|
90
|
+
/**
|
|
91
|
+
* Which deviation from the pack's own declaration, for
|
|
92
|
+
* `declaration-deviation`, or which content-dependent finding, for
|
|
93
|
+
* `declaration-content-dependent`.
|
|
94
|
+
*/
|
|
95
|
+
deviation?: DeclarationDeviationKind | DeclarationContentKind;
|
|
82
96
|
}
|
|
83
97
|
export interface PackGrade {
|
|
84
98
|
verdict: PackVerdict;
|
|
@@ -87,6 +101,12 @@ export interface PackGrade {
|
|
|
87
101
|
recordsFailed: number;
|
|
88
102
|
recordsUndetermined: number;
|
|
89
103
|
causes: PackGradeCause[];
|
|
104
|
+
/**
|
|
105
|
+
* The declaration check's own verdict, carried so a consumer that reads only
|
|
106
|
+
* this value can tell a declared pack from an undeclared one. Absent when no
|
|
107
|
+
* check was passed, which is the pre-§5.4 call.
|
|
108
|
+
*/
|
|
109
|
+
declaration?: DeclarationVerdict;
|
|
90
110
|
}
|
|
91
111
|
/**
|
|
92
112
|
* Account for the disclosed bytes a pack handed us against the records its
|
|
@@ -110,7 +130,7 @@ export interface PackGrade {
|
|
|
110
130
|
* listing and pass that to `gradePack` — the rule takes an account, never a
|
|
111
131
|
* bundle, precisely so the transport can say things the map cannot.
|
|
112
132
|
*/
|
|
113
|
-
export declare function accountDisclosure(bundle: unknown, content:
|
|
133
|
+
export declare function accountDisclosure(bundle: unknown, content: SideContentMap): DisclosureAccount;
|
|
114
134
|
/** The `leaf_count` a checkpoint's SIGNED record commits to, or null if unreadable. */
|
|
115
135
|
export declare function signedLeafCount(checkpoint: {
|
|
116
136
|
event_bytes?: unknown;
|
|
@@ -132,7 +152,7 @@ export declare function completenessNotes(bundle: unknown): CompletenessNote[];
|
|
|
132
152
|
* Every reason this pack is not `verified`, in the order a reader should hear
|
|
133
153
|
* them: what is missing from the whole, then what is missing from the parts.
|
|
134
154
|
*/
|
|
135
|
-
export declare function gradeCauses(account: DisclosureAccount, notes: readonly CompletenessNote[]): PackGradeCause[];
|
|
155
|
+
export declare function gradeCauses(account: DisclosureAccount, notes: readonly CompletenessNote[], declaration?: DeclarationCheck | null): PackGradeCause[];
|
|
136
156
|
/**
|
|
137
157
|
* The verdict a surface may print, and the counts it may print beside it.
|
|
138
158
|
*
|
|
@@ -145,8 +165,49 @@ export declare function gradeCauses(account: DisclosureAccount, notes: readonly
|
|
|
145
165
|
* (§5.1: undetermined, not failed), and a proven mismatch is never softened
|
|
146
166
|
* into absence. The last branch passes the kernel's headline through unchanged:
|
|
147
167
|
* this rule DOWNGRADES a verdict, it never invents one.
|
|
168
|
+
*
|
|
169
|
+
* ── the declaration (optional fourth argument) ──────────────────────────────
|
|
170
|
+
*
|
|
171
|
+
* `declaration` is what `checkDeclaration` in `./declaration.ts` made of the
|
|
172
|
+
* pack's own signed statement of what it holds (`evidence-bundle.md` §5.4). A
|
|
173
|
+
* deviation from it is a PROVEN mismatch between what the pack claimed and
|
|
174
|
+
* what it carries, so it FAILS the pack and each deviation becomes its own
|
|
175
|
+
* cause. It must never soften into absence: that is how a trimmed hand-over
|
|
176
|
+
* reads as an innocent gap.
|
|
177
|
+
*
|
|
178
|
+
* A check that came back `partial` splits in THREE (proof#147), and the first
|
|
179
|
+
* of the three is not about the declaration's readability at all. The other
|
|
180
|
+
* two split on whether the body carried a `declaration` member.
|
|
181
|
+
*
|
|
182
|
+
* content-dependent the pack and the UNSIGNED content beside it do not
|
|
183
|
+
* line up (proof#147). Each finding is its own cause
|
|
184
|
+
* and the pack is `incomplete`. It must never reach
|
|
185
|
+
* `failed`: whoever assembles a case fills in the
|
|
186
|
+
* side-content map, so an accuser holding an intact
|
|
187
|
+
* pack could otherwise put its holder into `failed`
|
|
188
|
+
* with no key at all, by dropping one file or adding
|
|
189
|
+
* one. `failed` is left to what a signature covers.
|
|
190
|
+
* no declaration at all nothing moves. Every pack built before §5.4 is
|
|
191
|
+
* this case, and re-grading them would move pinned
|
|
192
|
+
* exit codes for artifacts already in customers'
|
|
193
|
+
* hands.
|
|
194
|
+
* present and unreadable the pack is `incomplete`. `declaration_version` is
|
|
195
|
+
* chosen by the same party that assembles the pack
|
|
196
|
+
* and has no accept-set, so a holder who trims a
|
|
197
|
+
* record and then writes one unreadable field can
|
|
198
|
+
* re-seal the pack with its own key and hand a
|
|
199
|
+
* reader a clean verdict over evidence of the trim
|
|
200
|
+
* sitting inside the signed body. UNKNOWN is the
|
|
201
|
+
* only honest answer to a claim the reader cannot
|
|
202
|
+
* read, and it adds no cause: `PackGrade.declaration`
|
|
203
|
+
* says why.
|
|
204
|
+
*
|
|
205
|
+
* `verified` adds nothing and moves nothing.
|
|
206
|
+
*
|
|
207
|
+
* Omitting the argument entirely is the pre-§5.4 behavior, unchanged, and
|
|
208
|
+
* `PackGrade.declaration` is then absent.
|
|
148
209
|
*/
|
|
149
|
-
export declare function gradePack(result: GradeInput, account: DisclosureAccount, notes: readonly CompletenessNote[]): PackGrade;
|
|
210
|
+
export declare function gradePack(result: GradeInput, account: DisclosureAccount, notes: readonly CompletenessNote[], declaration?: DeclarationCheck | null): PackGrade;
|
|
150
211
|
/**
|
|
151
212
|
* The house wording for one cause.
|
|
152
213
|
*
|
package/dist/grading.js
CHANGED
|
@@ -67,7 +67,12 @@
|
|
|
67
67
|
// copy against it. If an implementation disagrees with the table, the
|
|
68
68
|
// implementation is wrong.
|
|
69
69
|
import { hexToBytes } from "./crypto.js";
|
|
70
|
+
import { renderContentFinding, renderDeviation } from "./declaration.js";
|
|
70
71
|
import { hasSideContent, sideContentKeys } from "./side-content.js";
|
|
72
|
+
// The kernel's one reader for bytes a producer signed: strict UTF-8, no leading
|
|
73
|
+
// byte-order mark, no non-finite literal. `jcs_loads` in
|
|
74
|
+
// `src/sanning_proof/canonicalize.py` is the twin (proof#140 gate 5).
|
|
75
|
+
import { jcsParse } from "./verifier.js";
|
|
71
76
|
/**
|
|
72
77
|
* The checkpoint's join key, in whichever vocabulary the bundle speaks.
|
|
73
78
|
*
|
|
@@ -240,7 +245,12 @@ function unsafeEventIdReason(eventId) {
|
|
|
240
245
|
* listing and pass that to `gradePack` — the rule takes an account, never a
|
|
241
246
|
* bundle, precisely so the transport can say things the map cannot.
|
|
242
247
|
*/
|
|
243
|
-
export function accountDisclosure(bundle,
|
|
248
|
+
export function accountDisclosure(bundle,
|
|
249
|
+
// `SideContentMap`, which is what `hasSideContent` and `sideContentKeys`
|
|
250
|
+
// below already take: an entry holding a string that is not hex IS an entry,
|
|
251
|
+
// and typing it out of this signature made the one map a caller builds
|
|
252
|
+
// unusable here (proof#147). A widening, so no caller moves.
|
|
253
|
+
content) {
|
|
244
254
|
const expected = [];
|
|
245
255
|
const present = [];
|
|
246
256
|
const missing = [];
|
|
@@ -288,7 +298,13 @@ export function signedLeafCount(checkpoint) {
|
|
|
288
298
|
if (typeof hex !== "string" || hex.length === 0)
|
|
289
299
|
return null;
|
|
290
300
|
try {
|
|
291
|
-
|
|
301
|
+
// `jcsParse`, matching `signed_leaf_count` in `src/sanning_proof/grading.py`.
|
|
302
|
+
// The pair used to agree by pointing two LENIENT decoders at one input, and
|
|
303
|
+
// that held for invalid UTF-8 and nowhere else: `TextDecoder` EATS a
|
|
304
|
+
// leading byte-order mark where Python's `errors="replace"` keeps it, so
|
|
305
|
+
// `BOM + JCS(checkpoint)` yielded a signed leaf count here and unreadable
|
|
306
|
+
// there, and that count decides a completeness note (proof#140 gate 5).
|
|
307
|
+
const record = jcsParse(hexToBytes(hex));
|
|
292
308
|
const n = record?.event?.leaf_count;
|
|
293
309
|
return Number.isInteger(n) ? n : null;
|
|
294
310
|
}
|
|
@@ -342,8 +358,32 @@ export function completenessNotes(bundle) {
|
|
|
342
358
|
* Every reason this pack is not `verified`, in the order a reader should hear
|
|
343
359
|
* them: what is missing from the whole, then what is missing from the parts.
|
|
344
360
|
*/
|
|
345
|
-
export function gradeCauses(account, notes) {
|
|
361
|
+
export function gradeCauses(account, notes, declaration) {
|
|
346
362
|
const causes = [];
|
|
363
|
+
// What the pack CLAIMED to hold comes first, because it frames everything
|
|
364
|
+
// below it: a reader told "four records are missing from what this pack says
|
|
365
|
+
// it contains" reads the per-record findings differently. Every deviation
|
|
366
|
+
// gets its own cause (one defect, one line), so a pack that deviates in
|
|
367
|
+
// four ways can never be reported as one vague shortfall.
|
|
368
|
+
for (const deviation of declaration?.deviations ?? []) {
|
|
369
|
+
causes.push({
|
|
370
|
+
kind: "declaration-deviation",
|
|
371
|
+
deviation: deviation.kind,
|
|
372
|
+
...(deviation.eventId === undefined ? {} : { firstEventId: deviation.eventId }),
|
|
373
|
+
reason: renderDeviation(deviation),
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
// Then what the pack and the UNSIGNED content beside it do not agree on
|
|
377
|
+
// (proof#147). A cause, so the pack cannot read clean over it; NOT a
|
|
378
|
+
// deviation, so it never reaches `failed`.
|
|
379
|
+
for (const finding of declaration?.contentFindings ?? []) {
|
|
380
|
+
causes.push({
|
|
381
|
+
kind: "declaration-content-dependent",
|
|
382
|
+
deviation: finding.kind,
|
|
383
|
+
...(finding.eventId === undefined ? {} : { firstEventId: finding.eventId }),
|
|
384
|
+
reason: renderContentFinding(finding),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
347
387
|
for (const note of notes) {
|
|
348
388
|
if (note.kind === "omitted") {
|
|
349
389
|
causes.push({
|
|
@@ -434,23 +474,77 @@ function passThrough(status) {
|
|
|
434
474
|
* (§5.1: undetermined, not failed), and a proven mismatch is never softened
|
|
435
475
|
* into absence. The last branch passes the kernel's headline through unchanged:
|
|
436
476
|
* this rule DOWNGRADES a verdict, it never invents one.
|
|
477
|
+
*
|
|
478
|
+
* ── the declaration (optional fourth argument) ──────────────────────────────
|
|
479
|
+
*
|
|
480
|
+
* `declaration` is what `checkDeclaration` in `./declaration.ts` made of the
|
|
481
|
+
* pack's own signed statement of what it holds (`evidence-bundle.md` §5.4). A
|
|
482
|
+
* deviation from it is a PROVEN mismatch between what the pack claimed and
|
|
483
|
+
* what it carries, so it FAILS the pack and each deviation becomes its own
|
|
484
|
+
* cause. It must never soften into absence: that is how a trimmed hand-over
|
|
485
|
+
* reads as an innocent gap.
|
|
486
|
+
*
|
|
487
|
+
* A check that came back `partial` splits in THREE (proof#147), and the first
|
|
488
|
+
* of the three is not about the declaration's readability at all. The other
|
|
489
|
+
* two split on whether the body carried a `declaration` member.
|
|
490
|
+
*
|
|
491
|
+
* content-dependent the pack and the UNSIGNED content beside it do not
|
|
492
|
+
* line up (proof#147). Each finding is its own cause
|
|
493
|
+
* and the pack is `incomplete`. It must never reach
|
|
494
|
+
* `failed`: whoever assembles a case fills in the
|
|
495
|
+
* side-content map, so an accuser holding an intact
|
|
496
|
+
* pack could otherwise put its holder into `failed`
|
|
497
|
+
* with no key at all, by dropping one file or adding
|
|
498
|
+
* one. `failed` is left to what a signature covers.
|
|
499
|
+
* no declaration at all nothing moves. Every pack built before §5.4 is
|
|
500
|
+
* this case, and re-grading them would move pinned
|
|
501
|
+
* exit codes for artifacts already in customers'
|
|
502
|
+
* hands.
|
|
503
|
+
* present and unreadable the pack is `incomplete`. `declaration_version` is
|
|
504
|
+
* chosen by the same party that assembles the pack
|
|
505
|
+
* and has no accept-set, so a holder who trims a
|
|
506
|
+
* record and then writes one unreadable field can
|
|
507
|
+
* re-seal the pack with its own key and hand a
|
|
508
|
+
* reader a clean verdict over evidence of the trim
|
|
509
|
+
* sitting inside the signed body. UNKNOWN is the
|
|
510
|
+
* only honest answer to a claim the reader cannot
|
|
511
|
+
* read, and it adds no cause: `PackGrade.declaration`
|
|
512
|
+
* says why.
|
|
513
|
+
*
|
|
514
|
+
* `verified` adds nothing and moves nothing.
|
|
515
|
+
*
|
|
516
|
+
* Omitting the argument entirely is the pre-§5.4 behavior, unchanged, and
|
|
517
|
+
* `PackGrade.declaration` is then absent.
|
|
437
518
|
*/
|
|
438
|
-
export function gradePack(result, account, notes) {
|
|
519
|
+
export function gradePack(result, account, notes, declaration) {
|
|
439
520
|
const events = Array.isArray(result?.events) ? result.events : [];
|
|
440
521
|
const recordsVerified = events.filter((e) => e.ok && e.contentOk === true).length;
|
|
441
522
|
const recordsFailed = events.filter((e) => e.ok === false || e.contentOk === false).length;
|
|
442
523
|
const recordsUndetermined = events.length - recordsVerified - recordsFailed;
|
|
443
|
-
const causes = gradeCauses(account, notes);
|
|
524
|
+
const causes = gradeCauses(account, notes, declaration);
|
|
444
525
|
let verdict;
|
|
445
526
|
if (result?.status === "malformed")
|
|
446
527
|
verdict = "malformed";
|
|
447
528
|
else if (result?.status === "failed" || recordsFailed > 0)
|
|
448
529
|
verdict = "failed";
|
|
530
|
+
else if (declaration?.verdict === "failed")
|
|
531
|
+
verdict = "failed";
|
|
449
532
|
else if (recordsUndetermined > 0 || causes.length > 0)
|
|
450
533
|
verdict = "incomplete";
|
|
534
|
+
else if (declaration?.present === true && declaration.verdict === "partial")
|
|
535
|
+
verdict = "incomplete";
|
|
451
536
|
else
|
|
452
537
|
verdict = passThrough(result?.status);
|
|
453
|
-
|
|
538
|
+
const grade = {
|
|
539
|
+
verdict,
|
|
540
|
+
recordsVerified,
|
|
541
|
+
recordsFailed,
|
|
542
|
+
recordsUndetermined,
|
|
543
|
+
causes,
|
|
544
|
+
};
|
|
545
|
+
if (declaration != null)
|
|
546
|
+
grade.declaration = declaration.verdict;
|
|
547
|
+
return grade;
|
|
454
548
|
}
|
|
455
549
|
// ---- Presentation (optional) -------------------------------------------------
|
|
456
550
|
/**
|
|
@@ -484,5 +578,17 @@ export function renderCause(cause, subject = "this pack") {
|
|
|
484
578
|
case "unaccounted-disclosure":
|
|
485
579
|
return (`${cause.count} disclosed file(s) are not accounted for by any record in this bundle ` +
|
|
486
580
|
`(first: ${cause.firstEventId})`);
|
|
581
|
+
// Rendered once, by `renderDeviation` in `./declaration.ts`, and carried
|
|
582
|
+
// here on `reason`. Re-deriving the sentence from the cause fields would
|
|
583
|
+
// put the declaration's wording in two places, and one of the two would be
|
|
584
|
+
// edited alone.
|
|
585
|
+
case "declaration-deviation":
|
|
586
|
+
return `this pack deviates from its own declaration: ${cause.reason}`;
|
|
587
|
+
case "declaration-content-dependent":
|
|
588
|
+
// Named as what the reader could not establish, never as what the
|
|
589
|
+
// holder did: the bytes it turns on are unsigned and whoever assembled
|
|
590
|
+
// the case chose them (proof#147).
|
|
591
|
+
return (`${subject} and the unsigned content handed in beside it do not line up: ` +
|
|
592
|
+
`${cause.reason}`);
|
|
487
593
|
}
|
|
488
594
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
export declare const CANDIDATE_NAMESPACE = "handoff.";
|
|
2
|
+
export declare const DEFINED_EVENT_TYPES: readonly ["handoff.sent", "handoff.received"];
|
|
3
|
+
/**
|
|
4
|
+
* The finding a record that is not a hand-off record at all reads with.
|
|
5
|
+
*
|
|
6
|
+
* A FINDING and not a verdict: the ratified verdict words are closed
|
|
7
|
+
* (anchor#188) and this reading names an outcome beside one, exactly as
|
|
8
|
+
* `counterpart_absent`, `counterpart_ambiguous` and `malformed_record` do. It
|
|
9
|
+
* exists because 4.1 had a SILENT branch, and a rule with a silent branch is a
|
|
10
|
+
* rule the party being audited steers into (proof#136). A record TRYING to be
|
|
11
|
+
* a hand-off and getting it wrong reads `malformed`; a record that is not a
|
|
12
|
+
* hand-off at all reads this. Both are readings. Neither is silence.
|
|
13
|
+
*/
|
|
14
|
+
export declare const NOT_A_HANDOFF_FINDING = "not_a_handoff_record";
|
|
15
|
+
/**
|
|
16
|
+
* An envelope `public_key` as a KEY rather than as a string: the 32 key bytes
|
|
17
|
+
* written as lowercase hex, or `null` where the value is not 64 characters of
|
|
18
|
+
* hex and so names no key this reading can compare.
|
|
19
|
+
*
|
|
20
|
+
* Case is the whole point: §2 states lowercase as a PRODUCER convention, the
|
|
21
|
+
* envelope layer accepts either case in every kernel, and the party being
|
|
22
|
+
* audited is the producer (proof#133 adversarial gate, ruling 1). Nothing
|
|
23
|
+
* wider is folded: whitespace inside the value is not stripped, because
|
|
24
|
+
* Python's `bytes.fromhex` skips ASCII whitespace and this kernel's charset
|
|
25
|
+
* check does not, a live divergence at the ENVELOPE layer and a separate
|
|
26
|
+
* founder call (proof#135). A reading rule that silently repaired it would
|
|
27
|
+
* hide it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function normalizedSigningKey(value: unknown): string | null;
|
|
30
|
+
/**
|
|
31
|
+
* Whether two envelope keys are the SAME KEY. Two values that name a key are
|
|
32
|
+
* equal when their key bytes are equal.
|
|
33
|
+
*
|
|
34
|
+
* Where either side names no key, the question cannot be answered from key
|
|
35
|
+
* bytes and the answer is the conservative one: the two are reported as one
|
|
36
|
+
* key, so the pair reads `failed[self_handoff]` rather than clean. Exact
|
|
37
|
+
* string equality was the answer here and it was the wrong way round: one key
|
|
38
|
+
* that signed both ends, spelled `abcd...` on one envelope and `abcd ...` on
|
|
39
|
+
* the other, is one party, and string equality called it two and read the
|
|
40
|
+
* self-hand-off `verified` (proof#133 fourth hunt, finding 1). A record whose
|
|
41
|
+
* own envelope key this reading cannot read is `malformed` under
|
|
42
|
+
* `recordIsMalformed` and a formed pair reaches `malformed` before it reaches
|
|
43
|
+
* this line, so the two rules are belt and braces rather than one rule stated
|
|
44
|
+
* twice.
|
|
45
|
+
*/
|
|
46
|
+
export declare function sameSigningKey(left: unknown, right: unknown): boolean;
|
|
47
|
+
export type HandoffVerdict = "verified" | "failed" | "malformed";
|
|
48
|
+
export interface HandoffRecord {
|
|
49
|
+
event_type?: unknown;
|
|
50
|
+
event?: unknown;
|
|
51
|
+
context?: unknown;
|
|
52
|
+
metadata?: unknown;
|
|
53
|
+
[key: string]: unknown;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* One hand-off record as a reader holds it: the committed record, plus the
|
|
57
|
+
* `public_key` of the envelope that carries it. The key comes from the ENVELOPE
|
|
58
|
+
* and never from the record, which is the whole point of the amended rule.
|
|
59
|
+
*
|
|
60
|
+
* PRECONDITION: THE ENVELOPE MUST ALREADY HAVE VERIFIED. "Unforgeable" is a
|
|
61
|
+
* property of a VERIFIED envelope and of nothing else. The key on an envelope
|
|
62
|
+
* whose signature has not been checked is an assertion like any other, and
|
|
63
|
+
* every reading in this module is then worth exactly as much as that
|
|
64
|
+
* assertion. A reader builds a `HeldRecord` only from an envelope that
|
|
65
|
+
* `verifyEnvelope` returned `ok` for, bound to the canonical bytes of this
|
|
66
|
+
* record. Nothing in this module can check that for itself, which is why it is
|
|
67
|
+
* stated here (proof#133 adversarial gate, ruling 3), and why candidacy and
|
|
68
|
+
* the self-hand-off check read `normalizedSigningKey(publicKey)` rather than
|
|
69
|
+
* the spelling the audited party chose.
|
|
70
|
+
*/
|
|
71
|
+
export interface HeldRecord {
|
|
72
|
+
publicKey: string;
|
|
73
|
+
record: HandoffRecord;
|
|
74
|
+
ref: string;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Whether a reader reads this record as a hand-off record at all.
|
|
78
|
+
*
|
|
79
|
+
* §4.1 says a reader reads EVERY hand-off record it holds, and an exact
|
|
80
|
+
* `handoff.` prefix did not deliver that: `HANDOFF.RECEIVED`,
|
|
81
|
+
* `sanning.handoff.received`, `handoff-received` and a non-string `event_type`
|
|
82
|
+
* all vanished from the reading entirely, not even as malformed, so a receipt
|
|
83
|
+
* typed one character outside the prefix was never read and, beside a clean decoy,
|
|
84
|
+
* published `verified` over proven tampering (proof#133 adversarial gate,
|
|
85
|
+
* ruling 2).
|
|
86
|
+
*
|
|
87
|
+
* The rule is checkable rather than a judgement, and it is exactly two
|
|
88
|
+
* clauses. The FIRST is the one the adversary cannot evade: the record's
|
|
89
|
+
* `event` carries `counterparty_public_key`, the field §4.1 defines and no
|
|
90
|
+
* other section does, WHATEVER its `event_type` says. The second keeps a
|
|
91
|
+
* record that omits the field in scope: an `event_type` that is a string whose
|
|
92
|
+
* folded form has `handoff` as one of its `.`-separated segments, which leaves
|
|
93
|
+
* `asset_missing` and the other families alone.
|
|
94
|
+
*
|
|
95
|
+
* The order is the fix, not decoration. Every earlier version of this test was
|
|
96
|
+
* an ALLOW-LIST OVER A NAME THE AUDITED PARTY WRITES, so each repair narrowed
|
|
97
|
+
* the class and the next spelling walked around it: an exact `handoff.` prefix
|
|
98
|
+
* fell to `HANDOFF.RECEIVED`, and the ASCII fold that replaced it fell to the
|
|
99
|
+
* `ff` ligature, full-width letters, a Cyrillic `a`, `hand-off`, `handoffs`, a
|
|
100
|
+
* zero-width space, a BOM and a non-breaking space, each of which left a
|
|
101
|
+
* tampered receipt with no reading at all beside a clean decoy (proof#133
|
|
102
|
+
* fourth hunt, finding 2). The structural field closes all of them in one rule
|
|
103
|
+
* rather than by chasing Unicode, because the attack needs the field: a
|
|
104
|
+
* receipt that drops `counterparty_public_key` says nothing about who it came
|
|
105
|
+
* from.
|
|
106
|
+
*
|
|
107
|
+
* Anything in scope that is not exactly `handoff.sent` or `handoff.received`
|
|
108
|
+
* is `malformed`, so a spelling is never a way out.
|
|
109
|
+
*
|
|
110
|
+
* THIS IS NO LONGER A GATE ON BEING READ (proof#136). Both clauses are written
|
|
111
|
+
* by the party being audited, and one code point on each put a record in scope
|
|
112
|
+
* by neither, which produced silence. `readHandoffs` reads every record it
|
|
113
|
+
* holds, and this test now decides only WHICH reading a record gets: in scope
|
|
114
|
+
* and wrong reads `malformed`, out of scope reads `NOT_A_HANDOFF_FINDING`. A
|
|
115
|
+
* respelling buys a different line rather than no line.
|
|
116
|
+
*/
|
|
117
|
+
export declare function isHandoffNamespace(held: HeldRecord): boolean;
|
|
118
|
+
/**
|
|
119
|
+
* What one candidate graded to, kept beside the aggregate. 4.1 requires a
|
|
120
|
+
* reader to name the record and which check failed, and `malformed` outranks
|
|
121
|
+
* `failed`, so one junk record signed by the counterparty key would otherwise
|
|
122
|
+
* turn a named `message_hash` failure into a `malformed` with nothing named
|
|
123
|
+
* (proof#133 review, ruling 6).
|
|
124
|
+
*/
|
|
125
|
+
export interface CandidateReading {
|
|
126
|
+
ref: string;
|
|
127
|
+
verdict: HandoffVerdict;
|
|
128
|
+
failedChecks: string[];
|
|
129
|
+
}
|
|
130
|
+
export interface HandoffReading {
|
|
131
|
+
ref: string;
|
|
132
|
+
/** `null` where no pair formed: there is no verdict to give, and the finding says why. */
|
|
133
|
+
verdict: HandoffVerdict | null;
|
|
134
|
+
failedChecks: string[];
|
|
135
|
+
findings: string[];
|
|
136
|
+
/** Ordered, never filtered. */
|
|
137
|
+
candidates: string[];
|
|
138
|
+
/** One entry per candidate, in the same order, and nothing left out of it. */
|
|
139
|
+
candidateReadings: CandidateReading[];
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* A record missing a REQUIRED field of its own type, or carrying one that is
|
|
143
|
+
* not the type §4.1 states, is malformed and fails. Stated as a rule because
|
|
144
|
+
* §4.1 had none: a pair where both sides omitted `message_hash` compared absent
|
|
145
|
+
* to absent, found them equal, and graded intact (proof#132).
|
|
146
|
+
*
|
|
147
|
+
* A record whose ENVELOPE key this reading cannot read is malformed too, and
|
|
148
|
+
* that one is a repair rather than an omission. The rule used to be "a value
|
|
149
|
+
* that is not 64 characters of hex names no key and matches nothing", so such a
|
|
150
|
+
* record dropped out of candidacy in silence and the subject read
|
|
151
|
+
* `counterpart_absent`, which §4.1 obliges a reader to call innocent. Nine
|
|
152
|
+
* whitespace spellings of a real key produce a VERIFIED envelope in exactly
|
|
153
|
+
* that state in the Python kernel, because `bytes.fromhex` skips ASCII
|
|
154
|
+
* whitespace, which reopened the whole of the first attack through the repair
|
|
155
|
+
* rather than around it (proof#133 fourth hunt, finding 1). A record a reader
|
|
156
|
+
* HOLDS, whose own key it cannot read, is a defect in the record and not an
|
|
157
|
+
* absence of one. This holds whatever the envelope layer decides about its hex
|
|
158
|
+
* charset (proof#135).
|
|
159
|
+
*/
|
|
160
|
+
export declare function recordIsMalformed(held: HeldRecord): boolean;
|
|
161
|
+
/**
|
|
162
|
+
* Read ONE record against everything the reader holds.
|
|
163
|
+
*
|
|
164
|
+
* Every record gets a reading, and the two findings are how the distinction a
|
|
165
|
+
* reader needs survives (proof#136): a record that is not a hand-off record at
|
|
166
|
+
* all reads `NOT_A_HANDOFF_FINDING` with no verdict, and a record 4.1 DOES
|
|
167
|
+
* govern and that is wrong reads `malformed`. The scope test lives HERE and
|
|
168
|
+
* not in `readHandoffs`, so the two entry points cannot answer differently
|
|
169
|
+
* about one record.
|
|
170
|
+
*/
|
|
171
|
+
export declare function readHandoff(subject: HeldRecord, held: readonly HeldRecord[]): HandoffReading;
|
|
172
|
+
/**
|
|
173
|
+
* EVERY record the reader holds gets a reading, in the order given.
|
|
174
|
+
*
|
|
175
|
+
* This function does not filter, and the branch that used to decide whether a
|
|
176
|
+
* record was read at all is gone (proof#136, ratified by Phil 2026-09-22).
|
|
177
|
+
* Scope was two clauses, an exact test for the key name
|
|
178
|
+
* `counterparty_public_key` and an ASCII fold of `event_type`, and both
|
|
179
|
+
* operands are written by the party being audited: one code point on each put
|
|
180
|
+
* a record in scope by NEITHER route, so it had no reading at all and the pile
|
|
181
|
+
* read clean. Five repairs narrowed which values reached that silent branch
|
|
182
|
+
* and five hunts found a value outside it, so the branch is removed rather
|
|
183
|
+
* than narrowed again.
|
|
184
|
+
*
|
|
185
|
+
* The distinction a reader needs survives as two findings rather than as a
|
|
186
|
+
* reading and a silence: a record TRYING to be a hand-off and getting it wrong
|
|
187
|
+
* reads `malformed` with `malformed_record`, and a record that is not a
|
|
188
|
+
* hand-off record at all reads `NOT_A_HANDOFF_FINDING` with no verdict.
|
|
189
|
+
* `isHandoffNamespace` decides which of the two, in `readHandoff`, and no
|
|
190
|
+
* longer whether a record is read.
|
|
191
|
+
*
|
|
192
|
+
* THE COST, stated rather than left to be found: a reader given a mixed pile
|
|
193
|
+
* produces a line per record, including for records that have nothing to do
|
|
194
|
+
* with hand-offs. 4.1 says so too.
|
|
195
|
+
*/
|
|
196
|
+
export declare function readHandoffs(held: readonly HeldRecord[]): HandoffReading[];
|
|
197
|
+
/**
|
|
198
|
+
* Whether this record is a conformant hand-off record of 4.1: it is read as a
|
|
199
|
+
* hand-off record at all (`isHandoffNamespace`) and it is not `malformed`
|
|
200
|
+
* (`recordIsMalformed`), which between them mean it is exactly a
|
|
201
|
+
* `handoff.sent` or a `handoff.received` carrying every REQUIRED field of its
|
|
202
|
+
* own type, under an envelope key this reading can read.
|
|
203
|
+
*
|
|
204
|
+
* `./declaration.ts` reads this for evidence-bundle.md 5.4: a claimed
|
|
205
|
+
* `sent_event_id` or `received_event_id` naming a record that is not one of
|
|
206
|
+
* these is a DEVIATION, so a record cannot be unreadable at the record layer
|
|
207
|
+
* and usable at the pack layer at the same time (proof#136).
|
|
208
|
+
*/
|
|
209
|
+
export declare function isConformantHandoffRecord(held: HeldRecord): boolean;
|