From dbd2f585ede1179432c2fbf7849874d06cd32779 Mon Sep 17 00:00:00 2001 From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:08:31 +0000 Subject: [PATCH 1/8] docs(release): bring the roadmap up to date and prepare v2.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roadmap's sequencing table stopped at 4.5. Everything #210 landed is now a row: the end-to-end review fixes, the FORMAT-V2-DESIGN §4.8 password-and-shares slot, the paper vault's second pass (set code, presets, version-25 symbols, self-contained strips, the printout check), photo and live camera scanning, and RECOVERY.md being executed by recovery_test.py. Phase 8 now says what it waits on: the owner, after the next tag. The Cut table's steganography row records why the audio carrier stays: it is documented as a carrier, not steganography, and claims nothing that row cuts. TEN-X-PLAN.md said Bet 1 (camera scanning) was on hold in three places. It shipped in 22c7cdf and 161e919, and the decoder decision that held it was taken as BarcodeDetector where present and pinned jsqr where not. The plan now says so, and says what from the sketch did not ship. Release prep: package.json and the lockfile go to 2.3.0, a minor version because the changelog carries an additive format change (slot type 0x03), and the changelog's Unreleased section becomes the v2.3.0 heading. SECURITY.md's supported-versions table names only "latest release" and needs no change. Gates run on this tree: test:release-notes, test:release-gate and test:release-recipe all pass. The tag itself is the owner's to push: git tag -a v2.3.0 and git push origin v2.3.0. --- CHANGELOG.md | 2 +- docs/ROADMAP.md | 9 +++++-- docs/design/TEN-X-PLAN.md | 52 +++++++++++++++++++++++++++------------ package-lock.json | 4 +-- package.json | 2 +- 5 files changed, 47 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b51cfb8..22a0392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## Keymaker v2.3.0 One additive format change: a new slot type, `0x03` (FORMAT-V2-DESIGN §4.8), which a container carries only when its owner chooses it. Every container diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 05250c7..0fede04 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -997,7 +997,7 @@ Not "later". Cut, with reasons. |---|---| | **Plausible-deniability container** and **duress/decoy password** | The blueprint concedes it: "deniability fails if the UI announces it." This is open-source software with a public spec — an adversary who knows Keymaker exists knows the decoy mode exists, and the presence of the feature is itself evidence. Shipping it invites users to bet their physical safety on a property the design cannot deliver. Worse than absent. | | **PAKE / croc-style transfer** | Needs a rendezvous server. The zero-server property is the product. | -| **Steganography (KEYM-in-PNG)** | The blueprint frames it honestly as "obscurity, not security" — which is the argument for not shipping it. | +| **Steganography (KEYM-in-PNG)** | The blueprint frames it honestly as "obscurity, not security" — which is the argument for not shipping it. The audio carrier that ships under *Audio* (`KAUD1`, `docs/FORMAT-AUDIO-STEGO.md`) is kept for the opposite reason: it is documented as a carrier, not steganography. The magic sits in the first sample LSBs, nothing is hidden from steganalysis, and all confidentiality is the container's, so it makes no claim this row would cut. It is a transport for a container, like paper, and stays only as long as it claims nothing more. | | **TOTP vault** | Scope creep into password-manager territory, against incumbents with sync. Dilutes focus for no differentiation. | | **OPFS vault / File System Access workspace** | Chromium-only, large surface, and it puts plaintext-adjacent state into durable storage — directly against "nothing is stored", which is the claim people choose this tool for. | | **Importers (Hat.sh, age, OpenPGP)** | Low value, ongoing maintenance, and OpenPGP is a key-model mismatch with a huge dependency tree. | @@ -1047,7 +1047,12 @@ Phase 4.2 Paper vault print kit ─ done ─ §7.1 parts · the sheet · Phase 4.3 Self-extracting page ─ done ─ §7.2 subset · the page · three readers, one file Phase 4.4 Passkey / WebAuthn PRF slot ─ done ─ §4.7 · reference · parity · UI Phase 4.5 Inheritance wizard ─ done ─ a plan over shares · paper vault · self-extract · recovery kit -Phase 8 Outreach ────── gated on 6, and on a tag existing +Review End-to-end review fixes ─ done ─ strips scan back in · SVG symbols · secret hygiene · CI signs only reproduced bytes +§4.8 Password-and-shares slot ─ done ─ spec · reference · parity · UI · three fixtures +Paper Paper vault, second pass ─ done ─ set code · presets · version-25 symbols · self-contained strips · printout check +Scanning Photo and live camera ─ done ─ every code in one photo · live camera · TEN-X Bet 1 un-held +Docs RECOVERY.md executed ─ done ─ recovery_test.py runs every bash block · v1, v2, v3 · with shares +Phase 8 Outreach ────── owner-only · drafts in docs/OUTREACH.md · after the next tag ``` **Phase 6 goes before the rest of Phase 4, and that reverses the usual order.** diff --git a/docs/design/TEN-X-PLAN.md b/docs/design/TEN-X-PLAN.md index e2920fc..b1c534f 100644 --- a/docs/design/TEN-X-PLAN.md +++ b/docs/design/TEN-X-PLAN.md @@ -10,14 +10,17 @@ rule it amends. The organising fact: Keymaker *displays* QR codes everywhere and cannot *read* one. The paper vault prints symbols the app cannot scan back. The heir — the person the product exists for — types. The bets follow from taking that person -seriously, even though the bet that fixes it directly (Heir Mode) is on hold. +seriously. The bet that fixes it directly (Heir Mode) was on hold when this +was written and has since shipped; see the end of this file. ## Decisions already made -- **Bet 1 — Heir Mode (in-app camera scanning) is ON HOLD.** It adds a camera - permission surface and, because `BarcodeDetector` is not available in every - engine, almost certainly a QR-decoding dependency. That is a supply-chain - decision the owner has not taken. Do not start it; do not add a decoder. +- **Bet 1 — Heir Mode (in-app camera scanning) was ON HOLD, and has since + shipped** (`22c7cdf`, every QR code in one photo; `161e919`, live camera + scanning). The hold was the decoder: `BarcodeDetector` is not in every + engine, so a QR-decoding dependency was a supply-chain decision the owner + had not taken. It was taken as `BarcodeDetector` where the engine has it and + `jsqr`, pinned, where it does not. - **Bet 6's motion amendment is approved** by the instruction to execute every bet except Bet 1. The house order still applies: amend `DESIGN-SYSTEM.md § Motion` *first*, in the same PR, then write the code. @@ -143,8 +146,8 @@ walks the owner through the *heir's* path. **Scope.** - "Rehearse now" on the issued-shares dialog: choose any K of the N shares - just issued, enter them as an heir would (paste; scanning is Bet 1 and on - hold), run the identical decryption via the verify-only path, and report + just issued, enter them as an heir would (paste; Bet 1 was on hold when + this was written), run the identical decryption via the verify-only path, and report "opened in N s — contents kept hidden". A wrong share fails loudly. - On success, the next print carries the rehearsal stamp filled in (date, which strips). The app stores nothing: the stamp is ink on paper and @@ -252,12 +255,29 @@ the whole plan; say so in the PR body. stored anywhere; nothing new touches the clipboard. - `connect-src 'none'`. The build fails on purpose if it changes. -## Bet 1, for when it is un-held - -Heir Mode: in-app scanning of paper parts and shares inside Decrypt, a -viewfinder that requests the camera only on tap, "2 of 3 shares scanned" -progress, arrival detection when someone lands from the printed URL, and -zero-jargon copy. The decision it needs is the decoder dependency -(`BarcodeDetector` where present; a small, licence-vetted fallback where not). -Everything else in this plan is designed so that Heir Mode drops into flows -that already speak the heir's language. +## Bet 1, shipped + +As sketched when it was on hold: in-app scanning of paper parts and shares +inside Decrypt, a viewfinder that requests the camera only on tap, "2 of 3 +shares scanned" progress, arrival detection when someone lands from the +printed URL, and zero-jargon copy. The decision it needed was the decoder +dependency (`BarcodeDetector` where present; a small, licence-vetted fallback +where not). + +What shipped, in `22c7cdf` and `161e919`. The decoder decision was taken as +`BarcodeDetector` where the engine has it and `jsqr` where it does not. On the +Decrypt tab, *Use the camera* and *Scan strips with the camera* open the +device camera from a button and read strips and container symbols held up one +after another; the dialog says what is in and what is still needed ("1 of the +2 needed. Show the next strip."), leaves out a strip from a different set, +stops by itself once there is enough, hands the codes to the same boxes a +scanned photo fills, and releases the camera when it closes. Frames never +leave the page. A photo of a whole sheet is read in one go, on the Decrypt +tab and in the printout check. Code in `src/components/camera-scan.tsx`, +covered by `tests/browser/camera-scan.spec.ts`; the progress logic is gated +by `scripts/camera-progress-test.mjs`. + +Not shipped from the sketch: arrival detection when someone lands from the +printed URL, and a separate zero-jargon Heir Mode. The scanner drops into the +existing Decrypt flow instead, which is what the rest of this plan prepared +for. diff --git a/package-lock.json b/package-lock.json index 8258f80..a5e7ca5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "keymaker", - "version": "2.2.0", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "keymaker", - "version": "2.2.0", + "version": "2.3.0", "dependencies": { "@fontsource-variable/jetbrains-mono": "5.3.0", "@fontsource-variable/plus-jakarta-sans": "^5.3.0", diff --git a/package.json b/package.json index 2d6e074..b2bbad9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keymaker", - "version": "2.2.0", + "version": "2.3.0", "private": true, "scripts": { "dev": "node scripts/build-crypto-worker.mjs && next dev -p 9002", From 74113bc8f25a22daf4ba8398c335e9b8a2490755 Mon Sep 17 00:00:00 2001 From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:35:10 +0000 Subject: [PATCH 2/8] feat(format): KEYM v4, a padded payload, from spec to parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2 §8 and v3 §7 both deferred the length leak: a container's length determined its plaintext's byte for byte, so a backup's size said whether it held a password, a 12-word seed or a 24-word one. docs/FORMAT-V4-DESIGN.md is the padding scheme on its own, as both deferrals asked for. v4 is a delta on v3 that changes one thing. The payload seals a stream of an 8-byte length prefix, the plaintext and zeros, padded to 256 bytes for anything up to 248 and by Padmé above that, so the overhead stays under 7% and the length reveals only the bucket. Header, container_id, slot table, MAC, chunking, nonces and AAD are v3's, and the version byte sits inside every AAD, so a relabelled container opens in neither direction. The reader verifies the prefix, the bucket and every padding byte, and every failure is the generic one. Written in the house order. The section first; reference/keym2.py from the section alone (encrypt --pad, inspect reporting padded bytes rather than a plaintext length, and a self-test section of 71 checks including the published vector); then the TypeScript core, the app's dispatch, the inspector and the self-extract profile; then seven frozen fixtures, six at the floor and one past it; then crosstest2.py comparing the emitted bytes across both KDFs, three ciphers and every stream boundary, holding both to the vector, and requiring the TypeScript to refuse every stream the reference refuses. RECOVERY.md's commands claim v4 and recovery_test.py runs them against v4 containers the app wrote. Writers MAY emit v4; the default stays v3, because the cost lands on the writer's medium and on paper bytes are symbols. The self-extracting page keeps its v3 container and its writer refuses v4. The app opens a v4 backup today; the switch to write one is separate. Negative controls on the reference: the zero check removed fails the three step-5 checks, the bucket check removed fails the two step-4 checks, and a 512-byte floor fails the pinned table and the vector. --- CHANGELOG.md | 23 + README.md | 25 +- SECURITY.md | 18 +- docs/FORMAT-V2-DESIGN.md | 2 + docs/FORMAT-V3-DESIGN.md | 3 + docs/FORMAT-V4-DESIGN.md | 408 +++++++++++++++++ docs/RECOVERY.md | 19 +- docs/ROADMAP.md | 1 + reference/bridge.mts | 10 +- reference/crosstest2.py | 208 ++++++++- reference/keym2.py | 410 ++++++++++++++++-- reference/recovery_test.py | 11 +- scripts/fixtures/keymaker/fixtures.json | 70 +++ .../keymaker/v4-argon2id-aes256gcm.keym | Bin 0 -> 425 bytes .../v4-argon2id-chacha20poly1305.keym | Bin 0 -> 425 bytes .../keymaker/v4-argon2id-chained.keym | Bin 0 -> 457 bytes .../fixtures/keymaker/v4-padme-aes256gcm.keym | Bin 0 -> 489 bytes .../keymaker/v4-pbkdf2-aes256gcm.keym | Bin 0 -> 425 bytes .../keymaker/v4-pbkdf2-chacha20poly1305.keym | Bin 0 -> 425 bytes .../fixtures/keymaker/v4-pbkdf2-chained.keym | Bin 0 -> 457 bytes scripts/keym2-dispatch.mts | 17 + scripts/keymaker-generate-fixtures.mts | 51 ++- scripts/keymaker-regression.mts | 101 ++++- src/components/container-inspector.tsx | 11 +- src/components/encryptor-tool.tsx | 4 +- src/lib/keym-v2-selfextract.ts | 12 + src/lib/keym-v2.ts | 152 ++++++- src/lib/keymaker-crypto.ts | 7 +- 28 files changed, 1443 insertions(+), 120 deletions(-) create mode 100644 docs/FORMAT-V4-DESIGN.md create mode 100644 scripts/fixtures/keymaker/v4-argon2id-aes256gcm.keym create mode 100644 scripts/fixtures/keymaker/v4-argon2id-chacha20poly1305.keym create mode 100644 scripts/fixtures/keymaker/v4-argon2id-chained.keym create mode 100644 scripts/fixtures/keymaker/v4-padme-aes256gcm.keym create mode 100644 scripts/fixtures/keymaker/v4-pbkdf2-aes256gcm.keym create mode 100644 scripts/fixtures/keymaker/v4-pbkdf2-chacha20poly1305.keym create mode 100644 scripts/fixtures/keymaker/v4-pbkdf2-chained.keym diff --git a/CHANGELOG.md b/CHANGELOG.md index 22a0392..21c454e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## Unreleased + +One format revision, opt-in: **KEYM v4** (`docs/FORMAT-V4-DESIGN.md`), a delta +on v3 whose only change is a padded payload. Nothing the app writes today +changes, and every v2 and v3 container reads exactly as before. + +### Added +- **KEYM v4: the container's length no longer states the plaintext's.** v2 §8 + and v3 §7 both deferred this. The payload now seals a stream of an 8-byte + length prefix, the plaintext and zeros, padded to a bucket: 256 bytes for + anything up to 248, and Padmé above that, so a 12-word seed, a 24-word seed + and a password give the same file. Header, slots, MAC and chunking are + v3's; the version byte sits inside every AAD, so a relabelled container + opens in neither direction. Specified first, implemented in `keym2.py` from + the spec (`encrypt --pad`, and `inspect` says "padded bytes" rather than + claiming a plaintext length), then TypeScript; byte-identical across both, + three ciphers, both KDFs and every stream boundary; a published vector held + in both; seven frozen fixtures; RECOVERY.md's commands claim v4 and + `recovery_test.py` executes them against v4 containers the app wrote. v4 + is written on request only, so the paper vault does not grow by default; + the self-extracting page keeps its v3 container and its writer refuses v4. + The app can already open a v4 backup; a switch to write one is separate. + ## Keymaker v2.3.0 One additive format change: a new slot type, `0x03` (FORMAT-V2-DESIGN §4.8), diff --git a/README.md b/README.md index b5cab18..5b1617b 100644 --- a/README.md +++ b/README.md @@ -466,14 +466,18 @@ a way of not saying anything: - **Someone looking at your screen.** Secret fields blur by default and reveal toggles exist for that reason, but a shoulder, a webcam and a screen-recorder all defeat it. -- **How long your plaintext is.** The container is not padded, and the final - chunk is not padded either, so its length reveals the plaintext's length - *exactly* — overhead is a constant, and one more byte in gives one more byte - out. Not "to within a chunk": byte for byte. If the mere *size* of - what you are protecting is sensitive — which document, which of two possible - answers — that leaks regardless of the cipher. This is stated in - [§8 of the format design](docs/FORMAT-V2-DESIGN.md) and a padding scheme is - deliberately not in v2: it is its own design with its own trade-offs. +- **How long your plaintext is.** A v3 container (what the app writes today) + is not padded, and the final chunk is not padded either, so its length + reveals the plaintext's length *exactly* — overhead is a constant, and one + more byte in gives one more byte out. Not "to within a chunk": byte for + byte. If the mere *size* of what you are protecting is sensitive — which + document, which of two possible answers — that leaks regardless of the + cipher. This is stated in [§8 of the format design](docs/FORMAT-V2-DESIGN.md). + [KEYM v4](docs/FORMAT-V4-DESIGN.md) pads the payload so the length says only + which bucket the plaintext is in, and everything up to 248 bytes is the same + size; `keym2.py encrypt --pad` writes it, and it is opt-in because it costs + bytes, and on paper bytes are symbols. Padding is not deniability: the file + is still plainly a backup. - **A weak password.** Argon2id makes guessing expensive; it cannot make a guessable password unguessable. - **Forgetting the password.** There is no reset, no recovery email and nobody @@ -657,8 +661,8 @@ process list to every other account on the machine while the KDF ran. Prefer the interactive prompt, or `--shares-from` with a file only you can read. **Two scripts, because there are two container generations.** The app writes -**KEYM v3**, and `keym2.py` reads v3 and v2 alike — one command, whichever year -your backup is from. Backups older than that are **v1** and need `keym.py`; +**KEYM v3**, and `keym2.py` reads v4, v3 and v2 alike — one command, whichever +year your backup is from. Backups older than that are **v1** and need `keym.py`; neither script reads the other's format, and the one that refuses is telling you which you have. [docs/RECOVERY.md](docs/RECOVERY.md) is the printable procedure, and @@ -732,6 +736,7 @@ every KDF and cipher combination, and takes a few minutes. | [`docs/FORMAT.md`](docs/FORMAT.md) | Normative KEYM v1 byte-level specification | | [`docs/FORMAT-V2-DESIGN.md`](docs/FORMAT-V2-DESIGN.md) | Normative KEYM v2 specification — still read, no longer written | | [`docs/FORMAT-V3-DESIGN.md`](docs/FORMAT-V3-DESIGN.md) | Normative KEYM v3 specification, a delta on v2 — the format the app writes today | +| [`docs/FORMAT-V4-DESIGN.md`](docs/FORMAT-V4-DESIGN.md) | Normative KEYM v4 specification, a delta on v3 — a padded payload, written on request | | [`docs/FORMAT-AUDIO-STEGO.md`](docs/FORMAT-AUDIO-STEGO.md) | The KAUD1 encrypted audio carrier layout: packing a container into audio, with a diagram | | [`docs/ROADMAP.md`](docs/ROADMAP.md) | Phased plan: what ships next, and what was cut | | [`docs/VERIFYING.md`](docs/VERIFYING.md) | Checking that the site you loaded is the code you read | diff --git a/SECURITY.md b/SECURITY.md index ad11f8e..991c765 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -60,13 +60,17 @@ Out of scope / known limitations: - **JavaScript memory hygiene is best-effort.** `secureErase` zero-fills buffers, but the JS engine/GC may retain copies of secrets. WebCrypto keys are non-extractable where the API allows. -- **Deniability / traffic analysis.** Containers are not padded, so their - length reveals the plaintext's length exactly: the overhead is fixed for a - given format and settings, so container length determines plaintext length - byte for byte (FORMAT-V2-DESIGN §8). If the *size* of what is being protected is - itself sensitive, the cipher does not help. `docs/FORMAT-V2-DESIGN.md` §8 - records why a padding scheme was deliberately left out of v2 rather than - bundled into it. +- **Deniability / traffic analysis.** v1, v2 and v3 containers are not padded, + so their length reveals the plaintext's length exactly: the overhead is fixed + for a given format and settings, so container length determines plaintext + length byte for byte (FORMAT-V2-DESIGN §8). A **v4** container pads its + payload (`docs/FORMAT-V4-DESIGN.md`): its length reveals only which bucket + the plaintext falls in, and every plaintext up to 248 bytes gives the same + length. v4 is written on request, not by default, and padding is not + deniability: the container is still plainly a Keymaker container, its cipher + and slots are still readable, and a plaintext just over a bucket boundary is + still on the far side of it. If the *size* of what is being protected is + itself sensitive and the backup is not v4, the cipher does not help. - **Password strength.** Weak passwords undermine any KDF. Argon2id (memory-hard) is the default recommendation, but cannot fix a weak password. diff --git a/docs/FORMAT-V2-DESIGN.md b/docs/FORMAT-V2-DESIGN.md index af6a939..fe3781f 100644 --- a/docs/FORMAT-V2-DESIGN.md +++ b/docs/FORMAT-V2-DESIGN.md @@ -1990,6 +1990,8 @@ older reader, and `--capacity` still overrides the size. standing between a mistake and a permanent one. That decision is now settled rather than open — the format is frozen (§9), so padding is a v3 question. README.md states the leak plainly rather than leaving it to be discovered. + *Answered by [FORMAT-V4-DESIGN.md](FORMAT-V4-DESIGN.md): v4 pads the payload, + on request. v2 and v3 containers are exactly as this bullet says.* - **The slot table is authenticated slot-by-slot, not as a whole.** §5.3 keeps `slot_count` out of every AAD so a table stays editable by someone holding one diff --git a/docs/FORMAT-V3-DESIGN.md b/docs/FORMAT-V3-DESIGN.md index 67d55aa..e68ced8 100644 --- a/docs/FORMAT-V3-DESIGN.md +++ b/docs/FORMAT-V3-DESIGN.md @@ -285,6 +285,9 @@ fix a gap v2 never claimed to close. it with an authentication fix would make both harder to review; that is the same reasoning v2 used to defer it in the first place. It stays open, and bumping the version here does not close it. + *Closed by [FORMAT-V4-DESIGN.md](FORMAT-V4-DESIGN.md), a delta on this + document that changes the payload and nothing else. A v3 container's length + still says exactly what this bullet says.* --- diff --git a/docs/FORMAT-V4-DESIGN.md b/docs/FORMAT-V4-DESIGN.md new file mode 100644 index 0000000..60585ab --- /dev/null +++ b/docs/FORMAT-V4-DESIGN.md @@ -0,0 +1,408 @@ +# KEYM v4 — Container Format Specification + +**Status: design.** This document specifies KEYM v4 as a **delta on v3**. +Everything in [FORMAT-V3-DESIGN.md](FORMAT-V3-DESIGN.md), and through it +everything in [FORMAT-V2-DESIGN.md](FORMAT-V2-DESIGN.md), applies unchanged +unless a section here says otherwise. Where this document and v3 disagree +about v4, this document wins; where this document is silent, v3 is normative. + +v4 changes one thing: **the payload is padded**, so that the length of a +container no longer states the length of what is inside it. The header, the +slot table and its MAC, the key derivation, the chunking, the nonces, the +bounds and the text armor are all v3's, byte for byte. + +--- + +## 1. Why v4 exists + +v2 §8 records the leak and defers it: + +> Container length reveals plaintext length *exactly*: neither the payload nor +> the final chunk is padded, so overhead is a constant and container length +> determines plaintext length byte for byte. + +v3 §7 defers it again, for the same reason both times: a padding scheme is its +own design, and bundling it with the change each of those revisions was +actually making would have made both harder to review. This document is the +padding scheme on its own, which is what both deferrals asked for. + +### 1.1 What the leak costs + +For a container with `s` slots and a plaintext of `L` bytes, v3's length is + +``` +57 + s × slot_len(cipher_id) + L + 16 × ceil(L / 1048576) (32 × for chained) +``` + +Every term but `L` is readable from the header without a secret, so anyone +holding the file reads `L` off it. That is a small thing for a photograph and +a large thing for the inputs this tool exists for: + +| what is inside | `L` | what its length says | +|---|---|---| +| a 12-word seed phrase | about 75 bytes | "a 12-word seed phrase" | +| a 24-word seed phrase | about 150 bytes | "a 24-word seed phrase" | +| a 24-word seed phrase with a passphrase line | about 170 bytes | "with a passphrase" | +| a password | 16 to 40 bytes | "a password" | + +A backup whose size announces what kind of secret it protects has told an +attacker which file to spend the KDF budget on. The cipher hides the words; +the length hands over the category. + +### 1.2 What padding can and cannot do + +Padding hides `L` to within a **bucket**: a reader without the secret learns +which bucket `L` falls in and nothing finer. It cannot hide that a backup +exists, which cipher it uses, how many ways it can be unlocked, or how large +the container itself is. §5 states the bound and the residual leak precisely, +because a padding scheme described as "hides the size" is the same class of +overstatement as the ones the roadmap's *Honest framing* exists to prevent. + +--- + +## 2. What does not change + +- The header layout (v3 §3): magic, `cipher_id`, `flags`, the reserved byte, + `container_id`, `slot_count`, `slot_table_mac`, the slot table. Only the + version byte differs. +- `container_id` (v3 §4) and `slot_table_mac` (v3 §5), including §5.2's + report-do-not-refuse rule and §5.3's enrolment rule. +- Slot layout, slot types, the 48-byte prefix, key derivation, the KDF + parameter block and every bound (v2 §4, §6). +- The chunked payload, nonce construction, `final_flag` and both AADs (v2 §5). + v4 chunks a *padded stream* rather than the plaintext; the chunking itself is + untouched. +- Text armor, paper parts, the share encodings (v2 §7). +- The rejection rule: every failure reports as one generic decryption error + (v2 §6), with v3 §5.2 as the single exception. Nothing in this document adds + a second. +- Slot editing. Adding, removing and re-wrapping a slot copy the payload + through untouched (v2 §4, v3 §5.3), and padding lives inside the payload, so + every slot operation works on a v4 container exactly as on a v3 one, and + leaves it v4. + +**The version byte is inside both AADs.** Byte 4 is part of the core header, +which every chunk and every slot wrap authenticates. So a v4 container cannot +be relabelled as v3 to make a v3 reader hand back the padded stream as if it +were the plaintext: every chunk fails to open under the altered header. That +property is inherited, not new, and it is what makes a version byte the right +place to put a change to what the plaintext bytes *mean*. + +--- + +## 3. Header + +v3 §3's layout with `version = 0x04`: + +``` ++--------+------+------------------------------------------------+ +| Offset | Size | Field | ++--------+------+------------------------------------------------+ +| 0 | 4 | magic: ASCII "KEYM" (4B 45 59 4D) | +| 4 | 1 | version: 0x04 | +| 5 | 1 | cipher_id | +| 6 | 1 | flags (v2 §3.3, entirely reserved) | +| 7 | 1 | reserved, MUST be zero | +| 8 | 16 | container_id (v3 §4) | ++========+======+================================================+ + core header = bytes [0, 24) ++--------+------+------------------------------------------------+ +| 24 | 1 | slot_count: 1 .. 8 | +| 25 | 32 | slot_table_mac (v3 §5) | +| 57 | ... | slot table: slot_count × slot_len | ++--------+------+------------------------------------------------+ +| ... | ... | payload: chunk sequence over the padded stream | ++--------+------+------------------------------------------------+ +``` + +``` +payload_offset = 57 + slot_count × slot_len(cipher_id) +``` + +A reader dispatches on bytes 0–4 as before. A v4 reader MUST open v1, v2, v3 +and v4. A v3 reader meeting `0x04` rejects it as an unknown version, which v2 +§3's dispatch and v3 §6 already require of it. + +A flag bit was the other place this could have gone, and v2 §3.3 rules it out +in so many words: "new meanings get a version byte, not a reclaimed reserved +bit." + +--- + +## 4. The padded payload + +### 4.1 Construction + +The writer seals a **stream** rather than the plaintext: + +``` +L = len(plaintext) a uint64 +n = 8 + L +P = padded_len(n) §4.2 +stream = uint64_be(L) ‖ plaintext ‖ zero bytes × (P − n) +``` + +The stream is then split into chunks and sealed exactly as v2 §5 describes +for the plaintext: chunks of 1,048,576 bytes except the last, `nonce_i = +uint88_be(i) ‖ final_flag`, `payload_aad` = the core header, and the chunk +count `max(1, ceil(P / 1048576))`. Since `P ≥ 256`, the first chunk always +holds the whole 8-byte prefix. + +The prefix is inside the AEAD. Nothing about `L` is readable without the key, +and nothing about it can be altered without failing authentication. + +### 4.2 `padded_len` + +``` +padded_len(n): n = 8 + L, so n ≥ 8 + if n ≤ 256: + return 256 the floor, §4.3 + E = floor(log2(n)) + S = floor(log2(E)) + 1 + z = E − S + return n rounded up to a multiple of 2^z +``` + +Above the floor this is **Padmé** (Nikitin, Barman, Lueks, Underwood, Hubaux +and Ford, *Reducing Metadata Leakage from Encrypted Files and Communication +with PURBs*, PETS 2019). Within each doubling `[2^E, 2^(E+1))` it admits only +`2^S` distinct padded lengths, so the granularity grows with the size: two +bytes at 16, sixteen at 256, 2 KiB at 64 KiB, 2 MiB at 64 MiB. The overhead +stays bounded (§5) and no parameter is chosen by the writer, so two writers +cannot disagree about it. + +Worked values, which an implementation SHOULD pin in its own tests: + +| `n` | `E` | `S` | `2^z` | `padded_len(n)` | +|---|---|---|---|---| +| 8 | — | — | — | 256 (floor) | +| 256 | — | — | — | 256 (floor) | +| 257 | 8 | 4 | 16 | 272 | +| 300 | 8 | 4 | 16 | 304 | +| 1,000 | 9 | 4 | 32 | 1,024 | +| 1,025 | 10 | 4 | 64 | 1,088 | +| 65,544 | 16 | 5 | 2,048 | 67,584 | +| 1,048,584 | 20 | 5 | 32,768 | 1,081,344 | +| 100,000,008 | 26 | 5 | 2,097,152 | 100,663,296 | + +`padded_len` is a function of the format, like the chunk size. An +implementation MUST NOT vary it, and a reader MUST verify it (§4.4): a +container padded by any other rule is not a v4 container. + +### 4.3 The floor + +Padmé's granularity at the small end is two to sixteen bytes, which is exact +enough to keep every line of §1.1's table distinguishable. Below a few hundred +bytes the thing worth hiding is not the size but the *kind* of secret, and only +a floor hides that. 256 bytes is the smallest power of two that covers every +row of that table with room for a passphrase line and a note. + +It is also the largest floor that keeps a padded backup on one printed +symbol. A KMPART2 symbol at the paper vault's size carries 702 container bytes +(v2 §7.3); a v4 container at the floor is `57 + 96 s + 256 + 16 = 329 + 96 s` +bytes for `s` AES or ChaCha slots, so up to three slots fit. A floor of 512 +would have needed two symbols from the first slot. + +The floor costs at most 248 bytes, once, and nothing at all above it: for +`n > 256`, `padded_len(n) ≥ n > 256`, so the two branches of §4.2 meet without +a step. + +### 4.4 Reading + +A reader opens every chunk exactly as v2 §5 requires, including the final-flag +rule, and holds the whole stream. Then, with `P′` the stream's length: + +1. reject if `P′ < 8`; +2. read `L = uint64_be(stream[0, 8))`; +3. reject if `L > P′ − 8`; +4. reject if `P′ ≠ padded_len(8 + L)`; +5. reject if any byte of `stream[8 + L, P′)` is non-zero; +6. return `stream[8, 8 + L)`, and nothing else. + +Every rejection is v2 §6's generic decryption failure. Step 3 comes before +step 4 so that `8 + L` is computed only when it fits; an implementation whose +integers are narrower than 64 bits must not be led into an overflow by a +prefix an attacker cannot forge but a broken writer could emit. + +A reader **MUST NOT** return any padding byte, and **MUST NOT** report success +before steps 1–5 have all passed on the complete stream. v2 §5.5 already +forbids presenting partially decrypted output as a result; padding adds a +second reason, since the last chunk is where the padding is and the checks on +it are the last to run. + +**Streaming is still possible.** Chunk 0 carries `L`, so a reader may emit +plaintext as chunks verify and stop emitting at byte `8 + L`, checking each +later chunk's bytes for zero as it goes, provided the result is not committed +until the final chunk has verified and every check above has passed. That is +v2 §5.5's temporary-destination rule with one more thing to check at the end. + +### 4.5 Why this shape + +**A prefix, not a trailer or a marker.** A length at the *end* of the stream +would force a streaming reader to hold back every byte until the last chunk, +since it could not know which bytes were padding. A marker byte before the +zeros (`0x80 00 00 …`) would force it to hold back every trailing run of +zeros in case the marker was still to come. A prefix in chunk 0 lets a reader +know the answer before the second chunk arrives, and it costs eight bytes. + +**The cost of the prefix is on the writer.** It must know `L` before sealing +chunk 0, so a writer that reads its input from an unbounded stream cannot +write v4. Every writer in this project already holds the plaintext, or a +`File` whose size is known; the constraint is stated so that a future +streaming writer finds it here rather than in a bug. + +**Zeros, verified.** The padding's value carries no security: the AEAD +authenticates every byte of the stream, so an attacker cannot change a +padding byte any more than a plaintext byte. The reader checks them anyway, +and the reason is the one that runs through this whole project: the two +implementations must agree on every input. A writer that emitted anything but +zeros would be a non-conforming writer, and an unchecked reader would open its +output while a checking reader refused it. A rule with an exception is a rule +two implementations can disagree about; "every padding byte is zero, and the +reader says so" has none. + +--- + +## 5. What it hides, and what it does not + +**Hidden.** `L`, to within a bucket. A reader without the secret learns +`padded_len(8 + L)` and nothing finer. Above the floor that is `E` (the order +of magnitude of `L`) and `S` further bits of position within the doubling, +about `log2(log2 L) + 1` bits — in place of every bit of `L`. At or below the +floor it learns only that `L ≤ 248`. + +**Cost.** The floor adds at most 248 bytes, once. Above it the overhead is +below 2^−S of the size: under 7% at any size (the worst case above the floor +is 2,047 bytes on 32,769), under 4% from 64 KiB, under 2% from 4 GiB. A +100 MB file pads by about 660 KB. + +**Not hidden.** That a backup exists and is a KEYM container; that it is +padded (the version byte is in the clear, as every version byte is); the +cipher; the number of slots, their types and their KDF costs; `container_id`; +the container's own length; and, for a plaintext just above a bucket +boundary, which side of it the plaintext is on. A container is exactly as +identifiable as a v3 container; what changed is what its length says. + +**Not hidden either.** The identity of the plaintext among candidates that +fall in the same bucket. Padding is not deniability, and a size that "could +be anything from 1 to 248 bytes" is still a size. + +**Not padded.** v1, v2 and v3 containers. Their lengths still say exactly what +v2 §8 records, and nothing rewrites one (§6). + +--- + +## 6. Migration + +- A v4 reader MUST open v1, v2, v3 and v4 containers. Version dispatch on + byte 4 is unchanged. +- A v3 reader encountering version `0x04` MUST reject it as an unknown + version, which its existing bounds check already does. +- Writers **MAY** emit v4. This document does not say SHOULD, and does not + move any default, because the cost falls on the writer's medium: a padded + backup takes more bytes, and on paper more bytes are more symbols. Whether + that is worth the bucket is a decision to put in front of the person whose + paper it is, stated where the choice is made, and the reference CLI takes + it as `--pad`. A writer that emits v4 MUST do so under the rules of §4; a + writer that emits v3 is unchanged. +- There is no in-place upgrade. Moving an existing backup to v4 means + decrypting and re-encrypting it, which is a user's decision and needs their + secret. +- Slot enrolment, revocation and re-wrapping on a v4 container follow v3 §5.3 + and leave the version, the `container_id` and the payload untouched. +- **The §7.2 subset excludes v4.** A writer of a self-extracting page MUST NOT + emit a v4 container into it. The page carries its own container, written + for it and separate from the user's backup (v2 §7.2, "which is why the page + carries its own container"), so nothing is lost: the user's backup can be + v4 while the page's container stays v3. Widening the subset would mean + every page written from now on carries a reader for a format one more + revision away from the one it was born with; the subset stays what it is + until there is a reason to move it that is not "because we can". + +--- + +## 7. What this does not fix + +- **Metadata confidentiality.** v3 §7's list stands in full. v4 authenticates + the same header v3 does and hides none of it. +- **The bucket.** §5's residual leak is a property of the scheme, not a bug in + it. A scheme that leaked nothing would have to pad every container to the + largest size it will ever hold. +- **Older containers.** A v1, v2 or v3 container's length still states its + plaintext's length exactly. +- **What a reader with the secret learns.** `L` is in chunk 0. Padding hides + the length from someone who cannot open the container, and from no one + else. +- **A writer that does not know its input's length** cannot write v4 (§4.5). + +--- + +## 8. Test vector + +Produced by `reference/keym2.py`. The TypeScript core must reproduce this +container **byte for byte** from the same inputs, exactly as v3 §8 requires for +its vector: a container that differs only in its payload has a padding bug, and +one that differs earlier has a header bug, and the two are diagnosed +differently. + +Test-only credentials. Never use any of these values for real data: a pinned +salt and a pinned master key reuse every (key, nonce) pair in the container. + +**Inputs** — v3 §8's, with the version and the plaintext's label changed: + +| field | value | +|---|---| +| password | `correct horse battery staple — test only` (UTF-8, NFC) | +| plaintext | `Keymaker fixture - KEYM v4 / argon2id / aes-256-gcm` (51 bytes, ASCII) | +| `cipher_id` | `0x00` (AES-256-GCM) | +| KDF | Argon2id, `t=3`, `m=65536` KiB, `p=4` | +| slot salt | `00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff` | +| master key | `404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f` | +| `container_id` | `0123456789abcdef0123456789abcdef` | +| slots | one, `slot_type = 0x00` | + +**Intermediates** + +``` +L 51 +n 59 +P 256 (the floor) +stream[0, 8) 0000000000000033 +stream[8, 59) the plaintext +stream[59, 256) 197 zero bytes +core_header 4b45594d040000000123456789abcdef0123456789abcdef +K_table 351351be1b09b978ae98feede32b49f98af1acd365aeda6943178985395e7cf6 +slot_table_mac 2b57f2b782ba97fe97cf4f18455c21e27d9286d749ce5aa5ff9c76a1fed44c11 +``` + +`core_header` differs from v3 §8's in one byte, the version. `K_table` is +v3 §8's exactly, since it depends on the master key alone; `slot_table_mac` +and every byte after byte 57 differ, because the core header is inside the +MAC's message, the slot wrap's AAD and every chunk's AAD. An implementation +that reproduces `K_table` but not the MAC has the header wrong; one that +reproduces the MAC and the slot but not the payload has the padding wrong. + +**Container** — 425 bytes: 24 core header, 1 `slot_count`, 32 MAC, 96 slot, +272 payload (256 stream + 16 tag). + +``` +4b45594d040000000123456789abcdef0123456789abcdef012b57f2b782ba97 +fe97cf4f18455c21e27d9286d749ce5aa5ff9c76a1fed44c1100010000000000 +0000112233445566778899aabbccddeeff00112233445566778899aabbccddee +ff00030001000004008e7b9e001250f449d30882f58e5d93c85bb8a8e6459ea5 +8ebba4ecc919f86d5c31a2e4a961d80c1b34957112c55e92ddb3ae733192a6e4 +725beff0a4ed88081ff33e43e1706716c145d27e0a55965b2bb169dd9f809630 +121604f971fec25aa62f06aac5737bd0c139e7a07d41a6a48edcf2868f64b1f9 +e31146e75116808c42530044fa45aa06db4459dda1d22ef646d4790d3769ba81 +26b022115b28841829f1f40096ebc4acf10b9dad99fada24566f1b6ba0604336 +5d5a508c80572e3c1486888e3f4cafcbe3a225b1bafe3fd211dcecfd635f2b32 +c5de3ffe575d3c696e8cb27777cb148bdf285d75e8464065939853297de066e2 +dbe384123dda057182fb2656f5c735cbc975e8fd07b1d019807174fc25b084fd +3ad0326314c796dde9346618e2ec7e29768b432206d2a99d212e17be19e8fb44 +420bacf2a03e69291d +``` + +Both implementations are held to these bytes by `reference/crosstest2.py`, and +the frozen corpus under `scripts/fixtures/keymaker/` carries v4 vectors beside +v2's and v3's. `scripts/keymaker-generate-fixtures.mts` writes them and skips +any that already exist, so the corpus only ever grows. diff --git a/docs/RECOVERY.md b/docs/RECOVERY.md index 80744b8..33eb90a 100644 --- a/docs/RECOVERY.md +++ b/docs/RECOVERY.md @@ -38,13 +38,14 @@ its dependencies, or any part of the JavaScript. **There are two scripts because there are two generations of the format.** `keym2.py` reads everything Keymaker writes today: **v3**, the current default, -and **v2** before it. `keym.py` reads **v1**, which came first. All three stay +**v4**, which it writes when asked to hide the length of what is inside, and +**v2** before them. `keym.py` reads **v1**, which came first. All four stay readable forever; neither script reads the other's generation, and step 2 tells you which one you have. Keep both — an old backup needs the old script, and *old backups are the ones most likely to need this page.* -You do not need to work out whether you have v2 or v3. `keym2.py` reads both and -says which it found; the distinction matters to the format, not to you. +You do not need to work out whether you have v2, v3 or v4. `keym2.py` reads all +three and says which it found; the distinction matters to the format, not to you. **If you enrolled a passkey, it will not help you here.** A passkey is quick access, not a backup. It only answers at the website it was created on, so if @@ -95,12 +96,12 @@ If your backup is **text**, the first six characters say it outright: | Starts with | Version | Script | |---|---|---| -| `keym2:` | v3 or v2 | `keym2.py` | +| `keym2:` | v4, v3 or v2 | `keym2.py` | | `KEYM1:` | v1 | `keym.py` | -**`keym2:` covers both v3 and v2 on purpose.** The prefix names the generation, +**`keym2:` covers v4, v3 and v2 on purpose.** The prefix names the generation, not the revision, so a backup written today and one written before v3 existed -look identical here — and both open with the same script. If you want to know +look identical here — and all of them open with the same script. If you want to know which one you are holding, `keym2.py inspect` says so. Note the case. `keym2:` and `KEYM1:` differ by one letter and it is deliberate — @@ -110,7 +111,7 @@ If your backup is a **file**, ask each script in turn. Neither needs a password and neither can damage the file: ```bash -python3 keym2.py inspect --in backup.keym # v3 or v2 +python3 keym2.py inspect --in backup.keym # v4, v3 or v2 python3 keym.py inspect --in backup.keym # v1 ``` @@ -160,7 +161,7 @@ and whether a key file is required, before doing anything else. Those values are authenticated: if decryption later succeeds, they were not tampered with. Until then, treat them as claims the file makes about itself. -**About `slots`.** A v3 or v2 container can hold up to eight ways of unlocking the +**About `slots`.** A v4, v3 or v2 container can hold up to eight ways of unlocking the same data, and any one of them opens it. Containers written by the app have one for the password, plus one for recovery shares or a passkey if either was set up when it was made. If yours says more than one, any of the secrets listed will @@ -169,7 +170,7 @@ work, and you only need one of them. ## Step 4 — Decrypt ```bash -python3 keym2.py decrypt --in backup.keym --out recovered.txt # v3 or v2 +python3 keym2.py decrypt --in backup.keym --out recovered.txt # v4, v3 or v2 python3 keym.py decrypt --in backup.keym --out recovered.txt # v1 ``` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0fede04..91f1218 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1052,6 +1052,7 @@ Review End-to-end review fixes ─ done ─ strips scan back in · SVG Paper Paper vault, second pass ─ done ─ set code · presets · version-25 symbols · self-contained strips · printout check Scanning Photo and live camera ─ done ─ every code in one photo · live camera · TEN-X Bet 1 un-held Docs RECOVERY.md executed ─ done ─ recovery_test.py runs every bash block · v1, v2, v3 · with shares +v4 Padded payload ─ done ─ spec · reference · parity · fixtures · opt-in, the app's switch is next Phase 8 Outreach ────── owner-only · drafts in docs/OUTREACH.md · after the next tag ``` diff --git a/reference/bridge.mts b/reference/bridge.mts index 70596f7..90670f7 100644 --- a/reference/bridge.mts +++ b/reference/bridge.mts @@ -60,7 +60,7 @@ import { decryptKeym2, KEYM2_VERSION_V2, KEYM2_VERSION_V3, - + KEYM2_VERSION_V4, addShamirSlotKeym2, addPasskeySlotKeym2, derivePrfSalt, @@ -179,7 +179,9 @@ try { { kdf, cipher: CIPHERS[flag("cipher") ?? "aes"]! }, Uint8Array.from(Buffer.from(flag("salt")!, "hex")), Uint8Array.from(Buffer.from(flag("master-key")!, "hex")), - containerId === undefined ? KEYM2_VERSION_V2 : KEYM2_VERSION_V3, + // `--version 4` writes v4 (padded); with a container id and no version + // flag it is v3, and without either it is v2, as it always was. + containerId === undefined ? KEYM2_VERSION_V2 : flag("version") === "4" ? KEYM2_VERSION_V4 : KEYM2_VERSION_V3, containerId === undefined ? new Uint8Array(0) : Uint8Array.from(Buffer.from(containerId, "hex")) ); writeFileSync(outFile, Buffer.from(out)); @@ -212,7 +214,9 @@ try { { kdf, cipher: CIPHERS[flag("cipher") ?? "aes"]! }, Number(flag("threshold")), Number(flag("shares")), - containerId === undefined ? KEYM2_VERSION_V2 : KEYM2_VERSION_V3, + // `--version 4` writes v4 (padded); with a container id and no version + // flag it is v3, and without either it is v2, as it always was. + containerId === undefined ? KEYM2_VERSION_V2 : flag("version") === "4" ? KEYM2_VERSION_V4 : KEYM2_VERSION_V3, { salt: hex("salt"), masterKey: hex("master-key"), diff --git a/reference/crosstest2.py b/reference/crosstest2.py index 8783903..42e7df9 100644 --- a/reference/crosstest2.py +++ b/reference/crosstest2.py @@ -201,13 +201,14 @@ def main() -> int: # corpus to v1 because the v1 reference cannot read a v2 container. The # v2 vectors would otherwise be written by the generator and checked by # nothing but the TypeScript that produced them. - print("\nFrozen v2 and v3 fixtures decrypted by the Python reference:") + print("\nFrozen v2, v3 and v4 fixtures decrypted by the Python reference:") corpus = ROOT / "scripts" / "fixtures" / "keymaker" meta = json.loads((corpus / "fixtures.json").read_text()) fx_pw, fx_kf = meta["password"], bytes.fromhex(meta["keyFileHex"]) v2_fixtures = [f for f in meta["fixtures"] if f.get("version") == 2] v3_fixtures = [f for f in meta["fixtures"] if f.get("version") == 3] - for f in v2_fixtures + v3_fixtures: + v4_fixtures = [f for f in meta["fixtures"] if f.get("version") == 4] + for f in v2_fixtures + v3_fixtures + v4_fixtures: blob = (corpus / f["file"]).read_bytes() # §7.2. A page is a container wearing an HTML document; unwrap it and # it takes exactly the same checks as every other frozen vector. @@ -304,7 +305,7 @@ def main() -> int: except keym2.KeymError: check(f"{f['name']}: k-1 js-written shares still refused", True) - modern = v2_fixtures + v3_fixtures + modern = v2_fixtures + v3_fixtures + v4_fixtures shamir_fixtures = [f for f in modern if "shamir" in f] passkey_fixtures = [f for f in modern if "passkey" in f] stripped_fixtures = [f for f in modern if "strippedPasskey" in f] @@ -312,14 +313,15 @@ def main() -> int: # Counted rather than assumed, because the corpus is append-only and a # fixture that silently stopped being listed would otherwise just stop # being tested. Update deliberately when the corpus grows. - check("v2+v3 corpus has all twenty-nine vectors: six share sets, six " - "passkeys, three password-and-shares, one page, one stripped table", - len(v2_fixtures) == 13 and len(v3_fixtures) == 16 + check("v2+v3+v4 corpus has all thirty-six vectors: six share sets, six " + "passkeys, three password-and-shares, one page, one stripped table, " + "seven padded", + len(v2_fixtures) == 13 and len(v3_fixtures) == 16 and len(v4_fixtures) == 7 and len(shamir_fixtures) == 6 and len(passkey_fixtures) == 6 and len(both_fixtures) == 3 and len([f for f in v2_fixtures if f.get("selfextract")]) == 1 and len(stripped_fixtures) == 1, - f"found {len(v2_fixtures)} v2, {len(v3_fixtures)} v3, " + f"found {len(v2_fixtures)} v2, {len(v3_fixtures)} v3, {len(v4_fixtures)} v4, " f"{len(shamir_fixtures)} shamir, {len(passkey_fixtures)} passkey, " f"{len(both_fixtures)} both, {len(stripped_fixtures)} stripped") @@ -1480,6 +1482,196 @@ def js_opens_passkey_container_with_password(blob: bytes) -> bool: container_id=VEC_CID, version=keym2.VERSION_V3) check("the reference reproduces the §8 vector", py_vec.hex() == VEC_HEX) + # --------------------------------------------------------------- + # KEYM v4 (docs/FORMAT-V4-DESIGN.md) + # --------------------------------------------------------------- + # + # Byte equality is the whole test here. Two implementations that pad + # to different buckets, or put the prefix in a different place, or + # fill with something other than zeros, decode their own output + # perfectly and disagree only about the bytes — the same shape as + # §5.1's chunk-boundary defect, which is why the bytes are compared + # first and the round-trips second. + print("\nKEYM v4 byte equality (pinned salt, master key and container_id):") + for kdf in ("pbkdf2", "argon2id"): + for cipher in ("aes", "chacha", "chained"): + label = f"v4: {kdf} + {cipher}" + plaintext = b"v4 conformance payload \xf0\x9f\x97\x9d " * 40 + src = tmp / "pt4.bin" + src.write_bytes(plaintext) + js_out = tmp / "js4.keym2" + try: + bridge("encrypt2", "--password", PASSWORD, "--in", str(src), + "--out", str(js_out), "--cipher", cipher, "--salt", SALT.hex(), + "--master-key", MASTER_KEY.hex(), "--version", "4", + "--container-id", CONTAINER_ID.hex(), *kdf_flags(kdf)) + except BridgeError as e: + check(label, False, f"js refused to write: {e}") + continue + py_bytes = keym2.encrypt( + plaintext, PASSWORD, + kdf_id=keym2.KDF_ARGON2ID if kdf == "argon2id" else keym2.KDF_PBKDF2, + cipher_id=CIPHER_IDS[cipher], iterations=PBKDF2_ITERS, + salt=SALT, master_key=MASTER_KEY, container_id=CONTAINER_ID, + version=keym2.VERSION_V4, enforce_write_policy=False, **ARGON2) + js_bytes = js_out.read_bytes() + if py_bytes == js_bytes: + check(label, True) + else: + n = min(len(py_bytes), len(js_bytes)) + at = next((i for i in range(n) if py_bytes[i] != js_bytes[i]), n) + off = keym2.SLOT_TABLE_OFFSET_V3 + keym2.slot_len(CIPHER_IDS[cipher]) + where = ("header or slot" if at < off else f"payload+{at - off}") + check(label, False, + f"first difference at byte {at} ({where}); " + f"py {len(py_bytes)}B, js {len(js_bytes)}B") + + # §4.2 and §4.3 at every boundary the stream can sit on. The bucket + # arithmetic is where two writers would differ, and a plaintext just + # past the floor, just under one chunk and just over it are the inputs + # that tell padded_len's branches apart. + print("\nKEYM v4 stream boundaries (both write the same bucket):") + C = keym2.CHUNK_SIZE + for n, why in ((0, "empty"), (1, "one byte"), (248, "the last plaintext at the floor"), + (249, "the first past the floor"), (300, "inside Padmé's first unit"), + (C - 8, "a stream of exactly one chunk"), + (C - 7, "the first stream needing two chunks"), + (C, "one chunk of plaintext")): + plaintext = bytes((i * 31 + 7) & 0xFF for i in range(n)) + src = tmp / "pt4.bin" + src.write_bytes(plaintext) + js_out = tmp / "js4.keym2" + bridge("encrypt2", "--password", PASSWORD, "--in", str(src), + "--out", str(js_out), "--cipher", "aes", "--salt", SALT.hex(), + "--master-key", MASTER_KEY.hex(), "--version", "4", + "--container-id", CONTAINER_ID.hex(), *kdf_flags("pbkdf2")) + py_bytes = keym2.encrypt( + plaintext, PASSWORD, kdf_id=keym2.KDF_PBKDF2, cipher_id=keym2.CIPHER_AES, + iterations=PBKDF2_ITERS, salt=SALT, master_key=MASTER_KEY, + container_id=CONTAINER_ID, version=keym2.VERSION_V4, + enforce_write_policy=False) + js_bytes = js_out.read_bytes() + want = keym2.SLOT_TABLE_OFFSET_V3 + keym2.slot_len(keym2.CIPHER_AES) + P = keym2.padded_len(keym2.PAD_PREFIX_LEN + n) + want += P + keym2.TAG_LEN * max(1, -(-P // C)) + check(f"{why} ({n} B): identical, {want} bytes", py_bytes == js_bytes + and len(py_bytes) == want, + f"py={len(py_bytes)} js={len(js_bytes)} want={want}") + + # The §8 vector, pinned in both directions, as v3's is. + print("\nKEYM v4 published test vector (docs/FORMAT-V4-DESIGN.md §8):") + VEC4_PT = b"Keymaker fixture - KEYM v4 / argon2id / aes-256-gcm" + VEC4_HEX = ( + "4b45594d040000000123456789abcdef0123456789abcdef012b57f2b782ba97" + "fe97cf4f18455c21e27d9286d749ce5aa5ff9c76a1fed44c1100010000000000" + "0000112233445566778899aabbccddeeff00112233445566778899aabbccddee" + "ff00030001000004008e7b9e001250f449d30882f58e5d93c85bb8a8e6459ea5" + "8ebba4ecc919f86d5c31a2e4a961d80c1b34957112c55e92ddb3ae733192a6e4" + "725beff0a4ed88081ff33e43e1706716c145d27e0a55965b2bb169dd9f809630" + "121604f971fec25aa62f06aac5737bd0c139e7a07d41a6a48edcf2868f64b1f9" + "e31146e75116808c42530044fa45aa06db4459dda1d22ef646d4790d3769ba81" + "26b022115b28841829f1f40096ebc4acf10b9dad99fada24566f1b6ba0604336" + "5d5a508c80572e3c1486888e3f4cafcbe3a225b1bafe3fd211dcecfd635f2b32" + "c5de3ffe575d3c696e8cb27777cb148bdf285d75e8464065939853297de066e2" + "dbe384123dda057182fb2656f5c735cbc975e8fd07b1d019807174fc25b084fd" + "3ad0326314c796dde9346618e2ec7e29768b432206d2a99d212e17be19e8fb44" + "420bacf2a03e69291d") + vec4_src = tmp / "vec4.bin" + vec4_src.write_bytes(VEC4_PT) + vec4_js = tmp / "vec4.keym2" + try: + bridge("encrypt2", "--password", VEC_PW, "--in", str(vec4_src), + "--out", str(vec4_js), "--cipher", "aes", "--salt", VEC_SALT.hex(), + "--master-key", VEC_MK.hex(), "--container-id", VEC_CID.hex(), + "--version", "4", + "--kdf", "argon2id", "--time", "3", "--mem", "65536", "--par", "4") + check("the TypeScript reproduces the v4 §8 vector", + vec4_js.read_bytes().hex() == VEC4_HEX) + except BridgeError as e: + check("the TypeScript reproduces the v4 §8 vector", False, f"js refused: {e}") + py_vec4 = keym2.encrypt(VEC4_PT, VEC_PW, salt=VEC_SALT, master_key=VEC_MK, + container_id=VEC_CID, version=keym2.VERSION_V4) + check("the reference reproduces the v4 §8 vector", py_vec4.hex() == VEC4_HEX) + + # Round trips across the boundary, both ways, every cipher. The + # plaintext is random so a reader that returned a padding byte, or + # one byte short, cannot pass by coincidence. + print("\nKEYM v4 round-trips (py -> js and js -> py):") + for cipher in ("aes", "chacha", "chained"): + for n in (0, 200, 249, C - 7): + msg = os.urandom(n) + label = f"v4 {cipher}, {n} B" + py_c = keym2.encrypt(msg, PASSWORD, kdf_id=keym2.KDF_PBKDF2, + cipher_id=CIPHER_IDS[cipher], iterations=PBKDF2_ITERS, + version=keym2.VERSION_V4, enforce_write_policy=False) + py_path = tmp / "py4.keym2" + py_path.write_bytes(py_c) + js_pt = tmp / "js4.pt" + try: + bridge("decrypt2", "--password", PASSWORD, "--in", str(py_path), + "--out", str(js_pt)) + check(f"py -> js: {label}", js_pt.read_bytes() == msg, + f"js returned {js_pt.stat().st_size} B for {n}") + except BridgeError as e: + check(f"py -> js: {label}", False, str(e)) + src = tmp / "pt4.bin" + src.write_bytes(msg) + js_c = tmp / "js4w.keym2" + bridge("encryptapp", "--password", PASSWORD, "--in", str(src), + "--out", str(js_c), "--cipher", cipher, "--version", "4", + *kdf_flags("pbkdf2")) + try: + check(f"js -> py: {label}", keym2.decrypt(js_c.read_bytes(), PASSWORD) == msg) + except keym2.KeymError as e: + check(f"js -> py: {label}", False, str(e)) + + # §4.4 across the boundary: streams the reference's own sealer builds + # and no conforming writer emits. The reference's selftest shows it + # refuses each of these; here the TypeScript must refuse the same + # files, or the two readers disagree about which containers exist. + print("\nKEYM v4 §4.4: the TypeScript refuses what the reference refuses:") + mk4 = bytes(range(32)) + base4 = keym2.encrypt(b"a small secret", PASSWORD, kdf_id=keym2.KDF_PBKDF2, + iterations=PBKDF2_ITERS, master_key=mk4, + version=keym2.VERSION_V4, enforce_write_policy=False) + core4, _r4, pay4 = keym2.parse_container(base4) + head4 = base4[:len(base4) - len(pay4)] + good4 = keym2.pad_stream(b"a small secret") + + def js_opens(container: bytes) -> bool: + path = tmp / "bad4.keym2" + path.write_bytes(container) + try: + bridge("decrypt2", "--password", PASSWORD, "--in", str(path), + "--out", str(tmp / "bad4.pt")) + return True + except BridgeError: + return False + + def py_opens(container: bytes) -> bool: + try: + keym2.decrypt(container, PASSWORD) + return True + except keym2.KeymError: + return False + + for why, stream in ( + ("the writer's own stream (the control)", good4), + ("step 1: shorter than the prefix", b"\x00" * 4), + ("step 3: a prefix past the stream", (1 << 40).to_bytes(8, "big") + good4[8:]), + ("step 4: another rule's bucket, zeros and all", good4 + b"\x00"), + ("step 4: one byte short of its bucket", good4[:-1]), + ("step 5: a non-zero padding byte", good4[:-1] + b"\x01")): + container = head4 + keym2._seal_stream(core4, mk4, stream) + expected = why.startswith("the writer") + py_ok, js_ok = py_opens(container), js_opens(container) + check(f"{why}: both {'open' if expected else 'refuse'}", + py_ok == js_ok == expected, + f"python {'opened' if py_ok else 'refused'}, js {'opened' if js_ok else 'refused'}") + check("a v4 container relabelled v3 is refused by both", + not py_opens(base4[:4] + b"\x03" + base4[5:]) + and not js_opens(base4[:4] + b"\x03" + base4[5:])) + # --------------------------------------------------------------- # v3 §5.2 — the report each implementation makes about the table # --------------------------------------------------------------- @@ -1843,7 +2035,7 @@ def py_verdict(kind: str, text: str) -> dict: if failed: print("KEYM v2/v3 conformance FAILED — the implementations disagree.") return 1 - print("Conformance passed: independent implementations agree on KEYM v2 and v3, " + print("Conformance passed: independent implementations agree on KEYM v2, v3 and v4, " "byte for byte.") return 0 diff --git a/reference/keym2.py b/reference/keym2.py index cf13710..cfb1fda 100644 --- a/reference/keym2.py +++ b/reference/keym2.py @@ -206,7 +206,8 @@ VERSION_V2 = 2 VERSION_V3 = 3 -SUPPORTED_VERSIONS = (VERSION_V2, VERSION_V3) +VERSION_V4 = 4 +SUPPORTED_VERSIONS = (VERSION_V2, VERSION_V3, VERSION_V4) # The version this implementation *writes* by default. # @@ -225,6 +226,12 @@ # nothing rewrites an existing file. `encrypt(..., version=VERSION_V2)` still # writes v2 for anyone who needs to produce one — a vector for the frozen # corpus, say, or a container for a reader that predates v3. +# +# v4 (docs/FORMAT-V4-DESIGN.md) pads the payload so the container's length +# stops stating the plaintext's, and is written on request only: +# `encrypt(..., version=VERSION_V4)`, `--pad` on the CLI. v4 §6 says MAY, +# not SHOULD, and does not move this default, because the cost lands on +# the writer's medium: on paper, more bytes are more symbols. VERSION = VERSION_V3 # §3. The header is in two parts and the split is load-bearing (§5.3): the core @@ -504,7 +511,7 @@ def core_header_len(version: int) -> int: """Length of the payload AAD, and of the first half of every slot's AAD.""" if version == VERSION_V2: return CORE_HEADER_LEN - if version == VERSION_V3: + if version in (VERSION_V3, VERSION_V4): return CORE_HEADER_LEN_V3 raise UsageError(f"unknown version {version}") @@ -515,10 +522,10 @@ def slot_count_offset(version: int) -> int: def slot_table_offset(version: int) -> int: """Where the slot table starts: straight after slot_count in v2, after - slot_count and the 32-byte MAC in v3.""" + slot_count and the 32-byte MAC in v3 and v4 (v4 §3 keeps v3's header).""" if version == VERSION_V2: return SLOT_TABLE_OFFSET - if version == VERSION_V3: + if version in (VERSION_V3, VERSION_V4): return SLOT_TABLE_OFFSET_V3 raise UsageError(f"unknown version {version}") @@ -529,7 +536,7 @@ def slot_table_offset(version: int) -> int: @dataclass(frozen=True) class CoreHeader: - """§3, bytes [0, 8) in v2 and [0, 24) in v3. The payload AAD, and the only + """§3, bytes [0, 8) in v2 and [0, 24) in v3 and v4. The payload AAD, and the only part every AEAD invocation in the container agrees on. v3 §3 widens it by ``container_id`` and nothing else, which is what makes @@ -549,14 +556,22 @@ def tag_overhead(self) -> int: return tag_overhead(self.cipher_id) @property - def is_v3(self) -> bool: - return self.version == VERSION_V3 + def authenticated_table(self) -> bool: + """v3 §3: a container_id in the core header and a MAC over the slot + table. v4 keeps both unchanged (v4 §2); only v2 lacks them.""" + return self.version in (VERSION_V3, VERSION_V4) + + @property + def padded(self) -> bool: + """v4 §4: the payload seals a length-prefixed, zero-padded stream + rather than the plaintext.""" + return self.version == VERSION_V4 def __post_init__(self) -> None: if self.version == VERSION_V2: if self.container_id: raise UsageError("v2 containers have no container_id") - elif self.version == VERSION_V3: + elif self.version in (VERSION_V3, VERSION_V4): if len(self.container_id) != CONTAINER_ID_LEN: raise UsageError(f"container_id must be {CONTAINER_ID_LEN} bytes") else: @@ -630,9 +645,10 @@ def parse_core_header(data: bytes) -> CoreHeader: raise _reject() version = data[4] - # v3 §6: a v3 reader MUST open v1, v2 and v3. v1 has its own file; here the - # bound is v2 or v3, and anything else is an unknown version. A v2-only - # reader rejects 0x03 through this same check, which is what §6 relies on. + # v3 §6: a v3 reader MUST open v1, v2 and v3, and v4 §6 adds v4. v1 has + # its own file; here the bound is v2, v3 or v4, and anything else is an + # unknown version. A v2-only reader rejects 0x03 through this same check, + # and a v3-only reader rejects 0x04, which is what both §6s rely on. if version not in SUPPORTED_VERSIONS: raise _reject() if len(data) < slot_table_offset(version): @@ -650,7 +666,7 @@ def parse_core_header(data: bytes) -> CoreHeader: if data[7] != 0: # §3 reserved raise _reject() - container_id = (data[8:8 + CONTAINER_ID_LEN] if version == VERSION_V3 + container_id = (data[8:8 + CONTAINER_ID_LEN] if version != VERSION_V2 else b"") return CoreHeader(cipher_id=cipher_id, flags=flags, version=version, container_id=container_id) @@ -701,7 +717,7 @@ def read_slot_table_mac(data: bytes) -> Optional[bytes]: the container is long enough to hold it. """ core = parse_core_header(data) - if not core.is_v3: + if not core.authenticated_table: return None return data[SLOT_TABLE_MAC_OFFSET_V3:SLOT_TABLE_MAC_OFFSET_V3 + SLOT_TABLE_MAC_LEN] @@ -869,6 +885,13 @@ def webcrypto_profile_violations(container: bytes) -> list[str]: "WebCrypto has never had; the subset is AES-256-GCM only" ) + if core.padded: + reasons.append( + "version 0x04 pads the payload (docs/FORMAT-V4-DESIGN.md §4), and " + "v4 §6 keeps the page's reader at v3. The page carries its own " + "container, separate from the backup, so write that one as v3" + ) + # §4.4's skip rule applies here too: a slot this implementation cannot parse # is not evidence about the container, so an unparseable slot simply is not a # candidate. The question is whether *some* slot is in the subset. @@ -1226,7 +1249,7 @@ def compute_slot_table_mac(core: CoreHeader, records: list[bytes], edge cases, and it pins slot order as a side effect, which is why the reordering attack of §1.1 stops being possible even though it was inert. """ - if not core.is_v3: + if not core.authenticated_table: raise UsageError("slot_table_mac is a v3 field") if not (SLOT_COUNT_MIN <= len(records) <= SLOT_COUNT_MAX): raise UsageError(f"slot_count must be {SLOT_COUNT_MIN}..{SLOT_COUNT_MAX}") @@ -1249,7 +1272,7 @@ def verify_slot_table(container: bytes, core: CoreHeader, records: list[bytes], but because a MAC comparison written the other way is the kind of thing that gets copied into a place where it does matter. """ - if not core.is_v3: + if not core.authenticated_table: return None stored = read_slot_table_mac(container) assert stored is not None @@ -1877,6 +1900,73 @@ def _split_plaintext(plaintext: bytes) -> list[bytes]: return [plaintext[i * CHUNK_SIZE:(i + 1) * CHUNK_SIZE] for i in range(count)] +# v4 §4. The payload of a v4 container seals a *stream*, not the plaintext: +# +# stream = uint64_be(L) || plaintext || zero bytes, to padded_len(8 + L) +# +# so that the container's length no longer states L. The chunking below is +# untouched; it simply sees the stream. +PAD_PREFIX_LEN = 8 # v4 §4.1, uint64_be(L) +PAD_FLOOR = 256 # v4 §4.3, the smallest stream + + +def padded_len(n: int) -> int: + """ + v4 §4.2. The stream length for a prefixed plaintext of ``n = 8 + L`` bytes. + + At or below the floor, the floor. Above it, Padmé: ``n`` rounded up to a + multiple of ``2^(E - S)`` with ``E = floor(log2 n)`` and + ``S = floor(log2 E) + 1``, so each doubling admits only ``2^S`` distinct + lengths. A function of the format, like the chunk size: a reader verifies + it (§4.4) rather than trusting the writer's arithmetic. + """ + if n < PAD_PREFIX_LEN: + raise UsageError("a padded stream carries at least its 8-byte prefix") + if n <= PAD_FLOOR: + return PAD_FLOOR + e = n.bit_length() - 1 # floor(log2 n) + s = e.bit_length() # floor(log2 e) + 1 + unit = 1 << (e - s) + return (n + unit - 1) // unit * unit + + +def pad_stream(plaintext: bytes) -> bytes: + """v4 §4.1. Prefix, plaintext, zeros.""" + n = PAD_PREFIX_LEN + len(plaintext) + return len(plaintext).to_bytes(PAD_PREFIX_LEN, "big") + plaintext \ + + bytes(padded_len(n) - n) + + +def unpad_stream(stream: bytes) -> bytes: + """ + v4 §4.4, its six steps in its order. Every failure is §6's generic + rejection: a v4 reader says no more about a bad prefix than about a bad + tag. + + Step 3 runs before step 4 so ``8 + L`` is only formed once it is known to + fit; Python's integers cannot overflow, but the order is the document's + and an implementation that copies this one should not be taught the wrong + one. + + The zero check has no cryptographic job — the AEAD already covers every + byte — and exists so that the two implementations agree on every input. + A writer that emitted anything but zeros is not a conforming writer, and a + reader that opened its output while the other refused it would be the + disagreement §4.5 was written to rule out. + """ + if len(stream) < PAD_PREFIX_LEN: + raise _reject() + length = int.from_bytes(stream[:PAD_PREFIX_LEN], "big") + if length > len(stream) - PAD_PREFIX_LEN: + raise _reject() + if len(stream) != padded_len(PAD_PREFIX_LEN + length): + raise _reject() + end = PAD_PREFIX_LEN + length + if stream[end:].count(0) != len(stream) - end: + raise _reject() + return stream[PAD_PREFIX_LEN:end] + + def _seal(cipher_id: int, aes_key: bytes, chacha_key: bytes, nonce: bytes, plaintext: bytes, aad: bytes) -> bytes: """§5.4. Chained is AES inner, ChaCha outer — both under the same nonce, @@ -2046,7 +2136,7 @@ def assemble(core: CoreHeader, slots: list[bytes], payload: bytes, raise UsageError("slot record has the wrong width for this cipher") head = core.pack() + bytes([len(slots)]) - if core.is_v3: + if core.authenticated_table: if master_key is None: raise UsageError( "writing a v3 slot table needs the master key to recompute " @@ -2058,10 +2148,19 @@ def assemble(core: CoreHeader, slots: list[bytes], payload: bytes, def encrypt_payload(core: CoreHeader, master_key: bytes, plaintext: bytes) -> bytes: - """§5. The chunk sequence, sealed under the master key.""" + """§5. The chunk sequence, sealed under the master key. For v4 the thing + chunked is §4.1's padded stream; the sealing is the same.""" + return _seal_stream(core, master_key, + pad_stream(plaintext) if core.padded else plaintext) + + +def _seal_stream(core: CoreHeader, master_key: bytes, stream: bytes) -> bytes: + """§5 on exactly these bytes. Split out from encrypt_payload so the + selftest can seal a stream a conforming v4 writer would never produce and + watch the reader refuse it.""" aes_key, chacha_key = payload_keys(core.cipher_id, master_key) aad = core.pack() - chunks = _split_plaintext(plaintext) + chunks = _split_plaintext(stream) out = [] for i, chunk in enumerate(chunks): out.append(_seal(core.cipher_id, aes_key, chacha_key, @@ -2087,7 +2186,7 @@ def encrypt( container_id: Optional[bytes] = None, ) -> bytes: """ - Write a single-slot container, v3 by default and v2 on request. + Write a single-slot container, v3 by default, v2 or v4 on request. The default is ``VERSION`` above, which moved to v3 once the TypeScript core was held to the same bytes. ``version=VERSION_V2`` still writes v2, for the @@ -2107,11 +2206,11 @@ def encrypt( if version not in SUPPORTED_VERSIONS: raise UsageError(f"unknown version {version}") - if version == VERSION_V3: + if version in (VERSION_V3, VERSION_V4): if container_id is None: container_id = os.urandom(CONTAINER_ID_LEN) elif container_id is not None: - raise UsageError("container_id is a v3 field") + raise UsageError("container_id is a v3 and v4 field") core = CoreHeader(cipher_id=cipher_id, version=version, container_id=container_id or b"") @@ -2127,7 +2226,7 @@ def encrypt( salt=salt, enforce_write_policy=enforce_write_policy, ) return assemble(core, [record], encrypt_payload(core, master_key, plaintext), - master_key if core.is_v3 else None) + master_key if core.authenticated_table else None) def encrypt_both( @@ -2168,11 +2267,11 @@ def encrypt_both( raise UsageError(f"unknown cipher_id {cipher_id}") if version not in SUPPORTED_VERSIONS: raise UsageError(f"unknown version {version}") - if version == VERSION_V3: + if version in (VERSION_V3, VERSION_V4): if container_id is None: container_id = os.urandom(CONTAINER_ID_LEN) elif container_id is not None: - raise UsageError("container_id is a v3 field") + raise UsageError("container_id is a v3 and v4 field") core = CoreHeader(cipher_id=cipher_id, version=version, container_id=container_id or b"") if master_key is None: @@ -2188,7 +2287,7 @@ def encrypt_both( enforce_write_policy=enforce_write_policy, ) container = assemble(core, [record], encrypt_payload(core, master_key, plaintext), - master_key if core.is_v3 else None) + master_key if core.authenticated_table else None) return container, texts @@ -2455,7 +2554,12 @@ def decrypt_report( if offset != len(payload): raise _reject() - return DecryptResult(plaintext=b"".join(out), slot_table_authentic=authentic) + stream = b"".join(out) + # v4 §4.4: only once every chunk has verified and the final flag has been + # seen. The prefix and the padding are inside the AEAD, so nothing here is + # attacker-controlled; the checks are for a writer that got §4 wrong. + plaintext = unpad_stream(stream) if core.padded else stream + return DecryptResult(plaintext=plaintext, slot_table_authentic=authentic) def decrypt( @@ -2540,7 +2644,7 @@ def add_slot( salt=salt, enforce_write_policy=enforce_write_policy, ) return assemble(core, records + [record], payload, - master if core.is_v3 else None) + master if core.authenticated_table else None) def add_shamir_slot( @@ -2579,7 +2683,7 @@ def add_shamir_slot( core, master, k, n, salt=salt, share_secret=share_secret, coefficients=coefficients) return (assemble(core, records + [record], payload, - master if core.is_v3 else None), texts) + master if core.authenticated_table else None), texts) def add_both_slot( @@ -2622,7 +2726,7 @@ def add_both_slot( time_cost=time_cost, memory_kib=memory_kib, parallelism=parallelism, salt=salt, share_secret=share_secret, coefficients=coefficients) return (assemble(core, records + [record], payload, - master if core.is_v3 else None), texts) + master if core.authenticated_table else None), texts) def _passkey_only(records: list[bytes]) -> bool: @@ -2676,7 +2780,7 @@ def add_passkey_slot( "a passkey is hardware, and a container only a lost key opens is " "lost data (§4.7)" ) - return assemble(core, out, payload, master if core.is_v3 else None) + return assemble(core, out, payload, master if core.authenticated_table else None) def remove_slot( @@ -2709,7 +2813,7 @@ def remove_slot( """ core = parse_core_header(container) master: Optional[bytes] = None - if core.is_v3: + if core.authenticated_table: if (unlock_password is None and not unlock_shares and unlock_prf_output is None): raise UsageError( @@ -2797,7 +2901,7 @@ def rewrap_slot( salt=salt, enforce_write_policy=enforce_write_policy, ) return assemble(core, records[:index] + [record] + records[index + 1:], payload, - master if core.is_v3 else None) + master if core.authenticated_table else None) # ============================================================================= @@ -3348,7 +3452,7 @@ def _inspect(container: bytes) -> str: f"KEYM v{core.version}", f" cipher {cipher}", ] - if core.is_v3: + if core.authenticated_table: lines.append(f" container {core.container_id.hex()}") # Deliberately not "mac ok" / "mac bad": whether the MAC is *correct* is # not knowable here. inspect holds no secret, so it cannot derive @@ -3362,7 +3466,13 @@ def _inspect(container: bytes) -> str: for i, record in enumerate(records): lines.extend(_describe_slot(i, record)) - lines.append(f" chunks {len(sizes)} ({sum(sizes)} plaintext bytes)") + if core.padded: + # v4 §5: without the secret, the stream length is all that is knowable + # about the plaintext's, and inspect holds no secret. + lines.append(f" chunks {len(sizes)} ({sum(sizes)} padded bytes; " + f"v4 hides the length of what is inside)") + else: + lines.append(f" chunks {len(sizes)} ({sum(sizes)} plaintext bytes)") return "\n".join(lines) @@ -5235,9 +5345,9 @@ def reorder_slots(container: bytes) -> bytes: check("a container written with no version says v3 on the wire", encrypt(v3msg, pw, **fast)[4] == VERSION_V3) - check("an unknown version is still rejected", + check("an unknown version (5) is still rejected", _raises_keym(lambda: decrypt(bytes([v3one[0], v3one[1], v3one[2], - v3one[3], 4]) + v3one[5:], pw))) + v3one[3], 5]) + v3one[5:], pw))) check("v1 and v2 magic dispatch is unchanged by v3", detect(v3one) == "keym-binary-v3" and detect(a2) == "keym-binary-v2") @@ -5249,6 +5359,219 @@ def reorder_slots(container: bytes) -> bytes: check(f"the v3 section ran to completion{'' if v3_crash is None else f' ({v3_crash})'}", v3_crash is None) + def _v4_section() -> None: + # ===================================================================== + # KEYM v4 (docs/FORMAT-V4-DESIGN.md) + # ===================================================================== + # + # Only the delta: the padded stream. Header, MAC, slots and chunking are + # v3's and are tested above; what is checked here is that the length + # stops saying what v2 §8 said it says, that §4.4's reader refuses every + # stream a conforming writer cannot produce, and that nothing else moved. + + # §4.2's worked values, pinned so the table in the document cannot drift + # from the arithmetic. + for n, want in ((8, 256), (256, 256), (257, 272), (300, 304), + (1000, 1024), (1025, 1088), (65544, 67584), + (1048584, 1081344), (100000008, 100663296)): + check(f"v4 §4.2: padded_len({n}) == {want}", padded_len(n) == want) + check("v4 §4.2: a stream shorter than its prefix is not a length", + _raises_usage(lambda: padded_len(7))) + check("v4 §4.2: padded_len never shrinks and never steps down", + all(padded_len(n) >= n and padded_len(n + 1) >= padded_len(n) + for n in range(PAD_PREFIX_LEN, 70000))) + check("v4 §5: the overhead above the floor stays under 7% up to 256 KiB", + all((padded_len(n) - n) * 100 < 7 * n for n in range(257, 1 << 18))) + check("v4 §4.3: the two branches meet without a step", + padded_len(256) == 256 and padded_len(257) == 272) + + v4msg = b"what is inside must not show through the length" + v4one = encrypt(v4msg, pw, version=VERSION_V4, **fast) + check("v4 §3: a v4 container declares version 4", v4one[4] == VERSION_V4) + check("v4 §3: the header geometry is v3's", + core_header_len(VERSION_V4) == CORE_HEADER_LEN_V3 == 24 + and slot_table_offset(VERSION_V4) == SLOT_TABLE_OFFSET_V3 == 57) + check("v4 §2: container_id and the table MAC are present", + len(parse_container(v4one)[0].container_id) == CONTAINER_ID_LEN + and read_slot_table_mac(v4one) is not None) + opens("v4: round trip", lambda: decrypt(v4one, pw), v4msg) + reports("v4 §2: a fresh v4 table is reported authentic", + lambda: decrypt_report(v4one, pw).slot_table_authentic, True) + check("v4 §6: this implementation still writes v3 by default", + VERSION == VERSION_V3 and encrypt(v4msg, pw, **fast)[4] == VERSION_V3) + check("v4: detect names the version", detect(v4one) == "keym-binary-v4") + + # §1.1 and §4.3: the leak, closed. Every plaintext up to 248 bytes gives + # the same container; v2 §8's "byte for byte" is no longer true of v4. + floor_len = SLOT_TABLE_OFFSET_V3 + slot_len(CIPHER_AES) + PAD_FLOOR + TAG_LEN + lens = {L: len(encrypt(bytes(L), pw, version=VERSION_V4, **fast)) + for L in (0, 1, 75, 150, 170, 247, 248)} + check(f"v4 §4.3: every plaintext up to 248 bytes is a {floor_len}-byte container", + set(lens.values()) == {floor_len}) + check("v4 §4.3: 249 bytes is the first plaintext past the floor", + len(encrypt(bytes(249), pw, version=VERSION_V4, **fast)) == floor_len + 16) + v3_lens = {L: len(encrypt(bytes(L), pw, version=VERSION_V3, **fast)) + for L in (75, 150)} + check("v2 §8 still holds for v3: its lengths differ by the plaintext's", + v3_lens[150] - v3_lens[75] == 75) + + # Round trips across every boundary the stream can sit on: the floor, + # one full chunk, and the first byte that needs a second chunk. + for L in (0, 1, 248, 249, 256, 257, CHUNK_SIZE - 8, CHUNK_SIZE - 7, CHUNK_SIZE): + msg = os.urandom(L) + c = encrypt(msg, pw, version=VERSION_V4, **fast) + opens(f"v4: a {L}-byte plaintext round-trips", lambda c=c: decrypt(c, pw), msg) + _, _, payload = parse_container(c) + want_chunks = max(1, math.ceil(padded_len(PAD_PREFIX_LEN + L) / CHUNK_SIZE)) + check(f"v4 §4.1: a {L}-byte plaintext is sealed as {want_chunks} chunk(s)", + len(_chunk_layout(len(payload), TAG_LEN)) == want_chunks) + + # §4.4, each step, against streams a conforming writer never produces. + # Built with the writer's own sealer on a pinned master key, so the + # only thing wrong with each container is the stream inside it. + mk = bytes(range(32)) + base = encrypt(v4msg, pw, version=VERSION_V4, master_key=mk, **fast) + core4, _recs4, pay4 = parse_container(base) + head4 = base[:len(base) - len(pay4)] + + def with_stream(stream: bytes) -> bytes: + return head4 + _seal_stream(core4, mk, stream) + + good = pad_stream(v4msg) + opens("v4 §4.4: the writer's own stream opens (the control for the rest)", + lambda: decrypt(with_stream(good), pw), v4msg) + rejects("v4 §4.4 step 1: a stream shorter than the prefix", + lambda: decrypt(with_stream(b"\x00" * 4), pw)) + rejects("v4 §4.4 step 3: a prefix claiming more bytes than the stream holds", + lambda: decrypt(with_stream((1 << 40).to_bytes(8, "big") + good[8:]), pw)) + rejects("v4 §4.4 step 4: a stream padded by another rule, even with zeros", + lambda: decrypt(with_stream(good + b"\x00"), pw)) + rejects("v4 §4.4 step 4: a stream one byte short of its bucket", + lambda: decrypt(with_stream(good[:-1]), pw)) + rejects("v4 §4.4 step 5: a non-zero padding byte at the end", + lambda: decrypt(with_stream(good[:-1] + b"\x01"), pw)) + rejects("v4 §4.4 step 5: a non-zero padding byte right after the plaintext", + lambda: decrypt(with_stream(good[:8 + len(v4msg)] + b"\x80" + + good[9 + len(v4msg):]), pw)) + rejects("v4 §4.4 step 5: the prefix undercounting, so plaintext reads as padding", + lambda: decrypt(with_stream((len(v4msg) - 1).to_bytes(8, "big") + good[8:]), pw)) + # The version byte is in the AAD (§2), so neither relabelling opens. + rejects("v4 §2: a v4 container relabelled v3 fails on every chunk", + lambda: decrypt(base[:4] + bytes([VERSION_V3]) + base[5:], pw)) + v3one4 = encrypt(v4msg, pw, version=VERSION_V3, **fast) + rejects("v4 §2: a v3 container relabelled v4 fails on every chunk", + lambda: decrypt(v3one4[:4] + bytes([VERSION_V4]) + v3one4[5:], pw)) + + # §2, §6: slot editing is v3's and leaves the container v4. + pw2, pw3 = "second holder", "new password" + two = add_slot(v4one, pw, pw2, **fast) + check("v4 §2: enrolling a slot keeps the version", two[4] == VERSION_V4) + opens("v4 §2: the enrolled slot opens the padded payload", + lambda: decrypt(two, pw2), v4msg) + check("v4 §2: enrolment did not touch the payload", + parse_container(two)[2] == parse_container(v4one)[2]) + one_again = remove_slot(two, 1, unlock_password=pw) + check("v4 §2: revocation keeps the version and the payload", + one_again[4] == VERSION_V4 + and parse_container(one_again)[2] == parse_container(v4one)[2]) + rewrapped = rewrap_slot(v4one, 0, pw, pw3, **fast) + opens("v4 §2: a re-wrapped slot opens the padded payload", + lambda: decrypt(rewrapped, pw3), v4msg) + check("v4 §2: re-wrapping keeps the version", rewrapped[4] == VERSION_V4) + # v3 §1.1's strip attack is still announced on v4. + _c, recs2, pay2 = parse_container(two) + stripped = (bytearray(two[:SLOT_TABLE_OFFSET_V3])) + stripped[SLOT_COUNT_OFFSET_V3] = 1 + reports("v4 §2: a stripped v4 table is still reported", + lambda: decrypt_report(bytes(stripped) + recs2[0] + pay2, + pw).slot_table_authentic, False) + + # §8's published vector. v3 §8's inputs with the version and one word + # of the plaintext changed, real Argon2id cost and all, so the hex in + # the document cannot drift from what this file writes. + vec_pw = "correct horse battery staple \u2014 test only" + vec_pt = b"Keymaker fixture - KEYM v4 / argon2id / aes-256-gcm" + vec_mk = bytes.fromhex("404142434445464748494a4b4c4d4e4f" + "505152535455565758595a5b5c5d5e5f") + vec = encrypt(vec_pt, vec_pw, kdf_id=KDF_ARGON2ID, cipher_id=CIPHER_AES, + time_cost=3, memory_kib=65536, parallelism=4, + salt=bytes.fromhex("00112233445566778899aabbccddeeff" + "00112233445566778899aabbccddeeff"), + master_key=vec_mk, + container_id=bytes.fromhex("0123456789abcdef0123456789abcdef"), + version=VERSION_V4) + check("v4 §8: the published vector is 425 bytes", len(vec) == 425) + check("v4 §8: the published core header reproduces", + vec[:24].hex() == "4b45594d040000000123456789abcdef0123456789abcdef") + check("v4 §8: K_table is v3 §8's, since only the master key feeds it", + slot_table_key(vec_mk).hex() + == "351351be1b09b978ae98feede32b49f98af1acd365aeda6943178985395e7cf6") + check("v4 §8: the published slot_table_mac reproduces", + vec[25:57].hex() + == "2b57f2b782ba97fe97cf4f18455c21e27d9286d749ce5aa5ff9c76a1fed44c11") + check("v4 §8: the published container reproduces byte for byte", + vec.hex() == + "4b45594d040000000123456789abcdef0123456789abcdef012b57f2b782ba97" + "fe97cf4f18455c21e27d9286d749ce5aa5ff9c76a1fed44c1100010000000000" + "0000112233445566778899aabbccddeeff00112233445566778899aabbccddee" + "ff00030001000004008e7b9e001250f449d30882f58e5d93c85bb8a8e6459ea5" + "8ebba4ecc919f86d5c31a2e4a961d80c1b34957112c55e92ddb3ae733192a6e4" + "725beff0a4ed88081ff33e43e1706716c145d27e0a55965b2bb169dd9f809630" + "121604f971fec25aa62f06aac5737bd0c139e7a07d41a6a48edcf2868f64b1f9" + "e31146e75116808c42530044fa45aa06db4459dda1d22ef646d4790d3769ba81" + "26b022115b28841829f1f40096ebc4acf10b9dad99fada24566f1b6ba0604336" + "5d5a508c80572e3c1486888e3f4cafcbe3a225b1bafe3fd211dcecfd635f2b32" + "c5de3ffe575d3c696e8cb27777cb148bdf285d75e8464065939853297de066e2" + "dbe384123dda057182fb2656f5c735cbc975e8fd07b1d019807174fc25b084fd" + "3ad0326314c796dde9346618e2ec7e29768b432206d2a99d212e17be19e8fb44" + "420bacf2a03e69291d") + opens("v4 §8: the published vector opens to its plaintext", + lambda: decrypt(vec, vec_pw), vec_pt) + + # §5: inspect says what is knowable and not what is not. + text = _inspect(v4one) + check("v4: inspect names the version and the padded stream, not the plaintext length", + text.startswith("KEYM v4") and "padded bytes" in text + and f"{len(v4msg)} plaintext bytes" not in text) + + # §6: the §7.2 subset excludes v4, and the writer says so. + v4page = encrypt(v4msg, pw, version=VERSION_V4, kdf_id=KDF_PBKDF2, **fast) + check("v4 §6: the WebCrypto profile names v4 as outside the subset", + any("0x04" in r for r in webcrypto_profile_violations(v4page))) + check("v4 §6: the self-extract writer refuses a v4 container", + _raises_usage(lambda: check_selfextract_policy(v4page))) + check("...and a v3 container with the same slot is inside it", + webcrypto_profile_violations( + encrypt(v4msg, pw, version=VERSION_V3, kdf_id=KDF_PBKDF2, **fast)) == []) + + # The CLI: --pad writes v4, decrypt opens it, and --v2 with --pad is + # one choice too many. + with _tempfile.TemporaryDirectory() as d: + src, dst, back = (os.path.join(d, n) for n in ("in.bin", "out.keym", "back.bin")) + open(src, "wb").write(v4msg) + code, _o, err = _cli(["encrypt", "--kdf", "pbkdf2", "--iterations", "600000", + "--pad", "--password", pw, "--in", src, "--out", dst]) + check("v4 CLI: encrypt --pad writes a v4 container", + code == 0 and open(dst, "rb").read()[4] == VERSION_V4) + code, out, _e = _cli(["inspect", "--in", dst]) + check("v4 CLI: inspect reads it as v4", code == 0 and out.startswith("KEYM v4")) + code, _o, _e = _cli(["decrypt", "--password", pw, "--in", dst, "--out", back]) + check("v4 CLI: decrypt recovers the bytes", + code == 0 and open(back, "rb").read() == v4msg) + code, _o, err = _cli(["encrypt", "--kdf", "pbkdf2", "--iterations", "600000", + "--pad", "--v2", "--password", pw, "--in", src, + "--out", dst + ".2"]) + check("v4 CLI: --pad with --v2 is refused in a sentence", + code == 1 and "choose one" in err and not os.path.exists(dst + ".2")) + + v4_crash = None + try: + _v4_section() + except (KeymError, UsageError, AssertionError, ValueError) as exc: + v4_crash = f"{type(exc).__name__}: {exc}" + check(f"the v4 section ran to completion{'' if v4_crash is None else f' ({v4_crash})'}", + v4_crash is None) + # --- the multi-secret matrix ------------------------------------------- # # password x key file x share set x passkey, over both versions, all three @@ -5601,8 +5924,16 @@ def main(argv: Optional[list[str]] = None) -> int: help="write a KEYM v2 container instead of v3 " "(older readers only; the slot table is then " "unauthenticated — see docs/FORMAT-V3-DESIGN.md §1.1)") + # v4 §6: MAY, not SHOULD, so the direction that costs bytes is the + # explicit one here too. + p.add_argument("--pad", action="store_true", + help="write a KEYM v4 container: the payload is padded so " + "the file's length does not state the length of what " + "is inside (docs/FORMAT-V4-DESIGN.md). Costs up to 248 " + "bytes plus under 7%%, and a reader older than v4 " + "cannot open it") p.add_argument("--container-id", - help="16-byte hex container id, v3 only " + help="16-byte hex container id, v3 and v4 only " "(conformance testing only)") @@ -5941,6 +6272,8 @@ def main(argv: Optional[list[str]] = None) -> int: # A password and a share set are not alternatives to choose between. # §4.4's walk tries every slot against every secret it holds, so # supplying both is exactly how you find out which one still works. + if args.cmd == "encrypt" and args.v2 and args.pad: + raise UsageError("--v2 writes v2 and --pad writes v4; choose one") password = (args.password if (shares or prf_output) else resolve_password(args.password, confirm=(args.cmd == "encrypt"))) @@ -5970,7 +6303,8 @@ def main(argv: Optional[list[str]] = None) -> int: parallelism=args.parallelism, salt=bytes.fromhex(args.salt) if args.salt else None, master_key=bytes.fromhex(args.master_key) if args.master_key else None, - version=VERSION_V2 if args.v2 else VERSION, + version=(VERSION_V2 if args.v2 + else VERSION_V4 if args.pad else VERSION), container_id=(bytes.fromhex(args.container_id) if args.container_id else None), ) diff --git a/reference/recovery_test.py b/reference/recovery_test.py index 78ab03c..92a833f 100644 --- a/reference/recovery_test.py +++ b/reference/recovery_test.py @@ -48,13 +48,14 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent -# Keyed by container version, not by script: v2 and v3 are both keym2.py, which +# Keyed by container version, not by script: v2, v3 and v4 are all keym2.py, which # is the point of §6's dispatch — an heir runs one command whatever year the # backup is from, and the script works out the rest. SCRIPTS = { 1: ROOT / "reference" / "keym.py", 2: ROOT / "reference" / "keym2.py", 3: ROOT / "reference" / "keym2.py", + 4: ROOT / "reference" / "keym2.py", } BRIDGE = ROOT / "reference" / "bridge.mjs" @@ -188,7 +189,7 @@ def main() -> int: # v3 is what the app writes today, so it is the backup an heir is most # likely to be holding. This loop covered v1 and v2 only for as long as # v3 has been the default. - for version in (1, 2, 3): + for version in (1, 2, 3, 4): for kdf, cipher, kf in ( ("pbkdf2", "aes", None), ("pbkdf2", "chacha", None), @@ -748,7 +749,7 @@ def recovery_commands() -> None: check(False, f"RECOVERY.md command is executed by this runner: `{c}`", "add a case for it here rather than leaving it unchecked") for group, name in ((identify, "inspect"), (decrypt, "decrypt")): - for version in (1, 2, 3): + for version in (1, 2, 3, 4): owners = [c for c in group if version in claimed_versions(c)] check(len(owners) == 1, f"exactly one {name} line on the page is for v{version}", @@ -772,7 +773,7 @@ def recovery_commands() -> None: mykey.write_bytes(KEYFILE) # Step 2: the right script describes the file, the wrong one refuses. - for version in (1, 2, 3): + for version in (1, 2, 3, 4): backup.write_bytes(js_encrypt(version, SECRET, "pbkdf2", "aes", None, tmp).read_bytes()) for c in identify: r = run_doc_command(c, tmp, stdin="") @@ -784,7 +785,7 @@ def recovery_commands() -> None: # Step 4: the line for this version, with the password on the prompt, # and again with the key file the page tells you to add. - for version in (1, 2, 3): + for version in (1, 2, 3, 4): for kf in (None, KEYFILE): backup.write_bytes(js_encrypt(version, SECRET, "argon2id", "chained", kf, tmp, tag="-doc").read_bytes()) diff --git a/scripts/fixtures/keymaker/fixtures.json b/scripts/fixtures/keymaker/fixtures.json index 9ff8b96..feda416 100644 --- a/scripts/fixtures/keymaker/fixtures.json +++ b/scripts/fixtures/keymaker/fixtures.json @@ -167,6 +167,76 @@ "plaintext": "Keymaker fixture — v3 argon2id / chained", "slotTableAuthentic": true }, + { + "name": "v4-pbkdf2-aes256gcm", + "file": "v4-pbkdf2-aes256gcm.keym", + "version": 4, + "kdf": "pbkdf2", + "cipher": "aes-256-gcm", + "keyFile": false, + "plaintext": "Keymaker fixture — v4 pbkdf2 / aes-256-gcm", + "slotTableAuthentic": true + }, + { + "name": "v4-pbkdf2-chacha20poly1305", + "file": "v4-pbkdf2-chacha20poly1305.keym", + "version": 4, + "kdf": "pbkdf2", + "cipher": "chacha20-poly1305", + "keyFile": true, + "plaintext": "Keymaker fixture — v4 pbkdf2 / chacha20-poly1305", + "slotTableAuthentic": true + }, + { + "name": "v4-pbkdf2-chained", + "file": "v4-pbkdf2-chained.keym", + "version": 4, + "kdf": "pbkdf2", + "cipher": "chained", + "keyFile": false, + "plaintext": "Keymaker fixture — v4 pbkdf2 / chained", + "slotTableAuthentic": true + }, + { + "name": "v4-argon2id-aes256gcm", + "file": "v4-argon2id-aes256gcm.keym", + "version": 4, + "kdf": "argon2id", + "cipher": "aes-256-gcm", + "keyFile": false, + "plaintext": "Keymaker fixture — v4 argon2id / aes-256-gcm", + "slotTableAuthentic": true + }, + { + "name": "v4-argon2id-chacha20poly1305", + "file": "v4-argon2id-chacha20poly1305.keym", + "version": 4, + "kdf": "argon2id", + "cipher": "chacha20-poly1305", + "keyFile": true, + "plaintext": "Keymaker fixture — v4 argon2id / chacha20-poly1305", + "slotTableAuthentic": true + }, + { + "name": "v4-argon2id-chained", + "file": "v4-argon2id-chained.keym", + "version": 4, + "kdf": "argon2id", + "cipher": "chained", + "keyFile": false, + "plaintext": "Keymaker fixture — v4 argon2id / chained", + "slotTableAuthentic": true + }, + { + "name": "v4-padme-aes256gcm", + "file": "v4-padme-aes256gcm.keym", + "version": 4, + "kdf": "pbkdf2", + "cipher": "aes-256-gcm", + "keyFile": false, + "plaintext": "Keymaker fixture - v4 past the floor / pbkdf2 / aes-256-gcm. Keymaker fixture - v4 past the floor / pbkdf2 / aes-256-gcm. Keymaker fixture - v4 past the floor / pbkdf2 / aes-256-gcm. Keymaker fixture - v4 past the floor / pbkdf2 / aes-256-gcm. Keymaker fixture - v4 past the floor / pbkdf2 / aes-256-", + "slotTableAuthentic": true + }, { "name": "v2-shamir-aes256gcm", "file": "v2-shamir-aes256gcm.keym", diff --git a/scripts/fixtures/keymaker/v4-argon2id-aes256gcm.keym b/scripts/fixtures/keymaker/v4-argon2id-aes256gcm.keym new file mode 100644 index 0000000000000000000000000000000000000000..e52d4d459d675bb686f3a0a40237f14f2cf48393 GIT binary patch literal 425 zcmV;a0apG?MOjS*0002092$0HPy%*iOp`;CI|;4&n{s?BEujHJf9NyN!!?kh&!=@} z00IC2KmY;&3r`6BWk0D$Py~NyGmE;gf3^ImGIIz$f2gh%uXE+EFK9%Ype7zpbPPGZ zjvAo(O7yC+T*3RzubAAuxIc>@QjAszv^UpnA*qkWh8Zne0fl3Ze57r7@K>)nltYcV z#Vy*wnRy(?#pj)w=otUp%2wQz(*@VMC+2hluQ&QhFlP| zSj>3$X1^yTgnlz*F`@B;$VtoMwT~hODYhCJfc-}!cDa}HnHY>ac1b*myla6fF%qip z@3M0Wkr;SZIsPWStGTr=K=B3<9T?QXbh%PHS$}DM`vQqImtL$k9w!Q+&hm6Dc`8;r(8?axvHi%8^#U`KcbYWct}s7X_Us*OTe zvlUe_W~A*TzD5e%0099200000ng^oTYT92J5@>}|My)D6?>@Gmty*c*={Jia-31bYi#sI8Xc5DgEW3g~Z+^%gX z)9Xw~v7G^m_T#})8j(UI%#(YATuB#gXaD;r3HA6oI*Tlj1& z&^<7Bxp!URvAR%^YPp|uq|wuzrvgUrzA literal 0 HcmV?d00001 diff --git a/scripts/fixtures/keymaker/v4-argon2id-chained.keym b/scripts/fixtures/keymaker/v4-argon2id-chained.keym new file mode 100644 index 0000000000000000000000000000000000000000..c1eb1e86b4f5e955eec1a0d6998fdaca3784958d GIT binary patch literal 457 zcmV;)0XF_iMOjS*0ssJ6Av@Q$Mks4z;+XC95~xrC8Igl(Cen_nLoKpC*x;oYtMq?k z4GsEPmMK&b;1^Kx0096100000VLFijvo%+kjh+=+>Qv9$Nqn3>d!us6=J(lQcc5^+ z00IC2KmY;&d0f6*$?|T}x{XJ76Yjz3lz^Wyz6$=E2DbIYMeX`y_q6cvplYRa!sc@^ zr6Qa}wJop}vCqbU{}Wl<2X~P5Sr0$=aFsrb)0p4Wy9SW-JLLn9SQ7h{>;SeG zfoL6)y2Ho$&@u%)eCP0_nfOv#lp4vuX3$6$DVJ1Q-paG`No?O9R1kifZc4N{&H!-1 z)1tpHOnVYmCD}SLIW>+OZ_{*Rj!+H<n+SZ#8Z=+!I>1r^? zvB5~r(wFsMYHYjbMohaE6SYSD%*hLg_esGWJmUwc*)zGu+x(UF!^(xN@P)3bjnR10 zL>2b4xGHFGJd#?H5!q%Kx#Ub^+o^3L}v?vaCl}&(nI>+%iC#7|_Z_u-c$Q>6YiW literal 0 HcmV?d00001 diff --git a/scripts/fixtures/keymaker/v4-padme-aes256gcm.keym b/scripts/fixtures/keymaker/v4-padme-aes256gcm.keym new file mode 100644 index 0000000000000000000000000000000000000000..5e80fc8f7815f328fc0d78ed4b90cc0d2e1155fc GIT binary patch literal 489 zcmVZjr=O=g9({r*`;`6X$CFETva|AFJpN$ni6S99WNrf)(Dq^GLxXJ3P2+i&rtyQ-R zq1t`o$5MX|&Wy3LFEdbSLn@`#aG~~Tl_#+-(;u_cAAw{GmiVNU8ooa#S4v6zZ$m?@ zgqI>&PoP|nP#I}qPGnGeaCPXR**l#amHk00ZC0QdTj3|&A3_KVuRCWVWCmLiQQ!)d z9><0!Nr&$zC$8XJic>4@epJiXB{XI~c8bCLX*hu4-CI=7c2TOZW;J;n^3-PHnp?Q@ z7P4}B{aT)kGRhK#i7||VAhw?k4%7bc-rtQLe5c9 fiFhW`g#Rb;J%AxKK>u)b*+UCBx6;lM#42TubLZ(I literal 0 HcmV?d00001 diff --git a/scripts/fixtures/keymaker/v4-pbkdf2-aes256gcm.keym b/scripts/fixtures/keymaker/v4-pbkdf2-aes256gcm.keym new file mode 100644 index 0000000000000000000000000000000000000000..51799ee38da71ed3c9c510b8f4328d38c6268d4f GIT binary patch literal 425 zcmV;a0apG?MOjS*0002g=U{bD6yB7j7!VW-9dLyKKD2RkpQk>0tn&+~NwGqkq9gKAOJzAKg$8r#RML7` z+XMHL4-)|U)K76xuV>o)LMuOHyG|4pOV_-^u zsSOk2oAa4Ge5ZD+`o+yrn>V5$nJ_x-qK*wG^95Fow9tot!LRiddd3GSXZ8Y5OT3G$ z6&Rf(xj8~Bc@U8K^AAJj%(=F>R7@~|@{Ex5A}yyioXdK|(&s-pRQ|yTl4R&!i^;*j z?MyH(vr(av_?Z8Tn3*%v-q~v-lRPAkDycm~|9=7ZMZR z00}3+00000q5v_1y-nmy8K6-RYLq39q3(%}UAc-R&8rPDnfhslze4N&d}_Mg@d9t@ z$*{gW^o>z4xa|XFintoVYJ|-tFi4^)m2~y)2AQX;*sT=1@M+UD+R#%^L&8vpe`99` zYeno4l9p?4kxdQ_&RPKHr9YGuDg8_8GT!ja7a~zhnMIX#!P|%uwxEL; zm3C|#I47TRq8;H0?&P4`A`gf)A7=jSGVl_28SQx-*!`g-izr--^cYxQTLH*q!DFJq zLQ*~CSi`$c9+n1fR>|x@LV~wX-2t2T^2ipnA$VjMBG=Xj4N~9K^1AbI>WNBM1|H5X zL5R)>4jqUV`Rn8leM&MEJ{^iA2;^po!!PBS`N{$LZi*-^m7EnMe2o%b-xB|}VVexh T!ElJQSZhu+bS3rnUnK0m8qvkL literal 0 HcmV?d00001 diff --git a/scripts/fixtures/keymaker/v4-pbkdf2-chained.keym b/scripts/fixtures/keymaker/v4-pbkdf2-chained.keym new file mode 100644 index 0000000000000000000000000000000000000000..fda0e5b0106d11de8219acda2878ba170ab8708b GIT binary patch literal 457 zcmV;)0XF_iMOjS*0ssI-%2}rHp)J2DWOE(XJ0+a~$SmXUaq!6rOe$-QrDAa`QO?AF zJ{q7BL#=Vq;%3qV0000000000gDXXTRvTat)|~-Us#m*p!v>YN?tk-iNwsi|HntkH z00}3+00000mL?K0pTJ{)w!;eq;UrttaI;p!Y;+Ppr$&)}tm{5|w0>fDBMGDADz(DS zj(Z;w>$6R9#Ea1f_i6vQVJWp@;gbS$T&+2$M&0d(VB6xUvE3P4`NIwsaG1{D&J_ez z=DZlgXSG>F92S1+7D2Qa{aTcV*GXKo>}v0V+E#ys4nK#;sn?u>NJtBRV3~K)fbLvS zwac%N9`0c+8gmA&vhvGRfA+cTB&fIgCn3aDffVI7fE);*ni>5y+scYU!s(;Uqc;5I z-B`Ju`~&zsgkauqs1cT^YHS=w6H_dFBfSo2ZVe8s!UQYMh*pV^2*`F*{2X z5&VY;!TnhD9__J2x!uEqaj+fnq6q)aF7(BnS1R_Ua0G}odtJI}g~*}I_Je`DT6Eh) literal 0 HcmV?d00001 diff --git a/scripts/keym2-dispatch.mts b/scripts/keym2-dispatch.mts index ae28772..075437b 100644 --- a/scripts/keym2-dispatch.mts +++ b/scripts/keym2-dispatch.mts @@ -38,6 +38,7 @@ import { KEYM2_HEADER_PEEK_BYTES, KEYM2_MAX_SLOTS, KEYM2_VERSION_V3, + KEYM2_VERSION_V4, keym2SlotCountOffset, keym2SlotTableOffset, isKeym2Binary, @@ -112,6 +113,17 @@ check(detectFormat(v2) === "keym-v2", "a v2 container is detected as v2"); // understood perfectly to the v1 path, which rejected it as "a newer KEYM // version" — a reader refusing a format it had already implemented. check(detectFormat(v3) === "keym-v3", "a v3 container is detected as v3"); +// v4 §6, the same lesson again: the parser and the dispatcher learn a version +// together, or a container the module opens is refused as "newer". +const v4 = await encryptKeym2( + enc.encode("v4 payload"), + PASSWORD, + null, + { kdf: FAST, cipher: CipherId.AES_256_GCM }, + KEYM2_VERSION_V4 +); +check(detectFormat(v4) === "keym-v4", "a v4 container is detected as v4"); +check(isKeym2Binary(v4), "isKeym2Binary accepts v4"); check(!isKeym2Binary(v1), "isKeym2Binary rejects v1"); check(isKeym2Binary(v2), "isKeym2Binary accepts v2"); @@ -134,6 +146,11 @@ const openedV3 = await decryptData(toArrayBuffer(v3), PASSWORD, null); check(dec.decode(openedV3.data) === "v3 payload", "decryptData opens v3 through the dispatch"); check(openedV3.format === "keym-v3", "decryptData reports v3"); +const openedV4 = await decryptData(toArrayBuffer(v4), PASSWORD, null); +check(dec.decode(openedV4.data) === "v4 payload", "decryptData opens v4 through the dispatch, padding removed"); +check(openedV4.format === "keym-v4", "decryptData reports v4"); +check(openedV4.slotTableAuthentic === true, "a v4 container carries v3's slot-table verdict"); + // v3 §5.2 travels with the plaintext or it is not a report. Two containers that // have never been touched, and the verdict distinguishes "sealed and intact" // from "carries no seal at all" rather than collapsing both to a passing diff --git a/scripts/keymaker-generate-fixtures.mts b/scripts/keymaker-generate-fixtures.mts index c89f904..53a6517 100644 --- a/scripts/keymaker-generate-fixtures.mts +++ b/scripts/keymaker-generate-fixtures.mts @@ -43,6 +43,7 @@ import { encryptKeym2WithSharesRequired, keym2SlotLen, KEYM2_VERSION_V3, + KEYM2_VERSION_V4, } from "../src/lib/keym-v2.ts"; import { buildSelfExtractingPage } from "../src/lib/keym-v2-selfextract.ts"; @@ -78,7 +79,7 @@ const ARGON_PARAMS: KdfParams = { interface Combo { name: string; - version: 1 | 2 | 3; + version: 1 | 2 | 3 | 4; kdf: KdfParams; cipher: CipherId; kdfName: string; @@ -98,7 +99,7 @@ const combos: Combo[] = []; // added a MAC; it did not move the cost floor, and holding the derivation // settings still is what makes a v2 and a v3 vector comparable on the one axis // that did change. -for (const version of [1, 2, 3] as const) { +for (const version of [1, 2, 3, 4] as const) { const kdfs: Array<{ params: KdfParams; name: string }> = [ { params: version === 1 ? PBKDF2_V1_PARAMS : PBKDF2_V2_PARAMS, name: "pbkdf2" }, { params: ARGON_PARAMS, name: "argon2id" }, @@ -147,14 +148,17 @@ async function main() { // takes: it writes whatever `KEYM2_VERSION` currently names, and that is // still v2 (keym-v2.ts's note on why). A vector for a version the app does // not yet default to has to say which version it wants. + // v4 the same way: the version is the only thing that differs, and the + // plaintext sits well inside v4 §4.3's floor, so each of these six vectors + // is a 256-byte stream in one chunk. The vector past the floor is below. const ct = - c.version === 3 + c.version === 3 || c.version === 4 ? await encryptKeym2( new TextEncoder().encode(plaintext), PASSWORD, keyFile ? new Uint8Array(keyFile.slice(0)) : null, { kdf: c.kdf, cipher: c.cipher }, - KEYM2_VERSION_V3 + c.version === 3 ? KEYM2_VERSION_V3 : KEYM2_VERSION_V4 ) : await (c.version === 1 ? encryptData : encryptContainer)( new TextEncoder().encode(plaintext).buffer as ArrayBuffer, @@ -175,12 +179,49 @@ async function main() { // so that the one vector below whose table was tampered with states its // expectation in the same field as the ones whose table is intact — // a reader that inferred "v3 ⇒ authentic" could not express it. - ...(c.version === 3 ? { slotTableAuthentic: true } : {}), + ...(c.version === 3 || c.version === 4 ? { slotTableAuthentic: true } : {}), }); wrote++; console.log(`wrote ${file} (${ct.byteLength} bytes)`); } + // v4 §4.2. One vector whose stream is above the floor, so the corpus holds + // Padmé's arithmetic and not only the floor: 300 plaintext bytes is n = 308, + // E = 8, S = 4, a 16-byte unit, a 320-byte stream. A reader that padded to + // the wrong bucket, or the right bucket by the wrong rule, refuses this file + // (v4 §4.4 step 4) while still opening the six at the floor. + { + const file = "v4-padme-aes256gcm.keym"; + const prior = byName.get("v4-padme-aes256gcm"); + if (prior && existsSync(join(DIR, file))) { + fixtures.push(prior); + console.log(`kept ${file}`); + } else { + const plaintext = "Keymaker fixture - v4 past the floor / pbkdf2 / aes-256-gcm. ".repeat(6).slice(0, 300); + if (new TextEncoder().encode(plaintext).length !== 300) throw new Error("the Padmé vector must be 300 bytes"); + const ct = await encryptKeym2( + new TextEncoder().encode(plaintext), + PASSWORD, + null, + { kdf: PBKDF2_V2_PARAMS, cipher: CipherId.AES_256_GCM }, + KEYM2_VERSION_V4 + ); + writeFileSync(join(DIR, file), Buffer.from(ct)); + fixtures.push({ + name: "v4-padme-aes256gcm", + file, + version: 4, + kdf: "pbkdf2", + cipher: "aes-256-gcm", + keyFile: false, + plaintext, + slotTableAuthentic: true, + }); + wrote++; + console.log(`wrote ${file} (${ct.byteLength} bytes)`); + } + } + // §4.6. One share-set fixture per cipher, because the wrap uses the // container's cipher — a chained container chains its wrap too — so a bug in // the chained path would be invisible in an AES-only vector. diff --git a/scripts/keymaker-regression.mts b/scripts/keymaker-regression.mts index 0f645f4..0a0983b 100644 --- a/scripts/keymaker-regression.mts +++ b/scripts/keymaker-regression.mts @@ -339,7 +339,8 @@ async function main() { // default lives here rather than in the JSON so the six v1 entries could // stay byte-identical when v2 was added — see the generator's note. const version = fx.version ?? 1; - const expected = version === 3 ? "keym-v3" : version === 2 ? "keym-v2" : "keym-v1"; + const expected = + version === 4 ? "keym-v4" : version === 3 ? "keym-v3" : version === 2 ? "keym-v2" : "keym-v1"; try { // §4.8. A `both` vector opens with the password *and* its shares and // with nothing less, so it goes through the v2 module with both; the @@ -357,7 +358,7 @@ async function main() { `v${version} ${fx.name} (${fx.kdf} / ${fx.cipher}${fx.keyFile ? " / +keyfile" : ""})` ); const expectedVerdict = - version === 3 ? (fx.slotTableAuthentic as boolean) : null; + version === 3 || version === 4 ? (fx.slotTableAuthentic as boolean) : null; check( res.slotTableAuthentic === expectedVerdict, `v${version} ${fx.name} — slot table reported as ` + @@ -466,19 +467,20 @@ async function main() { const v1Count = meta.fixtures.filter((f: any) => (f.version ?? 1) === 1).length; const v2Count = meta.fixtures.filter((f: any) => f.version === 2).length; const v3Count = meta.fixtures.filter((f: any) => f.version === 3).length; + const v4Count = meta.fixtures.filter((f: any) => f.version === 4).length; const shamirCount = meta.fixtures.filter((f: any) => f.shamir).length; const passkeyCount = meta.fixtures.filter((f: any) => f.passkey).length; const pageCount = meta.fixtures.filter((f: any) => f.selfextract).length; const strippedCount = meta.fixtures.filter((f: any) => f.strippedPasskey).length; const bothCount = meta.fixtures.filter((f: any) => f.both).length; check( - fixtureCount === 35 && v1Count === 6 && v2Count === 13 && v3Count === 16 && + fixtureCount === 42 && v1Count === 6 && v2Count === 13 && v3Count === 16 && v4Count === 7 && shamirCount === 6 && passkeyCount === 6 && pageCount === 1 && strippedCount === 1 && bothCount === 3, - `corpus covers all three versions and all three ciphers per slot type ` + - `(${v1Count} v1 + ${v2Count} v2 + ${v3Count} v3, of which ${shamirCount} share ` + + `corpus covers all four versions and all three ciphers per slot type ` + + `(${v1Count} v1 + ${v2Count} v2 + ${v3Count} v3 + ${v4Count} v4, of which ${shamirCount} share ` + `sets, ${passkeyCount} passkey slots, ${bothCount} password-and-shares slots, ` + - `${pageCount} self-extracting page and ${strippedCount} stripped slot table = ${fixtureCount}/35)` + `${pageCount} self-extracting page and ${strippedCount} stripped slot table = ${fixtureCount}/42)` ); } catch (err) { check(false, `fixture load — threw: ${(err as Error).message}`); @@ -1182,6 +1184,93 @@ async function main() { ); } + // ---- 10. v4 padding (docs/FORMAT-V4-DESIGN.md) ---- + // + // The reader's refusals (§4.4, every step) are exercised in + // reference/keym2.py's selftest against streams its own sealer produces, and + // the two implementations are held to the same bytes by crosstest2.py. What + // this section pins is the TypeScript on its own: the arithmetic the + // document publishes, the boundary every stream can sit on, and the one + // property v4 exists for — that the container's length stops moving with the + // plaintext's. + { + console.log("\n10. v4 padding"); + const { + encryptKeym2, + decryptKeym2, + keym2PaddedLen, + keym2SlotLen, + KEYM2_CHUNK_SIZE, + KEYM2_VERSION_V3, + KEYM2_VERSION_V4, + } = await import("../src/lib/keym-v2.ts"); + const aes = { kdf: PBKDF2_FAST, cipher: CipherId.AES_256_GCM }; + + for (const [n, want] of [ + [8, 256], [256, 256], [257, 272], [300, 304], [1000, 1024], [1025, 1088], + [65544, 67584], [1048584, 1081344], [100000008, 100663296], + ] as const) { + check(keym2PaddedLen(n) === want, `v4 §4.2: paddedLen(${n}) === ${want}`); + } + let refused = false; + try { + keym2PaddedLen(7); + } catch { + refused = true; + } + check(refused, "v4 §4.2: a stream shorter than its prefix is not a length"); + let monotone = true; + for (let n = 8; n < 70000; n++) { + if (keym2PaddedLen(n) < n || keym2PaddedLen(n + 1) < keym2PaddedLen(n)) monotone = false; + } + check(monotone, "v4 §4.2: paddedLen never shrinks and never steps down"); + + // §1.1 closed: one container length for every plaintext under the floor. + const floorLen = 57 + keym2SlotLen(CipherId.AES_256_GCM) + 256 + 16; + const lens = new Set(); + for (const L of [0, 1, 75, 150, 170, 247, 248]) { + lens.add((await encryptKeym2(new Uint8Array(L), PASSWORD, null, aes, KEYM2_VERSION_V4)).length); + } + check( + lens.size === 1 && lens.has(floorLen), + `v4 §4.3: every plaintext up to 248 bytes is a ${floorLen}-byte container (got ${[...lens].join(", ")})` + ); + check( + (await encryptKeym2(new Uint8Array(249), PASSWORD, null, aes, KEYM2_VERSION_V4)).length === floorLen + 16, + "v4 §4.3: 249 bytes is the first plaintext past the floor" + ); + const v3a = (await encryptKeym2(new Uint8Array(75), PASSWORD, null, aes, KEYM2_VERSION_V3)).length; + const v3b = (await encryptKeym2(new Uint8Array(150), PASSWORD, null, aes, KEYM2_VERSION_V3)).length; + check(v3b - v3a === 75, "v2 §8 still holds for v3: its lengths differ by the plaintext's"); + + // Every boundary a stream can sit on: the floor, one full chunk, and the + // first byte that needs a second chunk. Random bytes, so a reader that + // returned padding or dropped a tail byte cannot pass by coincidence. + for (const L of [0, 1, 248, 249, 256, 257, KEYM2_CHUNK_SIZE - 8, KEYM2_CHUNK_SIZE - 7]) { + // In 64 KiB slices: getRandomValues refuses a larger buffer in one call. + const msg = new Uint8Array(L); + for (let at = 0; at < L; at += 65536) webcrypto.getRandomValues(msg.subarray(at, Math.min(L, at + 65536))); + const ct = await encryptKeym2(msg, PASSWORD, null, aes, KEYM2_VERSION_V4); + const got = await decryptKeym2(ct, PASSWORD, null); + check( + got.data.length === L && got.data.every((b, i) => b === msg[i]) && got.slotTableAuthentic === true, + `v4: a ${L}-byte plaintext round-trips exactly, with v3's table verdict` + ); + } + + // §2: the version byte is inside every AAD, so a relabelled container opens + // in neither direction. This is what makes a version byte, rather than a + // flag, the right place for a change to what the plaintext bytes mean. + const four = await encryptKeym2(enc.encode("relabel me"), PASSWORD, null, aes, KEYM2_VERSION_V4); + const three = await encryptKeym2(enc.encode("relabel me"), PASSWORD, null, aes, KEYM2_VERSION_V3); + const asThree = Uint8Array.from(four); + asThree[4] = KEYM2_VERSION_V3; + const asFour = Uint8Array.from(three); + asFour[4] = KEYM2_VERSION_V4; + await rejects("v4 §2: a v4 container relabelled v3 is refused", () => decryptKeym2(asThree, PASSWORD, null)); + await rejects("v4 §2: a v3 container relabelled v4 is refused", () => decryptKeym2(asFour, PASSWORD, null)); + } + // ---- Summary ---- console.log(`\n${passed} passed, ${failures} failed`); if (failures > 0) { diff --git a/src/components/container-inspector.tsx b/src/components/container-inspector.tsx index 1cfd71d..49bb0d2 100644 --- a/src/components/container-inspector.tsx +++ b/src/components/container-inspector.tsx @@ -29,6 +29,8 @@ import { KEYM2_VERSION, KEYM2_VERSION_V2, KEYM2_VERSION_V3, + KEYM2_VERSION_V4, + keym2HasAuthenticatedTable, keym2SlotCountOffset, keym2SlotTableOffset, keym2SlotLen, @@ -380,7 +382,7 @@ export function ContainerInspector({