Skip to content

keym2.py reads the audio carrier: a WAV from the Audio tab opens without the app - #274

Merged
404SecNotFound merged 7 commits into
mainfrom
feature/keym2-audio-reader
Oct 24, 2026
Merged

404SecNotFound merged 7 commits into
mainfrom
feature/keym2-audio-reader

Conversation

@404SecNotFound

Copy link
Copy Markdown
Owner

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.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.

After this change keym2.py reads a carrier wherever it reads a container.

python3 keym2.py inspect --in keymaker-audio.wav
python3 keym2.py decrypt --in keymaker-audio.wav --out recovered.txt
python3 keym2.py unpack-audio --in keymaker-audio.wav --out backup.keym

No format change. The container inside a carrier is unchanged, and the carrier layout in docs/FORMAT-AUDIO-STEGO.md is unchanged. What is new is the reading procedure, written down so a third reader can follow it.

In the house order

  1. Design doc. docs/FORMAT-AUDIO-STEGO.md gains "Reading a carrier without the app", a normative procedure in four steps. It pins three things the TypeScript did implicitly.
    • The rounding rule. Samples wider than 16 bits round half up, as JavaScript's Math.round does. Python's round() rounds half to even, and floor(x + 0.5) is wrong for the largest double below one half. Both would read a different low bit.
    • The check order. A WAV is recognised before the §7.2 page sentinel is searched for, because that search is a substring and a WAV can hold the sentinel.
    • Audio that is not a WAV. An MP3, AAC, Ogg, FLAC, M4A, AIFF, WebM or AMR copy is named by its leading bytes before any password is asked for.
  2. Reference. reference/keym2.py implements 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.
  3. TypeScript. No reader change. reference/bridge.mts gains audioembed and audioextract so the conformance suite drives the app's own carrier code. The Audio tab now says that keym2.py from the recovery kit opens the WAV.
  4. Fixtures. scripts/fixtures/keymaker/v3-audio-carrier.wav is appended, with both corpus counts bumped (reference/crosstest2.py and scripts/keymaker-regression.mts). The other 45 entries are byte-identical to main, and rerunning the generator is a no-op.

Tests

Suite Before After
keym2.py selftest 695 783
crosstest2.py (conformance) 553 601
recovery_test.py (executes RECOVERY.md) 194 215
Chromium audio-stego.spec.ts 3 4
  • Conformance compares bytes. The Python and TypeScript writers emit the same WAV, byte for byte, including one filled exactly to capacity. Each reader opens the other's carrier. Both readers get the same container back from 24-bit, 32-bit and float re-saves, plain and WAVE_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.
  • RECOVERY.md is executed. Every sound-file line on the page runs against a carrier the shipping encryptor and the app's writer made, and the recovered bytes are asserted. A WAV with nothing in it, and real MP3, Ogg and M4A headers, are each refused with no password prompt and nothing written.
  • The corpus. The TypeScript regression and verify-transport-test.mts read 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.

  • Self-test, 25 controls.
    • Reader. Round half to even, 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.
    • Review fixes. The page searched before the WAV, compressed audio unrecognised, one Python int per sample again (the memory check), the high byte read for the payload bit, and a converted copy passed through as a container.
    • Test gaps. EXTENSIBLE always read as integer, a data size not cut to the file, float samples truncated, the length bound off by one, any format code accepted at a readable depth, no check that the file holds the fmt body, and no clamp on a finite value.
  • Conformance, 9 controls. Python rounding half to even, Python 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.
  • Recovery, 3 controls. No unpack-audio branch, no unwrap of a carrier, and a converted copy passed through as a container.
  • Browser, 1 control. With the sentence removed from the Audio tab, the new Chromium test fails on it.

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.

  • Ordering. A carrier holding the page sentinel in a metadata chunk was refused as a damaged page.
  • A false sentence. A real MP3, Ogg or M4A copy reached the password prompt and "decryption failed", while the new RECOVERY.md text said otherwise. RECOVERY.md's troubleshooting list and Step 2 now cover a sound file too.
  • Memory. Reading a carrier took about 24 times its size. A 100 MB carrier now takes 0.4 s instead of 12 s.
  • Spec prose. Three sentences overstated what the readers do.
  • Test gaps. Eight places where a plausible reader defect would have passed every suite, or where a regression would have crashed a run instead of naming the failing check. Each is covered now, and every one that pins reader behaviour has a control above.

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

  • The TypeScript Decrypt box still does not take a WAV. The Audio tab's Unpack side does, as before.
  • CHANGELOG.md is 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-recipe and test:dearmor-whitespace all pass.
  • npm run build, then Playwright Chromium with --workers=2 on the full suite. 358 passed, 5 skipped and 1 failed. The failure was async-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

…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.
@404SecNotFound
404SecNotFound merged commit 0c68e6e into main Oct 24, 2026
15 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