Skip to content

A share supplied twice counts once, one line rule for pasted text, and property tests across both readers - #275

Merged
404SecNotFound merged 5 commits into
feature/keym2-audio-readerfrom
feature/property-tests
Oct 24, 2026
Merged

404SecNotFound merged 5 commits into
feature/keym2-audio-readerfrom
feature/property-tests

Conversation

@404SecNotFound

@404SecNotFound 404SecNotFound commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

What this is

Bet 5 of the review report's plan. Three parts, because each fell out of the one before.

  1. A share supplied twice now counts once. Both readers refused a set holding k genuine shares when one of them was pasted or scanned twice, and the generic message told the person to check strips that were all correct. The spec now says a share whose decoded record is identical to one already supplied is that share supplied twice, and is dropped before the set-level rules run.
  2. A seeded property suite. reference/properties_test.py generates armor, shares, share sets, paper parts and Padmé lengths from a printed seed and hands every case to keym2.py and to the shipping TypeScript in one batch. Each verdict must match the other implementation's exactly, and where the right answer is known it must be that answer.
  3. One line rule for pasted text. The suite showed that keym2.py join and the app split a paste into lines differently, and so did --shares-from and the app's share box. §7 now defines it once, and both languages implement it once.

Stacked on #274 (the audio carrier reader), so this diff shows only its own change. GitHub retargets it to main when #274 merges.

The rules, in the house order

  1. Design doc.
    • §6 gains one reader rule. Each supplied share is checked on its own first (checksum, padding bits, k, index). Then a record identical to one already supplied is the same share supplied twice and is dropped, before the set-level rules. A damaged copy is refused as damaged, never dropped as a repeat. A record that differs in any one of its four fields is a different share. The index rule now refuses two different shares at one index, and reconstruction counts a repeat once.
    • §7 gains a paragraph on text that holds one item per line. A line ends at LF, CRLF or a lone CR, which is what a browser's text box hands over for all three. Each line is stripped of IGNORABLE characters, and a line left empty or starting with # is skipped. The other characters an editor may treat as a line end (U+000B, U+000C, U+0085, U+2028, U+2029) are IGNORABLE, so inside a line they are ignored.
  2. Reference.
    • combine_shares drops a repeated record, comparing set id and value in constant time because a share value is key material.
    • text_lines implements the §7 line rule. join and --shares-from both use it.
  3. TypeScript.
    • combineShares does the same as combine_shares. The repeats are only skipped, so the existing finally still erases every decoded value.
    • textLines in keym-text.ts is the line rule. splitPaperParts and the share box's parseShareLines both call it.
    • The key-ceremony rehearsal lists a strip pasted twice once.
  4. No fixture change. No container byte changes and no share text changes.

What changes for a person:

  • A set with a strip repeated now opens.
  • A parts file holding U+2028, U+0085, U+000B or U+000C inside a part now reassembles in keym2.py join, as it already did in the app. A lone CR still ends a line in both, as it did before.
  • A share list with a line holding only U+0085 now opens in the app, as it already did in keym2.py.

Nothing that opened before stops opening. These stay refused, in both readers, in conformance and in the property suite:

  • a repeat standing in for a missing share;
  • a different share at an index already supplied;
  • a copy of a share that differs only in k, or only in set id;
  • a damaged copy (bad checksum, spare padding bit).

The property suite

npm run test:properties, also a step in the conformance workflow. About 5,900 cases, in under 5 seconds, across 24 properties. Every multi-line case is joined with each of the three line ends in turn.

Area What must hold
Seed The cases are rebuilt in two child processes with different hash seeds and must be identical, so the printed seed replays a run
Armor Ignorable characters after the prefix are ignored, one inside the prefix is refused, and a cut-short armor reads a prefix of its bytes or nothing
Share spelling Case, I/L/O look-alikes, hyphens and ignorable characters decode to the record, including one just after the prefix
Share damage Refused with an ignorable character inside the prefix, one character changed, a character from outside the alphabet swapped in or inserted, a non-ASCII letter that case-folds into the alphabet (ı for 1, ſ for S, the Kelvin sign for K), or any spare padding bit set
Share sets k or more distinct open, in any order, with repeats in any spelling and blank, comment and ignorable-only lines between them. Fewer than k distinct never open. A consistent extra share above the k lowest is never used. A different share, a one-field copy (k, or a set id differing past byte 4) or a damaged copy at a supplied index is refused in either order. A copy with only its index changed is a different share and is interpolated. Both readers agree on an inconsistent share among the k lowest
Paper parts Any order, with ignorable characters in bodies, comment lines and any of the three line ends, reassembles the container. A set missing any one part is refused, and so is one holding a part twice
Padmé The same length in both, never shorter than its input, never decreasing

