@sanning/proof 0.11.0 → 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/src/handoff.ts CHANGED
@@ -1,4 +1,4 @@
1
- // The hand-off pairing rule of specs/envelope-spec.md §4.1 (v1.9).
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
@@ -40,6 +40,9 @@
40
40
  // compared absent to absent and found them equal.
41
41
  // - A pair whose two envelope signing keys are equal does not verify.
42
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.
43
46
  //
44
47
  // THE THREE ATTACKS THE ADVERSARIAL GATE LANDED (proof#133, rulings 1 to 3),
45
48
  // and they are one root cause: candidacy and the self-hand-off check compared
@@ -116,14 +119,23 @@
116
119
  // `not_a_handoff_record` are findings reported beside a verdict rather than
117
120
  // verdicts.
118
121
  //
119
- // THE COST OF GRADING EVERY CANDIDATE, stated rather than left to be found. A
120
- // reader whose pile holds two or more records of the complementary type signed
121
- // by the counterparty key can no longer reach `verified`: every one of them is
122
- // graded and the worst of them is the reading. Two agents that exchange more
123
- // than one message land there. That is the price of never letting a value the
124
- // audited party writes decide what reaches a check, and the belt is the pack
125
- // layer: the declaration (evidence-bundle.md 5.4) says which records a pack
126
- // claims are a pair.
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.
127
139
 
128
140
  export const CANDIDATE_NAMESPACE = "handoff.";
129
141
  export const DEFINED_EVENT_TYPES = ["handoff.sent", "handoff.received"] as const;
@@ -147,7 +159,7 @@ const LOWER_HEX64 = /^[0-9a-f]{64}$/;
147
159
 
148
160
  // The order a reader names the checks a formed pair failed, fixed so two
149
161
  // kernels report one list rather than two orderings of it.
150
- const CHECK_ORDER = ["message_hash", "sequence", "self_handoff"] as const;
162
+ const CHECK_ORDER = ["message_hash", "sequence", "self_handoff", "counterparty_named"] as const;
151
163
  const VERDICT_RANK: Record<string, number> = { verified: 0, failed: 1, malformed: 2 };
152
164
 
153
165
  // ASCII whitespace, listed rather than delegated: `String.trim()` and Python's
@@ -312,6 +324,16 @@ export interface CandidateReading {
312
324
  ref: string;
313
325
  verdict: HandoffVerdict;
314
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;
315
337
  }
316
338
 
317
339
  export interface HandoffReading {
@@ -322,7 +344,12 @@ export interface HandoffReading {
322
344
  findings: string[];
323
345
  /** Ordered, never filtered. */
324
346
  candidates: string[];
325
- /** One entry per candidate, in the same order, and nothing left out of it. */
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
+ */
326
353
  candidateReadings: CandidateReading[];
327
354
  }
328
355
 
@@ -548,6 +575,23 @@ function gradePair(
548
575
  const counterpartHash = String(counterpartEvent.message_hash ?? "").toLowerCase();
549
576
  if (subjectHash !== counterpartHash) failed.add("message_hash");
550
577
  if (!sameNumber(subjectEvent.sequence, counterpartEvent.sequence)) failed.add("sequence");
578
+ // Each record names the OTHER's signer (v1.16, A21). Candidacy reads the key
579
+ // the subject names and nothing the candidate says, so up to and including
580
+ // 0.11.0 a sent record addressed to C paired `verified` with B's receipt of
581
+ // it, and a receipt naming C as its sender paired `verified` with A's send.
582
+ // Both halves of the naming are graded, each against the envelope key the
583
+ // other record was SIGNED with, compared as a key. The Python half is
584
+ // `_grade_pair`.
585
+ const [sent, received] =
586
+ eventType(subject) === "handoff.sent" ? [subject, counterpart] : [counterpart, subject];
587
+ const sentNames = normalizedSigningKey(section(sent.record, "event").counterparty_public_key);
588
+ const receivedNames = normalizedSigningKey(
589
+ section(received.record, "event").counterparty_public_key,
590
+ );
591
+ // The send names who received it.
592
+ if (sentNames !== normalizedSigningKey(received.publicKey)) failed.add("counterparty_named");
593
+ // The receipt names who sent it.
594
+ if (receivedNames !== normalizedSigningKey(sent.publicKey)) failed.add("counterparty_named");
551
595
  const checks = CHECK_ORDER.filter((name) => failed.has(name));
552
596
  return { verdict: checks.length > 0 ? "failed" : "verified", checks: [...checks] };
553
597
  }
@@ -600,23 +644,42 @@ export function readHandoff(subject: HeldRecord, held: readonly HeldRecord[]): H
600
644
  // and took none of them out of this list.
601
645
  const graded: CandidateReading[] = candidates.map((candidate) => {
602
646
  const { verdict, checks } = gradePair(subject, candidate);
603
- return { ref: candidate.ref, verdict, failedChecks: checks };
647
+ const by = verdict === "failed" ? accountedFor(subject, candidate, held) : null;
648
+ return { ref: candidate.ref, verdict, failedChecks: checks, accountedFor: by };
604
649
  });
605
- const worst = Math.max(...graded.map((g) => VERDICT_RANK[g.verdict]));
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]));
606
668
  const verdict = (Object.keys(VERDICT_RANK).find(
607
669
  (name) => VERDICT_RANK[name] === worst,
608
670
  ) as HandoffVerdict)!;
609
671
 
610
- // Over every candidate, not only the ones at the winning verdict: `malformed`
611
- // outranks `failed`, so a single junk record would otherwise strip the named
612
- // `message_hash` failure out of the report (proof#133 review, ruling 6).
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).
613
676
  const failed = new Set<string>();
614
- for (const g of graded) for (const check of g.failedChecks) failed.add(check);
677
+ for (const g of live) for (const check of g.failedChecks) failed.add(check);
615
678
 
616
679
  const findings: string[] = [];
617
680
  // Never a free choice: a reader permitted to take one match can be handed an
618
681
  // honest decoy beside a tampered record.
619
- if (candidates.length > 1) findings.push("counterpart_ambiguous");
682
+ if (live.length > 1) findings.push("counterpart_ambiguous");
620
683
  if (verdict === "malformed") findings.push("malformed_record");
621
684
 
622
685
  return {
@@ -629,6 +692,54 @@ export function readHandoff(subject: HeldRecord, held: readonly HeldRecord[]): H
629
692
  };
630
693
  }
631
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
+
632
743
  /**
633
744
  * EVERY record the reader holds gets a reading, in the order given.
634
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. Whether a party that presented
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`.