Repository navigation
keym2.py reads the audio carrier: a WAV from the Audio tab opens without the app - #274
Merged
Merged
Conversation
…out the app The Audio tab writes a WAV with a KEYM container in its sample low bits, and nothing outside the app could open it, while README.md and the recovery kit dialog said the independent decryptor still applied. With the site gone, keym2.py decrypt on a carrier answered "decryption failed", which RECOVERY.md tells an heir to read as a wrong password. In the house order: - docs/FORMAT-AUDIO-STEGO.md gains "Reading a carrier without the app", a normative reading procedure. It pins the rounding rule for samples wider than 16 bits (half up, as Math.round does; not half to even and not floor(x + 0.5)), says a WAV is recognised before the section 7.2 page sentinel is searched for, and names audio that is not a WAV (MP3, AAC, Ogg, FLAC, M4A, AIFF, WebM, AMR) by its leading bytes. - reference/keym2.py implements it from that section. inspect, decrypt and the other container commands read a carrier directly, and the new unpack-audio writes the container out. Every carrier problem is a usage error naming the file, asked before any password. Extraction reads the samples a block at a time: a 100 MB carrier takes 0.4 s. - reference/bridge.mts gains audioembed and audioextract, so conformance drives the app's own carrier code. The Audio tab says keym2.py opens the WAV. The TypeScript reader is unchanged. - scripts/fixtures/keymaker/v3-audio-carrier.wav is appended, with both corpus counts bumped. The other 45 entries are byte-identical. Tests: keym2.py selftest 695 to 783, crosstest2.py 553 to 601 (writer bytes compared, each reader opens the other's carrier, depth, chunk and float edge cases through both readers, 18 refusals agreed), recovery_test.py 194 to 215 (every sound-file line on RECOVERY.md run against an app-written carrier, and real MP3, Ogg and M4A headers refused before any password), one Chromium test for the Audio tab sentence. Negative controls, each shown to compile and to fail the checks naming it: 25 against the self-test, 9 against conformance, 3 against the recovery suite, 1 in the browser.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
The first item of the review report's plan (Enabler E3, finding 1.1). The app's Audio tab writes a WAV with a KEYM container in its sample low bits, and nothing outside the app could open it.
README.mdand the recovery kit dialog said the independent decryptor still applied. With the site gone,keym2.py decrypton a carrier answered "decryption failed", which RECOVERY.md tells an heir to read as a wrong password.After this change
keym2.pyreads a carrier wherever it reads a container.No format change. The container inside a carrier is unchanged, and the carrier layout in
docs/FORMAT-AUDIO-STEGO.mdis unchanged. What is new is the reading procedure, written down so a third reader can follow it.In the house order
docs/FORMAT-AUDIO-STEGO.mdgains "Reading a carrier without the app", a normative procedure in four steps. It pins three things the TypeScript did implicitly.Math.rounddoes. Python'sround()rounds half to even, andfloor(x + 0.5)is wrong for the largest double below one half. Both would read a different low bit.reference/keym2.pyimplements the procedure from that section alone. Carrier refusals are usage errors that name the file and never print "decryption failed". Extraction reads the samples a block at a time. A 100 MB carrier takes 0.4 s, and the reader's working set stays under half the file's size.reference/bridge.mtsgainsaudioembedandaudioextractso the conformance suite drives the app's own carrier code. The Audio tab now says thatkeym2.pyfrom the recovery kit opens the WAV.scripts/fixtures/keymaker/v3-audio-carrier.wavis appended, with both corpus counts bumped (reference/crosstest2.pyandscripts/keymaker-regression.mts). The other 45 entries are byte-identical tomain, and rerunning the generator is a no-op.Tests
keym2.py selftestcrosstest2.py(conformance)recovery_test.py(executes RECOVERY.md)audio-stego.spec.tsWAVE_FORMAT_EXTENSIBLE, from samples half a 16-bit step off in integer and float, from seven chunk-layout variants, and from a float file whose bits ride on infinities, NaN and overflowing values. Both refuse the same 18 malformed files, and both open the frozen carrier.verify-transport-test.mtsread the carrier fixture through the app's own reader.Negative controls
Each control was applied to a copy, confirmed to compile, and shown to fail the checks that name it without crashing the suite.
floor(x + 0.5), any EXTENSIBLE GUID, no length check,detect()blind to WAV, no unwrap, no magic check, no pad byte, first data chunk wins, signed 8-bit, unguarded non-finite floats, any version accepted, zero channels accepted.floor(x + 0.5), the Python writer's length little-endian, Python accepting any version, the app truncating 24-bit samples, the app writer's byte rate off by one, the corpus losing its carrier entry (which also fails the TypeScript corpus count), Python's first fmt chunk winning, and Python requiring the whole fmt body.unpack-audiobranch, no unwrap of a carrier, and a converted copy passed through as a container.Review
The diff went through four independent reviews (parity, robustness, heir-facing docs, test quality), each finding then checked by a refuting verifier. The parity review ran 490 built WAVs and 1,500 mutations of the frozen carrier through both readers and found no disagreement. The reviews found real problems, all fixed here, each with a check shown to fail without its fix.
Code comments keep the file's existing style. The writing rules were applied to the documents and to user-facing messages.
Not in this PR
CHANGELOG.mdis left for the release that ships this.Verification on this tree
npm run typecheck,test:keym2,test:keymaker,test:verify-transport,test:keym2-dispatch,test:conformance,test:conformance2,test:recovery,test:readme,test:sw-precache,test:release-notes,test:verify-recipeandtest:dearmor-whitespaceall pass.npm run build, then Playwright Chromium with--workers=2on the full suite. 358 passed, 5 skipped and 1 failed. The failure wasasync-guard.spec.ts"switching tabs mid-derivation", one of the two timing flakes CLAUDE.md names, failing at its worker stub before any assertion this change could affect. Its spec passes on a re-run.Generated by Claude Code