Everything that can be enumerated is enumerated instead of drawn, whatever KEYMAKER_PROPERTY_CASES is set to:

  • each ignorable character;
  • each look-alike and fold trap;
  • each spare padding bit;
  • each part dropped in turn.

So the controls below bite on every seed, not only on a lucky one.

A crash on either side is its own verdict and can never pass as a refusal:

  • the bridge records a TypeError, RangeError, ReferenceError or SyntaxError as a crash;
  • the Python side accepts only the exceptions keym2.py raises on purpose for each kind of text.

No new dependency. The TypeScript side is two new verdict kinds (combine, padlen) on the bridge's existing textverdicts command. combine reads lines with the share box's own parseShareLines, so the box is what is tested.

Review

The first commit went through three independent reviews (rule semantics, harness strength, the other tests). They returned 16 findings, 14 of them distinct. The 8 handed to a verifier told to refute them were all reproduced instead, and each of the other 6 was confirmed by a negative control or by reading the code. All are fixed in the second commit.

  • Spec order. §6 said to drop a repeat before the checksum and padding-bit rules. A reader written from the prose would have dropped a damaged copy as a repeat.
  • Untested fields. The k and set-id parts of the duplicate comparison were tested nowhere. Deleting them passed every gate.
  • Seed replay. The printed seed did not replay a run, because share coefficients came from os.urandom.
  • Crashes passed as refusals. A TypeScript crash on a refusal case read as agreement.
  • Generator gaps. The k-lowest rule in TypeScript, an ignorable character inside a share prefix, and the share padding bits all went unexercised. The last was caught on about two seeds in three.
  • The line rule. This is the divergence described above, between keym2.py join and the app, and between --shares-from and the share box.
  • Weak conformance checks. The conformance duplicate checks asked whether the container opened, which a reader returning garbage also satisfies. They now compare refusals through both combiners.
  • Comments. Two were wrong.

A second review of that commit found nine more, all fixed in the third commit. Seven were handed to a refuter and all survived; the other two were confirmed by negative controls.

  • Lone CR. The second commit's §7 rule ended a line at LF only. A browser's text box hands over a lone CR as a line break, so a CR-only shares or parts file that opens in the app failed in the recovery kit. A line now ends at LF, CRLF or a lone CR.
  • CLI paths untested. Reverting join or --shares-from to its old line handling passed every suite. The self-test now runs both on CR-only and CRLF files holding U+2028 and U+0085, and asserts the bytes.
  • KMSHARE2 gaps. A duplicate check comparing only the first 4 bytes of a v2 set id, and one dropping a v2 copy with a bad checksum, passed every suite.
  • Index. A duplicate check that ignored the index passed every suite. A copy with only its index changed is now tested as a different share.
  • Harness. Characters from outside the alphabet were only swapped in, never inserted. The replay check ran in one process, so it could not see a set's iteration order. The fold traps covered upper() only.
  • Conformance guards. The new block called the reference unguarded, so a regression ended the run with a traceback.

Tests

Suite Before After
keym2.py selftest 783 802
shamir-adversarial.mts 19 38
crosstest2.py (conformance) 601 618
properties_test.py none 24 properties, about 5,900 cases
Chromium shamir-ui.spec.ts 18 19

Negative controls

Each was applied to a copy, confirmed to compile, and shown to fail the checks that name it without crashing the run.

  • Duplicate rule. No dedupe, dedupe ignoring the value, dedupe ignoring k and set id, and repeats counted toward k, each in both readers. Also a damaged copy dropped as a repeat, in both readers. Each fails the self-test or shamir-adversarial.mts, conformance and the property suite as relevant. One TypeScript check first passed, because a reader that counted the repeat toward k returned garbage instead of refusing, and the check only asked whether the set opened. It now asserts a refusal.
  • k lowest. TypeScript taking the first k instead of the k lowest fails the stray-share property on 137 of 200 cases.
  • Duplicate key, second round. A set id compared over the v1 width fails the TypeScript suite, conformance and the property suite. A dedupe ignoring the index fails all four suites. A damaged v2 copy dropped as a repeat fails conformance and the property suite.
  • Inserted junk and folding. TypeScript skipping ., _ and / like a hyphen fails the inserted-character property. Python folding with lower() fails the fold-trap property on the Kelvin sign.
  • Guards. With the reference refusing every set of exactly k shares, conformance reports 34 named failures and no traceback.
  • Share text.
    • TypeScript dropping ignorable characters before matching the prefix.
    • TypeScript ignoring padding bits.
    • Python and TypeScript each upper-casing with the language's own upper(). That control first bit on 1 case in 200, so the fold traps are now placed where they matter, and it bites on all 363.
  • Line rule. Each of these fails the parts or combine properties, the self-test's CLI checks, or both.
    • text_lines using splitlines(), or ignoring a lone CR.
    • textLines using trim(), or ignoring a lone CR.
    • The share box reverting to its old trim() parser.
    • join reverting to splitlines().
    • --shares-from splitting at LF only, or with splitlines().
  • The harness itself.
    • Coefficients from os.urandom fails the seed-replay property, and so does iterating the fold traps as a set.
    • A TypeScript crash on a short set fails 200 of 200 cases. With the bridge's crash classification removed, the same crash passes.
    • A Python ValueError crash fails 200 of 200 cases. With ValueError read as a refusal for every kind, it fails 52.
  • On the first commit's suite. Padmé off by one above 2^40, Python ignoring one ignorable character fewer, an ignorable character accepted inside the armor prefix, a lone trailing base64 character dropped, paper parts joined in arrival order, a part supplied twice accepted, and L not folded to 1.
  • Browser, 1. With the TypeScript dedupe removed and the site rebuilt, the new Chromium test fails and its two neighbours (k shares open, k-1 do not) still pass.

