@sanning/proof 0.11.1 → 0.12.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 +22 -0
- package/dist/case.d.ts +25 -16
- package/dist/case.js +53 -23
- package/dist/cli.d.ts +98 -0
- package/dist/cli.js +701 -6
- package/dist/handoff.d.ts +16 -1
- package/dist/handoff.js +91 -16
- package/dist/index.js +2 -1
- package/package.json +1 -1
- package/src/case.ts +65 -24
- package/src/cli.ts +787 -7
- package/src/handoff.ts +108 -17
- package/src/index.ts +2 -1
- package/vectors/README.md +35 -0
- package/vectors/case-split.json +893 -0
package/src/handoff.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The hand-off pairing rule of specs/envelope-spec.md §4.1 (v1.
|
|
1
|
+
// The hand-off pairing rule of specs/envelope-spec.md §4.1 (v1.17).
|
|
2
2
|
//
|
|
3
3
|
// Two agents exchange a message and each side records what it held. This module
|
|
4
4
|
// is the TypeScript half of the reference reading of those two records: which
|
|
@@ -119,14 +119,23 @@
|
|
|
119
119
|
// `not_a_handoff_record` are findings reported beside a verdict rather than
|
|
120
120
|
// verdicts.
|
|
121
121
|
//
|
|
122
|
-
// THE COST OF GRADING EVERY CANDIDATE,
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
// than one message
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
122
|
+
// THE COST OF GRADING EVERY CANDIDATE, AND THE ACCOUNTED-FOR RULE THAT PAYS
|
|
123
|
+
// MOST OF IT (envelope-spec.md 4.1 v1.17, amendment A20). Grading every
|
|
124
|
+
// candidate means the records of a party's OTHER exchanges are candidates too,
|
|
125
|
+
// and they fail. Up to v1.16 that put `verified` out of reach for any two agents
|
|
126
|
+
// that exchanged more than one message in one direction, and the declaration
|
|
127
|
+
// named as the belt cannot be one: evidence-bundle.md 5.5 forbids the reading
|
|
128
|
+
// to consult `handoffs[]` to pair anything. So a candidate that grades `failed`
|
|
129
|
+
// is SET ASIDE from the subject's verdict, failed checks and ambiguity when,
|
|
130
|
+
// and only when, the reader holds another record `o` that accounts for it
|
|
131
|
+
// (`accountedFor` below): `o` is signed by the subject's own key and is the
|
|
132
|
+
// subject's type; `o` names the candidate's signer and the candidate names the
|
|
133
|
+
// subject's key back; `o`'s sequence differs from the subject's; and the
|
|
134
|
+
// candidate grades `verified` against `o`. The candidate stays in `candidates`
|
|
135
|
+
// and `candidateReadings`, with `accountedFor` set. Setting one aside needs a
|
|
136
|
+
// verified pair with a record the OTHER side signed, so a tampered record
|
|
137
|
+
// stays live. Fan-in, fan-out, and one pair in two cases at one sequence still
|
|
138
|
+
// read `failed`: the Python half's module docstring says why.
|
|
130
139
|
|
|
131
140
|
export const CANDIDATE_NAMESPACE = "handoff.";
|
|
132
141
|
export const DEFINED_EVENT_TYPES = ["handoff.sent", "handoff.received"] as const;
|
|
@@ -315,6 +324,16 @@ export interface CandidateReading {
|
|
|
315
324
|
ref: string;
|
|
316
325
|
verdict: HandoffVerdict;
|
|
317
326
|
failedChecks: string[];
|
|
327
|
+
/**
|
|
328
|
+
* The ref of the record that ACCOUNTS FOR this candidate, or `null` (4.1
|
|
329
|
+
* v1.17, A20). Set only on a candidate that graded `failed` and that the
|
|
330
|
+
* reader holds a verified pair for elsewhere; such a candidate is still
|
|
331
|
+
* reported here and takes no part in the subject's verdict, failed checks or
|
|
332
|
+
* ambiguity. Every reading this kernel returns carries it. It is optional in
|
|
333
|
+
* the type only so that code which builds a `CandidateReading` itself keeps
|
|
334
|
+
* compiling: the field is an addition.
|
|
335
|
+
*/
|
|
336
|
+
accountedFor?: string | null;
|
|
318
337
|
}
|
|
319
338
|
|
|
320
339
|
export interface HandoffReading {
|
|
@@ -325,7 +344,12 @@ export interface HandoffReading {
|
|
|
325
344
|
findings: string[];
|
|
326
345
|
/** Ordered, never filtered. */
|
|
327
346
|
candidates: string[];
|
|
328
|
-
/**
|
|
347
|
+
/**
|
|
348
|
+
* One entry per candidate, in the same order, and nothing left out of it. A
|
|
349
|
+
* candidate another record accounts for is here with `accountedFor` set, and
|
|
350
|
+
* where every candidate is accounted for the reading is `counterpart_absent`
|
|
351
|
+
* with the candidates still listed.
|
|
352
|
+
*/
|
|
329
353
|
candidateReadings: CandidateReading[];
|
|
330
354
|
}
|
|
331
355
|
|
|
@@ -620,23 +644,42 @@ export function readHandoff(subject: HeldRecord, held: readonly HeldRecord[]): H
|
|
|
620
644
|
// and took none of them out of this list.
|
|
621
645
|
const graded: CandidateReading[] = candidates.map((candidate) => {
|
|
622
646
|
const { verdict, checks } = gradePair(subject, candidate);
|
|
623
|
-
|
|
647
|
+
const by = verdict === "failed" ? accountedFor(subject, candidate, held) : null;
|
|
648
|
+
return { ref: candidate.ref, verdict, failedChecks: checks, accountedFor: by };
|
|
624
649
|
});
|
|
625
|
-
|
|
650
|
+
|
|
651
|
+
// The verdict, the failed checks and the ambiguity are taken over the
|
|
652
|
+
// candidates nothing accounts for. The others stay in the report.
|
|
653
|
+
const live = graded.filter((g) => g.accountedFor === null);
|
|
654
|
+
if (live.length === 0) {
|
|
655
|
+
// Every candidate is another exchange's record, each proven so by a
|
|
656
|
+
// verified pair with a record the subject's own key signed: an absence,
|
|
657
|
+
// reported with the candidates it looked at.
|
|
658
|
+
return {
|
|
659
|
+
ref: subject.ref,
|
|
660
|
+
verdict: null,
|
|
661
|
+
failedChecks: [],
|
|
662
|
+
findings: ["counterpart_absent"],
|
|
663
|
+
candidates: candidates.map((c) => c.ref),
|
|
664
|
+
candidateReadings: graded,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
const worst = Math.max(...live.map((g) => VERDICT_RANK[g.verdict]));
|
|
626
668
|
const verdict = (Object.keys(VERDICT_RANK).find(
|
|
627
669
|
(name) => VERDICT_RANK[name] === worst,
|
|
628
670
|
) as HandoffVerdict)!;
|
|
629
671
|
|
|
630
|
-
// Over every candidate, not only the ones at the winning verdict:
|
|
631
|
-
// outranks `failed`, so a single junk record would otherwise
|
|
632
|
-
// `message_hash` failure out of the report (proof#133
|
|
672
|
+
// Over every live candidate, not only the ones at the winning verdict:
|
|
673
|
+
// `malformed` outranks `failed`, so a single junk record would otherwise
|
|
674
|
+
// strip the named `message_hash` failure out of the report (proof#133
|
|
675
|
+
// review, ruling 6).
|
|
633
676
|
const failed = new Set<string>();
|
|
634
|
-
for (const g of
|
|
677
|
+
for (const g of live) for (const check of g.failedChecks) failed.add(check);
|
|
635
678
|
|
|
636
679
|
const findings: string[] = [];
|
|
637
680
|
// Never a free choice: a reader permitted to take one match can be handed an
|
|
638
681
|
// honest decoy beside a tampered record.
|
|
639
|
-
if (
|
|
682
|
+
if (live.length > 1) findings.push("counterpart_ambiguous");
|
|
640
683
|
if (verdict === "malformed") findings.push("malformed_record");
|
|
641
684
|
|
|
642
685
|
return {
|
|
@@ -649,6 +692,54 @@ export function readHandoff(subject: HeldRecord, held: readonly HeldRecord[]): H
|
|
|
649
692
|
};
|
|
650
693
|
}
|
|
651
694
|
|
|
695
|
+
/**
|
|
696
|
+
* The ref of the record that accounts for a FAILED candidate, or `null` (4.1
|
|
697
|
+
* v1.17, A20). The candidate is another exchange's record, and not a deviation
|
|
698
|
+
* on this one, only where the reader holds a record `o`, other than the
|
|
699
|
+
* subject, for which all four hold:
|
|
700
|
+
*
|
|
701
|
+
* 1. `o` is signed by the SUBJECT's own key, and is the subject's type;
|
|
702
|
+
* 2. `o` names the candidate's signer, and the candidate names the subject's
|
|
703
|
+
* key back;
|
|
704
|
+
* 3. `o`'s `sequence` differs from the subject's;
|
|
705
|
+
* 4. the candidate grades `verified` against `o`.
|
|
706
|
+
*
|
|
707
|
+
* Condition 1 is what the audited party cannot supply: a verified pair needs
|
|
708
|
+
* the OTHER side's signature over the digest and the number. Condition 3 is
|
|
709
|
+
* the position guard (proof#129): a second record at the subject's own number
|
|
710
|
+
* is a second claim about the subject's message. The first `o` in the order
|
|
711
|
+
* the records are held is reported, so two kernels name one record. The
|
|
712
|
+
* Python half, `_accounted_for`, carries the full reasoning.
|
|
713
|
+
*/
|
|
714
|
+
function accountedFor(
|
|
715
|
+
subject: HeldRecord,
|
|
716
|
+
candidate: HeldRecord,
|
|
717
|
+
held: readonly HeldRecord[],
|
|
718
|
+
): string | null {
|
|
719
|
+
const own = normalizedSigningKey(subject.publicKey);
|
|
720
|
+
const theirs = normalizedSigningKey(candidate.publicKey);
|
|
721
|
+
// Condition 2: the candidate names the subject's key back.
|
|
722
|
+
if (normalizedSigningKey(section(candidate.record, "event").counterparty_public_key) !== own) {
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
const subjectSequence = section(subject.record, "event").sequence;
|
|
726
|
+
for (const other of held) {
|
|
727
|
+
if (other === subject) continue;
|
|
728
|
+
// Condition 1: signed by the subject's own key, of the subject's own type.
|
|
729
|
+
if (normalizedSigningKey(other.publicKey) !== own) continue;
|
|
730
|
+
if (eventType(other) !== eventType(subject)) continue;
|
|
731
|
+
// Condition 2: o names the candidate's signer.
|
|
732
|
+
if (normalizedSigningKey(section(other.record, "event").counterparty_public_key) !== theirs) {
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
// Condition 3: another position than the subject's.
|
|
736
|
+
if (sameNumber(section(other.record, "event").sequence, subjectSequence)) continue;
|
|
737
|
+
// Condition 4: a verified pair.
|
|
738
|
+
if (gradePair(other, candidate).verdict === "verified") return other.ref;
|
|
739
|
+
}
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
|
|
652
743
|
/**
|
|
653
744
|
* EVERY record the reader holds gets a reading, in the order given.
|
|
654
745
|
*
|
package/src/index.ts
CHANGED
|
@@ -193,7 +193,8 @@ export type {
|
|
|
193
193
|
// because the omission happens before any reader runs. Reading several parties'
|
|
194
194
|
// packs at once is what closes it: a `handoff.sent` with no counterpart is
|
|
195
195
|
// always named, and where a pack signed by the key it names is in the case, the
|
|
196
|
-
// reading says that pack holds no counterpart
|
|
196
|
+
// reading says that pack holds no counterpart (unless every candidate was
|
|
197
|
+
// accounted for under A20, which reads as no counterpart and names no pack). Whether a party that presented
|
|
197
198
|
// no such pack recorded anything is not established (proof#143). `readCase`
|
|
198
199
|
// verifies every pack itself, offline, so it is async. Matching is on keys and hashes alone and NEVER on tenancy, so a pair from two
|
|
199
200
|
// organizations verifies (DL-60).
|
package/vectors/README.md
CHANGED
|
@@ -117,3 +117,38 @@ happened, and the reason this file moved here.
|
|
|
117
117
|
Nothing in a case names a transport. A directory read and a browser drop produce
|
|
118
118
|
the same account and must produce the same verdict; a table that encoded one
|
|
119
119
|
surface's mechanism would let the other one drift while staying green.
|
|
120
|
+
|
|
121
|
+
## `case-split.json`
|
|
122
|
+
|
|
123
|
+
How `case` turns the folders it was handed into packs: the verify page's
|
|
124
|
+
splitting rule (`sanning-io/verify-page` `src/lib/case.ts` `splitCase`,
|
|
125
|
+
`src/lib/pack.ts` `assemble`) over a walk whose rules are the command's own,
|
|
126
|
+
restated in [`../src/cli.ts`](../src/cli.ts) and `src/sanning_proof/cli.py`
|
|
127
|
+
because the page's code lives in the page. Two tables and the limits:
|
|
128
|
+
|
|
129
|
+
- `trees`: a directory tree to create (files, folders, symbolic links, a name
|
|
130
|
+
given as bytes, or a folder filled with N empty files), the arguments, and
|
|
131
|
+
the packs, the outside paths and the paths that are not regular files, or
|
|
132
|
+
the refusal. The rows include the decision file unpacked, a symbolic link
|
|
133
|
+
under `logs/`, each side of the depth and entry limits, and names that are
|
|
134
|
+
not UTF-8.
|
|
135
|
+
- `parses`: how `bundle.json` bytes become JSON through the reader `verify`
|
|
136
|
+
and `case` share, including each side of the nesting limit.
|
|
137
|
+
- `limits`: the walk depth (32), the entry count (50,000) and the JSON nesting
|
|
138
|
+
depth (512), which both kernels assert they state.
|
|
139
|
+
|
|
140
|
+
Asserted by [`../test/case-cli.test.ts`](../test/case-cli.test.ts) **and by
|
|
141
|
+
the Python kernel against the same trees**, in `tests/test_case_cli.py`, and
|
|
142
|
+
run through both commands by `cross-kernel/run_case_cli.sh`.
|
|
143
|
+
|
|
144
|
+
Every expectation is a literal written in
|
|
145
|
+
`tools/gen-vectors/gen_case_split_vectors.py`, not the output of either
|
|
146
|
+
kernel. The splitting rows were graded against the page's own code when the
|
|
147
|
+
table was first written. The walk rows (links, depth, entry count, names, a
|
|
148
|
+
`.zip` argument) and the reader rows are the command's own rules, and the page
|
|
149
|
+
does not share them: it opens a dropped `.zip`, drops a leading byte-order
|
|
150
|
+
mark, and reads whatever the browser hands it for a link, a bad name or a deep
|
|
151
|
+
tree. One splitting row disagrees with the page on purpose: `record-file-names` keeps
|
|
152
|
+
a record named `__proto__`, which the page's `assemble` loses because it keys
|
|
153
|
+
disclosed content by a plain object. A page that vendors this table fails that
|
|
154
|
+
row until it keys content by a `Map`.
|