Open question for the owner

Both readers accept a paper part with an ignorable character inside its KMPART2: prefix, where armor and shares refuse the same placement. §7 says a reader ignores IGNORABLE characters "inside armor, share and part bodies" and does not say whether a part's prefix is its body. The property suite holds the two readers to agreement here without fixing an answer. Refusing would be stricter than today, and accepting is harmless because a part carries its own checksum and the set its fingerprint.

Not in this PR

  • The share box's status line still counts lines, so a repeat reads as one more share entered. Counting distinct shares there needs a decode on every keystroke.
  • CHANGELOG.md is left for the release that ships this.

Verification on this tree

  • npm run typecheck, npm run build, and every test:* script in package.json except test:browser (44 scripts) pass on the head commit.
  • Playwright Chromium with --workers=2 on the full suite, after a plain npm run build, gives 360 passed, 5 skipped and 0 failed. Firefox and WebKit are left to CI.

…rs on generated inputs

A set holding k genuine shares was refused when one of them was pasted or
scanned twice, and the generic message sent the person to check strips that
were all correct. §6 now says a share whose decoded record is identical to one
already supplied is that share supplied twice, and is dropped before the other
rules run. Two different shares at one index are still refused, and a repeat
never stands in for a missing share.

- docs/FORMAT-V2-DESIGN.md §6: the rule, ahead of the index rule it narrows.
- reference/keym2.py combine_shares and src/lib/keym-v2-shamir.ts
  combineShares: drop a repeated record, compared in constant time. The
  TypeScript only skips repeats, so its finally still erases every value.
- The key-ceremony rehearsal lists a strip pasted twice once.

reference/properties_test.py (npm run test:properties, and a step in the
conformance workflow) draws armor, shares, share sets, paper parts and Padme
lengths from a printed seed and hands every case to keym2.py and to the
shipping TypeScript in one textverdicts batch. Twelve properties; every verdict
must match the other side's, and where the answer is known it must be that
answer. KEYMAKER_PROPERTY_SEED replays a run. The bridge gains two verdict
kinds, combine and padlen. No new dependency.

Checks added to the self-test, shamir-adversarial.mts, crosstest2.py and
shamir-ui.spec.ts. Each new check was shown to fail with its fix removed.
…asted text, and stop crashes passing as refusals

An independent review of the previous commit found sixteen gaps, fourteen of
them distinct, each confirmed by a reproduction or a negative control. This
closes them.

The rule
- §6 said to drop a repeat "before the rules below", which put the drop ahead
  of the checksum and padding-bit rules. A reader written from the prose would
  have dropped a damaged copy of a share as a repeat. Both readers already
  check each share first; the spec now says so, and that a record differing in
  any one field is a different share.

One line rule (§7)
- keym2.py join split with str.splitlines(), which also breaks at U+000B,
  U+000C, U+000D, U+0085, U+2028 and U+2029, so a part holding one came apart
  in the recovery kit and reassembled in the app. The share box used trim(),
  which keeps U+0085, so a line holding only that character failed a set the
  recovery kit opened. §7 now says a text holding one item per line is split
  at U+000A only, each line stripped of IGNORABLE, blank and "#" lines
  skipped. keym2.py text_lines and keym-text.ts textLines implement it; join,
  --shares-from, splitPaperParts and the share box all use them.

Tests
- The self-test, shamir-adversarial.mts and crosstest2.py refuse a copy of a
  share that differs only in k, or only in set id, and a damaged copy (spare padding bit,
  wrong checksum), each in both orders. crosstest2 compares refusals
  through both combiners rather than "did not open", which a reader returning
  garbage also satisfies.
- The bridge records a TypeError, RangeError, ReferenceError or SyntaxError as
  a crash, which can never match a refusal, and its combine kind reads lines
  with the share box's own parseShareLines. The harness accepts only the
  exceptions keym2.py raises on purpose for each kind.
- properties_test.py draws coefficients from the seeded generator and checks that
  two builds from one seed are identical, so the printed seed replays a run.
  It adds properties for an ignorable character inside or just after a share
  prefix, characters outside the alphabet, non-ASCII letters that upper-case
  into it, every spare padding bit, a consistent extra share above the k
  lowest, a different share or near copy at a supplied index in either order,
  skipped lines between shares, and comment lines and CRLF between parts.
  Every enumerated set is covered whatever KEYMAKER_PROPERTY_CASES is.
- Comments in shared.ts and shamir-ui.spec.ts corrected.

Each new check was shown to fail with the thing it tests removed, and each
control compiled.
…ew's test gaps

A second independent review, of the previous commit, found nine more gaps.
Seven were handed to a refuter and all survived; the other two were confirmed
by negative controls.

The line rule
- §7 said a line ends at U+000A only. A browser's text box hands over CR and
  CRLF as LF, so the app's share and container boxes always ended a line at a
  lone CR, and keym2.py had before the previous commit. Splitting at LF alone
  made a CR-only shares or parts file, which opens in the app, fail in the
  recovery kit. §7 now ends a line at LF, CRLF and a lone CR; U+000B, U+000C,
  U+0085, U+2028 and U+2029 stay ignorable inside a line. text_lines and
  textLines both follow it.

Tests
- The self-test runs join and decrypt --shares-from on CR-only and CRLF files
  whose parts and shares hold U+2028 and U+0085, and asserts the bytes.
- A copy of a share with only its index changed is a different share and is
  interpolated, in the self-test, shamir-adversarial.mts, crosstest2.py and the
  property suite.
- A KMSHARE2 copy whose set id differs only past byte 4, and a KMSHARE2 copy
  with a wrong checksum, are refused in both orders (shamir-adversarial.mts,
  crosstest2.py, properties_test.py).
- crosstest2.py takes the pinned secret as its oracle and guards every call
  into the reference in the new block and the v2 combine check, so a
  regression is reported by name instead of ending the run.
- properties_test.py joins every multi-line case with each of the three line
  ends, inserts characters from outside the alphabet as well as swapping them
  in, adds the Kelvin sign to the fold traps, puts damaged copies among a
  set's near copies, and rebuilds its cases in two child processes with
  different hash seeds so the replay check sees a set's iteration order.

Each new check was shown to fail with the thing it tests removed, and each
control compiled.
docs/vectors/ holds the expected bytes for the text and binary forms of
KEYM v2, v3 and v4 as plain JSON, for anyone writing a third reader. There
are 94 vectors in seven families.

- primitives.json has HKDF-SHA-256 (RFC 5869 A.1 to A.3, prk and okm),
  ChaCha20-Poly1305 (RFC 8439 §2.8.2 and A.5), AES-256-GCM (NIST CAVP) and
  Argon2id, transcribed from pyca's cryptography_vectors 50.0.2 and checked
  against the libraries each reader loads.
- containers.json has whole containers from pinned inputs with kdf_input,
  slot_key, core_header, k_table and slot_table_mac, including v3 §8's and
  v4 §8's vectors, all three ciphers, a key file and an NFD password.
- shares.json, parts.json and text.json have a 3-of-5 split from pinned
  coefficients with both share texts and the set code, the KMPART1 and
  KMPART2 strings, armor in both widths, the IGNORABLE set and padded_len.
- reject.json has 50 inputs a reader must refuse with v2 §6's one generic
  failure, each tagged with its rule. The container rows that test a rule
  are sealed correctly around one violation, so a reader that skips the
  rule opens them.
- accept.json has 21 inputs a reader must open although it may be tempted
  to refuse: a bad slot beside a good one, a stripped slot table (which
  must also be reported), armor full of ignorable characters, shares out of
  order or repeated, and parts out of order.

reference/vectors.py generates the pack from keym2.py and checks it,
including the inputs and outputs printed in v3 §8 and v4 §8 and the files'
exact text. scripts/vectors-test.mts reproduces every vector with the app's
code. The pack is append-only, a wrong vector is superseded by an erratum
that both suites skip, and its count is asserted in both. v2 §7 now states
how armor is wrapped. keym-v2.ts exports the three intermediate derivations
for the suite. npm run test:vectors, a conformance workflow step and the
README list run it.
@404SecNotFound
404SecNotFound merged commit c6d109a into feature/keym2-audio-reader Oct 24, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant