From 5187564ff9726b4d6dce0c547a997fbdf0d987be Mon Sep 17 00:00:00 2001 From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:42:37 +0000 Subject: [PATCH 01/34] fix: scan recovery-share strips back in, and one unlock path at a time The paper vault prints a QR on every recovery strip, but nothing in the app could read one into the shares box. A strip scanned on the Decrypt tab was only told it was in the wrong box, and a whole printed backup scanned in one batch (container parts and strips together) failed as a single bad paste. The only way in from paper was retyping a ~140-character code per strip. - The recovery-shares box gains "Scan share QR images". - A scan now routes each decoded string by prefix: shares to the shares box (turning the shares path on), everything else to the container box. A share-only scan no longer switches File mode to Text, which would have dropped the selected .keym it was meant to open. - A strip scanned twice is entered once. combineShares refuses a repeated index (4.6), so a duplicate would fail an otherwise sufficient set with nothing to say which photo repeated. The QR decoder retries at several scales before reporting a miss. jsqr could not read the paper vault's own container symbol straight off its 300px canvas (it decodes at 0.75x, 1.25x, 1.5x and 2x), and a phone photo capped to 2000px can leave a version-40 part near 2.5 px per module. Choosing "Use recovery shares" after "Use a passkey" left usePasskey set behind a now-hidden control, and processData still took the passkey path: an heir with a sufficient share set was asked for a tap and told the container has no passkey. Turning shares on now turns the passkey off. Controls: removing the routing, the retry scales, the dedupe, or the passkey reset each fails its own test in shamir-ui.spec.ts. --- src/components/encryptor-tool.tsx | 149 +++++++++++++++++++++--- src/lib/qr-decode.ts | 77 +++++++----- tests/browser/shamir-ui.spec.ts | 187 ++++++++++++++++++++++++++++++ 3 files changed, 373 insertions(+), 40 deletions(-) diff --git a/src/components/encryptor-tool.tsx b/src/components/encryptor-tool.tsx index 152910d..00ed4d8 100644 --- a/src/components/encryptor-tool.tsx +++ b/src/components/encryptor-tool.tsx @@ -285,6 +285,48 @@ function shareInputRejection(next: string): string | null { return null; } +/** §4.6 share text, either version. A prefix test, not a parse: the parser is + * the authority, this only decides which box a string belongs in. */ +function isShareText(text: string): boolean { + return /^KMSHARE[12]:/.test(text.trimStart().toUpperCase()); +} + +/** + * Append scanned shares to what is already in the shares box, skipping any + * that are already there. + * + * Scanning the same strip twice is the ordinary mistake with a stack of + * photos, and a duplicate is not harmless: `combineShares` refuses a repeated + * index outright (§4.6), so k-1 strips plus one scanned twice would fail with + * nothing to say which photo was the repeat. Case-insensitive because the + * share alphabet is, and a phone's QR reader may hand back either. + */ +function mergeScannedShares( + existing: string, + scanned: readonly string[] +): { text: string; added: number; repeated: number } { + const seen = new Set(parseShareLines(existing).map((line) => line.toUpperCase())); + const fresh: string[] = []; + let repeated = 0; + for (const raw of scanned) { + const line = raw.trim(); + const key = line.toUpperCase(); + if (seen.has(key)) { + repeated++; + continue; + } + seen.add(key); + fresh.push(line); + } + if (fresh.length === 0) return { text: existing, added: 0, repeated }; + const base = existing.replace(/\s+$/, ""); + return { + text: `${base ? `${base}\n` : ""}${fresh.join("\n")}\n`, + added: fresh.length, + repeated, + }; +} + // Minimum password policy — deliberately NOT called a strength measurement. // // This check has been wrong twice, in the same way each time. First it accepted @@ -999,6 +1041,7 @@ export function EncryptorTool() { // exports but can be longer for a large phone photo. const [qrScanBusy, setQrScanBusy] = useState(false); const qrInputRef = useRef(null); + const shareQrInputRef = useRef(null); const [textSecret, setTextSecret] = useState(''); const [outputText, setOutputText] = useState(''); const [password, setPassword] = useState(''); @@ -1677,7 +1720,7 @@ export function EncryptorTool() { // // The text is kept, not refused. Throwing away what they just pasted would // be the second unhelpful thing to do; the notice says where it belongs. - if (decrypting && /^KMSHARE[12]:/.test(next.trimStart().toUpperCase())) { + if (decrypting && isShareText(next)) { setTextInputRejected( 'That is a recovery share, not an encrypted container. Put the container here, ' + 'then choose "Use recovery shares" beside the password field to enter it.' @@ -2117,13 +2160,53 @@ export function EncryptorTool() { setQrScanBusy(true); try { const texts = await decodeQrImages(files); - if (inputType !== 'text') handleInputTypeChange('text'); - handleTextSecretChange(texts.join("\n")); - toast({ - title: files.length === 1 ? "QR image scanned" : `${files.length} QR images scanned`, - description: - "The encrypted text is in the box below. Type the password to open it.", - }); + // A printed backup opened without the password is container parts *and* + // share strips, and the person opening it photographs all of it. Each + // string goes to the box that can use it: shares to the shares box, + // everything else to the container box. Joining them all into the + // container box instead failed the whole set as one bad paste, and a + // strip scanned on its own was only ever told it was in the wrong place. + // The printed strips carry a QR precisely so this path exists. + const shares = texts.filter(isShareText); + const rest = texts.filter((t) => !isShareText(t)); + // Checked before any state changes, so a refused set leaves the form + // exactly as it was. + const merged = shares.length > 0 ? mergeScannedShares(shareInput, shares) : null; + const rejection = merged ? shareInputRejection(merged.text) : null; + if (rejection) throw new QrDecodeError(rejection); + + // Only a container needs text mode. Shares scanned while a .keym file + // is selected in File mode belong beside that file, and switching mode + // would drop the very container they are meant to open. + if (rest.length > 0 && inputType !== 'text') handleInputTypeChange('text'); + if (merged) { + // Shares and a passkey are exclusive unlock paths; see the toggle. + setUsePasskey(false); + setUseShares(true); + setShareInputRejected(null); + setShareInput(merged.text); + } + if (rest.length > 0) handleTextSecretChange(rest.join("\n")); + + const title = files.length === 1 ? "QR image scanned" : `${files.length} QR images scanned`; + if (!merged) { + toast({ + title, + description: + "The encrypted text is in the box below. Type the password to open it.", + }); + } else { + const where = + merged.added === 0 + ? "Every share scanned was already in the recovery-shares box." + : `${merged.added} recovery ${merged.added === 1 ? "share is" : "shares are"} now in the recovery-shares box` + + (merged.repeated > 0 ? ` (${merged.repeated} scanned twice, counted once).` : "."); + const next = + rest.length > 0 || (inputType === 'file' ? !!file : !!textSecret.trim()) + ? " With enough of them no password is needed. If the container needs more, the attempt will simply fail." + : " The encrypted backup itself goes in the box above: scan or paste it too."; + toast({ title, description: where + next }); + } } catch (e) { // A QR that will not read is a scanning problem, never an AEAD one, so it // is reported here and never allowed to reach the password path. That is @@ -2138,7 +2221,7 @@ export function EncryptorTool() { } finally { setQrScanBusy(false); } - }, [inputType, handleInputTypeChange, handleTextSecretChange, toast]); + }, [inputType, handleInputTypeChange, handleTextSecretChange, toast, shareInput, textSecret, file]); /** Is this file an image, and so a QR to scan rather than a container to open? * MIME first, extension as the fallback for a drag that carried no type. */ @@ -3467,7 +3550,8 @@ export function EncryptorTool() {

Upload a Keymaker QR PNG, or every part of a paper backup at once, and its encrypted text fills the box above. Then type the - password. + password. Recovery-share strips can go in the same batch: they + are moved to the recovery-shares box.

)} @@ -3514,15 +3598,22 @@ export function EncryptorTool() { {currentMode === "decrypt" && (

{(() => { const n = shareLines.length; - if (n === 0) return "Paste the shares, one per line. Comment lines starting with # are ignored."; + if (n === 0) return "Paste the shares, one per line, or scan the QR on each strip. Comment lines starting with # are ignored."; // Deliberately does not say whether this is enough: the // threshold lives on the shares, not in the container, and // guessing it here would mean either reading it out of diff --git a/src/lib/qr-decode.ts b/src/lib/qr-decode.ts index 6bd6897..bde8081 100644 --- a/src/lib/qr-decode.ts +++ b/src/lib/qr-decode.ts @@ -31,6 +31,22 @@ */ const MAX_DECODE_EDGE = 2000; +/** + * Scales tried, relative to the first attempt's size, when a symbol is not + * found. jsqr's sampling grid is sensitive to how a module's width falls on + * whole pixels, not only to how many pixels it gets: the paper vault's own + * symbol, read straight off its 300px canvas, failed at 1x and decoded at + * 0.75x, 1.25x, 1.5x and 2x. And a phone photo capped to 2000px can leave a + * version-40 part near 2.5 px per module, which it cannot read, where the + * photo's own resolution would have been enough. So a miss is retried at a + * handful of scales before being reported, first upward, then down. + */ +const RETRY_SCALES = [1, 2, 1.5, 0.75, 1.25] as const; + +/** The largest edge any retry may reach: twice the first attempt's cap, which + * covers a 12-megapixel photo at close to its native resolution. */ +const MAX_RETRY_EDGE = 2 * MAX_DECODE_EDGE; + /** Thrown when an image carries no readable QR, named so the UI can tell the * two apart: a file that is not an image at all, versus an image with no code * the decoder could find. */ @@ -60,20 +76,14 @@ async function loadJsqr(): Promise { } /** - * Draw an image bitmap onto a 2D canvas and hand back its pixels. - * - * Scales down proportionally once the longest edge exceeds `MAX_DECODE_EDGE`. - * Kept separate so the test can reason about the pixel path without a jsqr in - * the way. + * Draw an image bitmap onto a 2D canvas, `longestEdge` pixels on its longest + * side, and hand back its pixels. Smoothing stays on: resampling with it is + * what lets a retry at another scale read a symbol the first size could not. */ -function bitmapToImageData(bitmap: ImageBitmap): ImageData { - let { width, height } = bitmap; - const longest = Math.max(width, height); - if (longest > MAX_DECODE_EDGE) { - const scale = MAX_DECODE_EDGE / longest; - width = Math.max(1, Math.round(width * scale)); - height = Math.max(1, Math.round(height * scale)); - } +function bitmapToImageData(bitmap: ImageBitmap, longestEdge: number): ImageData { + const scale = longestEdge / Math.max(bitmap.width, bitmap.height); + const width = Math.max(1, Math.round(bitmap.width * scale)); + const height = Math.max(1, Math.round(bitmap.height * scale)); const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; @@ -81,10 +91,27 @@ function bitmapToImageData(bitmap: ImageBitmap): ImageData { if (!ctx) { throw new QrDecodeError("This browser would not give a 2D canvas to read the image."); } + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; ctx.drawImage(bitmap, 0, 0, width, height); return ctx.getImageData(0, 0, width, height); } +/** + * The longest-edge sizes to try, in order: the image as it is (capped at + * `MAX_DECODE_EDGE`), then the retry scales, each clamped and deduplicated. + * Pure. + */ +function decodeAttemptEdges(longest: number): number[] { + const base = Math.min(longest, MAX_DECODE_EDGE); + const out: number[] = []; + for (const factor of RETRY_SCALES) { + const edge = Math.max(1, Math.min(Math.round(base * factor), MAX_RETRY_EDGE)); + if (!out.includes(edge)) out.push(edge); + } + return out; +} + /** * Decode one image file to the text of the QR it contains. * @@ -101,23 +128,21 @@ export async function decodeQrImage(file: File): Promise { ); } - let imageData: ImageData; try { - imageData = bitmapToImageData(bitmap); + const jsqr = await loadJsqr(); + for (const edge of decodeAttemptEdges(Math.max(bitmap.width, bitmap.height))) { + const imageData = bitmapToImageData(bitmap, edge); + const code = jsqr(imageData.data, imageData.width, imageData.height, { + inversionAttempts: "attemptBoth", + }); + if (code && code.data) return code.data; + } } finally { bitmap.close(); } - - const jsqr = await loadJsqr(); - const code = jsqr(imageData.data, imageData.width, imageData.height, { - inversionAttempts: "attemptBoth", - }); - if (!code || !code.data) { - throw new QrDecodeError( - `No QR code was found in "${file.name}". Crop the picture to the code, or scan it more squarely.` - ); - } - return code.data; + throw new QrDecodeError( + `No QR code was found in "${file.name}". Crop the picture to the code, or scan it more squarely.` + ); } /** diff --git a/tests/browser/shamir-ui.spec.ts b/tests/browser/shamir-ui.spec.ts index ab4efb5..2aa5ccb 100644 --- a/tests/browser/shamir-ui.spec.ts +++ b/tests/browser/shamir-ui.spec.ts @@ -204,3 +204,190 @@ test.describe("the inheritance path", () => { await expect(visible(page.locator("#output-text"))).toHaveValue(SECRET, { timeout: 90_000 }); }); }); + +/** + * The printed strips carry a QR each, and until this the app could not read + * them into the shares box: a strip scanned on the Decrypt tab was only told it + * was in the wrong box, and a whole printed backup scanned in one batch (parts + * and strips together) failed as one bad paste. The images here are the strip + * and part canvases of the real print sheet, snapshotted from inside + * `window.print()`, so the round trip is through the artefact a person would + * photograph rather than a fixture that could drift from it. + */ +interface PrintedBackup { + armored: string; + shares: string[]; + stripPngs: Buffer[]; + partPngs: Buffer[]; +} + +async function encryptAndPrintWithShares( + page: import("@playwright/test").Page, + k: number, + n: number +): Promise { + await page.goto("/"); + await useTextMode(page); + await selectCrypto(page, "pbkdf2", "aes"); + await enableShares(page, k, n); + + await visible(page.getByPlaceholder("Enter text to encrypt")).fill(SECRET); + await visible(page.getByPlaceholder("Enter a strong password")).fill(PASSWORD); + await visible(page.getByRole("button", { name: /^Encrypt Text$/i })).click(); + await expect(page.getByText(new RegExp(`Save these ${n} shares now`))).toBeVisible({ + timeout: 90_000, + }); + const shares = (await page.locator("p.font-mono").allTextContents()).filter((s) => + s.startsWith("KMSHARE2:") + ); + + // The print stub throws, which leaves the sheet mounted long enough to read + // its canvases; the same technique paper-vault.spec.ts uses. + await page.evaluate(() => { + const w = window as unknown as { __pngs?: unknown; print: () => void }; + w.__pngs = null; + w.print = () => { + const sheet = document.querySelector(".paper-vault"); + const png = (c: Element) => (c as HTMLCanvasElement).toDataURL("image/png"); + w.__pngs = { + strips: Array.from(sheet?.querySelectorAll(".pv-strip canvas") ?? [], png), + parts: Array.from(sheet?.querySelectorAll(".pv-qr canvas") ?? []) + .filter((c) => !c.closest(".pv-strip")) + .map(png), + }; + throw new Error("print stubbed"); + }; + }); + await visible(page.getByRole("dialog").getByRole("button", { name: /Print paper vault/i })).click(); + await page.waitForFunction( + () => (window as unknown as { __pngs: unknown }).__pngs !== null, + null, + { timeout: 30_000 } + ); + const pngs = await page.evaluate( + () => (window as unknown as { __pngs: { strips: string[]; parts: string[] } }).__pngs + ); + const toBuffer = (d: string) => Buffer.from(d.split(",")[1] as string, "base64"); + + await page.keyboard.press("Escape"); + await expect(page.getByText(/Save these/)).toHaveCount(0); + const armored = await page.evaluate( + () => (document.querySelector("#output-text") as HTMLTextAreaElement).value + ); + return { + armored, + shares, + stripPngs: pngs.strips.map(toBuffer), + partPngs: pngs.parts.map(toBuffer), + }; +} + +const png = (name: string, buffer: Buffer) => ({ name, mimeType: "image/png", buffer }); + +async function shareBoxLines(page: import("@playwright/test").Page): Promise { + const value = await visible(page.locator("#share-input")).inputValue(); + return value + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")); +} + +test.describe("scanning the printed strips", () => { + test("strip QRs scan into the shares box and open the backup with no password", async ({ page }) => { + const backup = await encryptAndPrintWithShares(page, 2, 3); + expect(backup.stripPngs, "the sheet printed no strip symbols").toHaveLength(3); + + await visible(page.getByRole("tab", { name: "Decrypt" })).click(); + await useTextMode(page); + await visible(page.getByPlaceholder("Enter text to decrypt")).fill(backup.armored); + await visible(page.getByRole("button", { name: /^Use recovery shares$/ })).click(); + + // Strips 2 and 3, for the same reason as the typed test above. + await page.locator("#share-qr-scan-input").setInputFiles([ + png("strip-2.png", backup.stripPngs[1] as Buffer), + png("strip-3.png", backup.stripPngs[2] as Buffer), + ]); + await expect.poll(() => shareBoxLines(page), { timeout: 20_000 }).toEqual([ + backup.shares[1], + backup.shares[2], + ]); + + await visible(page.getByRole("button", { name: /^Decrypt Text$/i })).click(); + await expect(visible(page.locator("#output-text"))).toHaveValue(SECRET, { timeout: 90_000 }); + }); + + test("a whole printed backup scanned in one batch sorts parts from strips", async ({ page }) => { + const backup = await encryptAndPrintWithShares(page, 2, 3); + expect(backup.partPngs.length, "the sheet printed no container symbols").toBeGreaterThan(0); + + await visible(page.getByRole("tab", { name: "Decrypt" })).click(); + await useTextMode(page); + + // Everything photographed off the sheet, strips interleaved with parts, + // through the container box's own scan button. Nothing else is touched: + // not the shares toggle, not the password. + await page.locator("#qr-scan-input").setInputFiles([ + png("strip-1.png", backup.stripPngs[0] as Buffer), + ...backup.partPngs.map((b, i) => png(`part-${i + 1}.png`, b)), + png("strip-3.png", backup.stripPngs[2] as Buffer), + ]); + + await expect(visible(page.locator("#text-secret"))).toHaveValue(backup.armored, { + timeout: 20_000, + }); + await expect( + visible(page.getByRole("button", { name: /^Use a password instead$/ })), + "the scanned strips did not turn on the shares path" + ).toBeVisible(); + expect(await shareBoxLines(page)).toEqual([backup.shares[0], backup.shares[2]]); + + await visible(page.getByRole("button", { name: /^Decrypt Text$/i })).click(); + await expect(visible(page.locator("#output-text"))).toHaveValue(SECRET, { timeout: 90_000 }); + }); + + test("a strip scanned twice is entered once", async ({ page }) => { + const backup = await encryptAndPrintWithShares(page, 2, 3); + + await visible(page.getByRole("tab", { name: "Decrypt" })).click(); + await useTextMode(page); + await visible(page.getByPlaceholder("Enter text to decrypt")).fill(backup.armored); + await visible(page.getByRole("button", { name: /^Use recovery shares$/ })).click(); + + // §4.6 refuses a repeated index, so a duplicate left in the box would fail + // an otherwise sufficient set with nothing to say which photo repeated. + await page.locator("#share-qr-scan-input").setInputFiles([ + png("strip-1.png", backup.stripPngs[0] as Buffer), + png("strip-1-again.png", backup.stripPngs[0] as Buffer), + png("strip-2.png", backup.stripPngs[1] as Buffer), + ]); + await expect.poll(() => shareBoxLines(page), { timeout: 20_000 }).toEqual([ + backup.shares[0], + backup.shares[1], + ]); + + await visible(page.getByRole("button", { name: /^Decrypt Text$/i })).click(); + await expect(visible(page.locator("#output-text"))).toHaveValue(SECRET, { timeout: 90_000 }); + }); +}); + +test.describe("one unlock path at a time", () => { + test("choosing shares after a passkey does not leave the passkey in charge", async ({ page }) => { + const { armored, shares } = await encryptWithShares(page, 2, 3); + + await visible(page.getByRole("tab", { name: "Decrypt" })).click(); + await useTextMode(page); + await visible(page.getByPlaceholder("Enter text to decrypt")).fill(armored); + + const passkey = page.getByRole("button", { name: /^Use a passkey$/ }); + test.skip((await passkey.count()) === 0, "this engine offers no passkey control"); + await visible(passkey).click(); + await visible(page.getByRole("button", { name: /^Use recovery shares$/ })).click(); + await visible(page.locator("#share-input")).fill(`${shares[0]}\n${shares[1]}\n`); + + // The passkey control is hidden while shares are on, so a passkey choice + // left set behind it would ask for a tap and then report that this + // container has no passkey, to someone holding a sufficient share set. + await visible(page.getByRole("button", { name: /^Decrypt Text$/i })).click(); + await expect(visible(page.locator("#output-text"))).toHaveValue(SECRET, { timeout: 90_000 }); + }); +}); From 7fac8376844bc0544acb3f30522c76b0d7f31508 Mon Sep 17 00:00:00 2001 From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:53:48 +0000 Subject: [PATCH 02/34] fix: keep one-time shares and their container from being lost Four defects, each of which could leave an heir without a way in. - The Recovery kit dialog offered keym.py alone, and keym.py reads KEYM v1 only. The app has written v2, then v3, for some time, so a kit saved exactly as offered could open none of its backups. It now lists keym2.py first, requirements.txt, RECOVERY.md, and keym.py labelled as v1-only. (The in-app docs and the inheritance plan already named keym2.py; only the dialog was wrong.) - The one-time shares dialog closed on Escape or a backdrop click, and closing it destroys the only copy of the shares. It now closes only from its X or an explicit "I have saved these shares" button, and it scrolls: with five or more shares it was taller than the viewport, and being fixed and centred, its lower half could not be reached. - The idle lock spared issued shares but wiped outputText, which on the encrypt side is the sealed container (ciphertext) and in Text mode the only copy of it. The dialog went on showing strips that opened nothing, and its Print paper vault button went dark. The container is now spared with the shares. - The encrypt-side inspector plan read the decrypt-side passkey toggle, so it never itemised the passkey slot the worker was about to write. Tests updated to close the shares dialog with its button rather than Escape. Controls: reverting each change fails its own test (kit links, Escape, backdrop, lock, inspector). --- src/components/encryptor-tool.tsx | 76 ++++++++++++++++++---- src/components/ui/dialog.tsx | 3 + tests/browser/container-inspector.spec.ts | 20 +++++- tests/browser/receipt.spec.ts | 2 +- tests/browser/shamir-ui.spec.ts | 38 +++++++++-- tests/browser/share-lifecycle.spec.ts | 10 ++- tests/browser/verify-recovery-lock.spec.ts | 24 ++++++- 7 files changed, 147 insertions(+), 26 deletions(-) diff --git a/src/components/encryptor-tool.tsx b/src/components/encryptor-tool.tsx index 00ed4d8..3f92953 100644 --- a/src/components/encryptor-tool.tsx +++ b/src/components/encryptor-tool.tsx @@ -94,7 +94,7 @@ import { AudioStegoTool } from "@/components/audio-stego-tool"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; import { Textarea } from "@/components/ui/textarea"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; @@ -1904,7 +1904,13 @@ export function EncryptorTool() { setTextInputRejected(null); setShowTextSecret(false); setTextSecretSeedStatus("none"); - setOutputText(''); + // Spared with the shares, and for the same reason. Issued shares exist + // only on the encrypt side, where `outputText` is the sealed container, + // ciphertext rather than a secret, and in Text mode the only copy of it. + // The lock used to keep the shares and wipe this, so the dialog went on + // showing strips that now opened nothing, its Print paper vault button + // went dark, and the note under it blamed "a file container". + if (!(opts?.sparingIssuedShares && issuedSharesRef.current !== null)) setOutputText(''); setShowDecryptedText(false); setDecryptInfo(null); setSlotTableWarning(false); @@ -4846,7 +4852,11 @@ export function EncryptorTool() { cipherId: cipherChoice, keyFile: useKeyFile && keyFile !== null, shares: shamirEnabled ? { threshold: shamirThreshold, count: shamirCount } : null, - passkey: usePasskey, + // The encrypt-side enrol switch. `usePasskey` is the decrypt-side unlock + // choice, false on this tab, so the plan used to omit the passkey slot + // the worker was about to write: one way in and one byte-map segment + // short. + passkey: passkeyEnabled, inputBytes: inputType === "file" ? (file?.size ?? null) @@ -4857,7 +4867,7 @@ export function EncryptorTool() { }, [ mode, kdfChoice, argonMemoryMiB, argonTimeCost, argonParallelism, cipherChoice, useKeyFile, keyFile, shamirEnabled, shamirThreshold, - shamirCount, usePasskey, inputType, file, textSecret, + shamirCount, passkeyEnabled, inputType, file, textSecret, ]); /** What the next printed sheet says about rehearsal, or nothing yet. */ @@ -5437,7 +5447,22 @@ export function EncryptorTool() { } }} > - + {/* + Closing this dialog destroys the only copy of the shares, so it closes + only when someone means it: the X or the button at the bottom. Escape + and a click on the backdrop are the two gestures people make without + deciding anything (dismissing a toast, reaching for another window), + and each of them used to turn a share set into scrap. + */} + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > @@ -5685,6 +5710,14 @@ export function EncryptorTool() { )} )} + +

+ + + +
@@ -5719,17 +5752,33 @@ export function EncryptorTool() {
+ {/* + keym2.py first, because it is the one that opens what this app + writes. The kit used to offer keym.py alone, and keym.py reads + KEYM v1 only: an heir who saved the kit exactly as offered held a + script that refuses every backup made since v2. + */} {[ { - href: `${BASE_PATH}/recovery/keym.py`, - name: 'keym.py', - what: 'A standalone Python decryptor. Standard library plus one dependency for Argon2id; no browser, no npm, no network.', + href: `${BASE_PATH}/recovery/keym2.py`, + name: 'keym2.py', + what: `The standalone Python decryptor for KEYM v2 and v3, which covers every backup this app writes (it writes KEYM v${KEYM2_VERSION}). Opens password, key-file and recovery-share containers; no browser, no npm, no network.`, + }, + { + href: `${BASE_PATH}/recovery/requirements.txt`, + name: 'requirements.txt', + what: 'The two libraries the scripts need, pinned. RECOVERY.md shows how to download them now so the install works offline later.', }, { href: `${BASE_PATH}/recovery/RECOVERY.md`, name: 'RECOVERY.md', what: 'The procedure in writing, including how to decrypt by hand if even the script is gone. Worth printing and storing with the backup.', }, + { + href: `${BASE_PATH}/recovery/keym.py`, + name: 'keym.py', + what: 'Only for older KEYM v1 backups. It refuses v2 and v3 containers; use keym2.py for those.', + }, ].map((item) => (
These are the same files as in the repository, copied into this build — so the copy you save is the one that matches the version that encrypted your - data. The format itself is documented in{' '} - FORMAT.md, and{' '} - keym.py was written - from that document independently of the code running here — which is how a - specification bug got caught before it shipped. + data. The format itself is specified in{' '} + docs/FORMAT-V2-DESIGN.md in + the repository, and{' '} + keym2.py was written + from that document independently of the code running here, which is how + specification bugs got caught before they shipped.

diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 834e41c..457a4c1 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -92,8 +92,11 @@ const DialogDescription = React.forwardRef< )) DialogDescription.displayName = DialogPrimitive.Description.displayName +const DialogClose = DialogPrimitive.Close + export { Dialog, + DialogClose, DialogPortal, DialogOverlay, DialogTrigger, diff --git a/tests/browser/container-inspector.spec.ts b/tests/browser/container-inspector.spec.ts index bb798af..e98e50e 100644 --- a/tests/browser/container-inspector.spec.ts +++ b/tests/browser/container-inspector.spec.ts @@ -100,6 +100,24 @@ test("encrypt: the itemisation is one click away, and still restates the form", await expect(byteMapSlots(page)).toHaveCount(1); }); +test("encrypt: passkey quick access is itemised as a way in before sealing", async ({ page }) => { + const passkeySwitch = page.locator("#passkey-enabled"); + const advanced = visible(page.getByRole("button", { name: /^Advanced/ })); + if ((await advanced.getAttribute("aria-expanded")) !== "true") await advanced.click(); + test.skip((await passkeySwitch.count()) === 0, "this engine offers no passkey control"); + + await visible(page.getByRole("button", { name: "Show the header it will write" })).click(); + await expect(byteMapSlots(page)).toHaveCount(1); + + await visible(passkeySwitch).click(); + await expect(visible(passkeySwitch)).toHaveAttribute("aria-checked", "true"); + + // The plan restates the form. With the enrol switch on, the worker writes a + // second slot, so the plan must say so: a row and a second byte-map segment. + await expect(inspector(page)).toContainText("WebAuthn PRF"); + await expect(byteMapSlots(page), "the plan left out the passkey slot it will write").toHaveCount(2); +}); + test("encrypt: real input opens the itemisation without being asked", async ({ page }) => { await useTextMode(page); const pane = inspector(page); @@ -144,7 +162,7 @@ test("encrypt with shares: the second slot the worker enrolled is itemised", asy // The one-time shares dialog sits over the page; the pane is behind it. await expect(page.getByText(/Save these 3 shares now/)).toBeVisible({ timeout: 30_000 }); - await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "I have saved these shares" }).click(); await expect(page.getByText(/Save these/)).toHaveCount(0); // The byte is the authority: slot 0 is the passphrase, slot 1 the share diff --git a/tests/browser/receipt.spec.ts b/tests/browser/receipt.spec.ts index bf61e7b..52e8174 100644 --- a/tests/browser/receipt.spec.ts +++ b/tests/browser/receipt.spec.ts @@ -70,7 +70,7 @@ test("its buttons download the container, print the paper vault, and rehearse fr // The one-time shares dialog sits over the receipt; an owner who has copied // the strips closes it. await expect(page.getByText(/Save these 3 shares now/)).toBeVisible({ timeout: 30_000 }); - await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "I have saved these shares" }).click(); await expect(page.getByText(/Save these/)).toHaveCount(0); await expect(receipt(page)).toBeVisible(); diff --git a/tests/browser/shamir-ui.spec.ts b/tests/browser/shamir-ui.spec.ts index 2aa5ccb..d59faf8 100644 --- a/tests/browser/shamir-ui.spec.ts +++ b/tests/browser/shamir-ui.spec.ts @@ -81,12 +81,12 @@ async function encryptWithShares( timeout: 90_000, }); const shares = await page.locator("p.font-mono").allTextContents(); - // Escape rather than hunting for a close control. Radix's dismiss button is - // an icon whose accessible name and attributes are an implementation detail - // of the component library, and a locator built on those is a portability - // hazard between engines — which is precisely how the U28 clipboard test put - // main red on two of three. - await page.keyboard.press("Escape"); + // The dialog's own labelled button, not Radix's icon X: the X's accessible + // name is an implementation detail of the component library, and a locator + // built on it is a portability hazard between engines (how the U28 + // clipboard test put main red on two of three). Escape no longer closes this + // dialog at all; see "the one-time shares survive a stray Escape". + await page.getByRole("button", { name: "I have saved these shares" }).click(); await expect(page.getByText(/Save these/)).toHaveCount(0); const armored = await page.evaluate( @@ -136,6 +136,30 @@ test.describe("enrolling a share set", () => { }); }); +test.describe("the one-time shares dialog", () => { + test("survives a stray Escape and a backdrop click, and closes when asked", async ({ page }) => { + await page.goto("/"); + await useTextMode(page); + await selectCrypto(page, "pbkdf2", "aes"); + await enableShares(page, 2, 3); + await visible(page.getByPlaceholder("Enter text to encrypt")).fill(SECRET); + await visible(page.getByPlaceholder("Enter a strong password")).fill(PASSWORD); + await visible(page.getByRole("button", { name: /^Encrypt Text$/i })).click(); + const title = page.getByText(/Save these 3 shares now/); + await expect(title).toBeVisible({ timeout: 90_000 }); + + // The dialog says the shares are shown once and cannot be reissued. Each + // of these used to close it, and closing it destroys them. + await page.keyboard.press("Escape"); + await expect(title, "Escape discarded the one-time shares").toBeVisible(); + await page.mouse.click(5, 5); + await expect(title, "a click on the backdrop discarded the one-time shares").toBeVisible(); + + await page.getByRole("button", { name: "I have saved these shares" }).click(); + await expect(title).toHaveCount(0); + }); +}); + test.describe("a share pasted into the wrong box", () => { test("is named as a share rather than failing as a container", async ({ page }) => { const { shares } = await encryptWithShares(page, 2, 3); @@ -269,7 +293,7 @@ async function encryptAndPrintWithShares( ); const toBuffer = (d: string) => Buffer.from(d.split(",")[1] as string, "base64"); - await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "I have saved these shares" }).click(); await expect(page.getByText(/Save these/)).toHaveCount(0); const armored = await page.evaluate( () => (document.querySelector("#output-text") as HTMLTextAreaElement).value diff --git a/tests/browser/share-lifecycle.spec.ts b/tests/browser/share-lifecycle.spec.ts index ad44016..0bb7fcd 100644 --- a/tests/browser/share-lifecycle.spec.ts +++ b/tests/browser/share-lifecycle.spec.ts @@ -229,6 +229,14 @@ test.describe("issued shares survive what they must", () => { page.getByPlaceholder("Enter a strong password"), "the lock spared the shares but also spared the password" ).toHaveValue(""); + + // Sparing the shares means nothing without the container they open. In + // Text mode the sealed container exists only on screen, and the dialog's + // print button is gated on it being there. + await expect( + page.getByRole("dialog").getByRole("button", { name: /Print paper vault/i }), + "the lock kept the shares but wiped the only copy of the container they open" + ).toBeEnabled(); }); test("the idle lock fires once, not once a second", async ({ page }) => { @@ -288,7 +296,7 @@ test.describe("issued shares survive what they must", () => { // The control on the test above. Sparing them from the timer must not // spare them from the panic button, or the button stops meaning anything. await issueShares(page); - await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "I have saved these shares" }).click(); await expect(page.getByText(/Save these 3 shares now/)).toHaveCount(0); await visible(page.getByPlaceholder("Enter text to encrypt")).fill("something"); diff --git a/tests/browser/verify-recovery-lock.spec.ts b/tests/browser/verify-recovery-lock.spec.ts index 18505f5..a8ba110 100644 --- a/tests/browser/verify-recovery-lock.spec.ts +++ b/tests/browser/verify-recovery-lock.spec.ts @@ -118,17 +118,35 @@ test.describe("recovery kit", () => { await visible(page.getByRole("button", { name: /Recovery kit/i })).click(); const dialog = page.getByRole("dialog"); - await expect(dialog.getByText("keym.py").first()).toBeVisible(); await expect(dialog.getByText("RECOVERY.md").first()).toBeVisible(); // Same-origin and download-flagged, so saving it does not navigate away // from a page that may be holding a decrypted secret. const links = dialog.getByRole("link", { name: /Save/i }); - await expect(links).toHaveCount(2); + const hrefs: string[] = []; for (const link of await links.all()) { await expect(link).toHaveAttribute("download", ""); - expect(await link.getAttribute("href")).toMatch(/\/recovery\/(keym\.py|RECOVERY\.md)$/); + hrefs.push((await link.getAttribute("href")) ?? ""); } + for (const href of hrefs) { + expect(href).toMatch(/\/recovery\/(keym2?\.py|RECOVERY\.md|requirements\.txt)$/); + } + // The script that opens what this app writes. The kit offered keym.py + // alone for as long as the app wrote v2 and v3, and keym.py reads KEYM v1 + // only, so a kit saved exactly as offered could open none of them. + expect(hrefs.some((h) => h.endsWith("/recovery/keym2.py")), "the kit does not offer keym2.py").toBe(true); + expect(hrefs.some((h) => h.endsWith("/recovery/requirements.txt")), "the kit does not offer requirements.txt").toBe(true); + // And it is listed first, ahead of the v1-only script. + expect(hrefs[0]).toMatch(/\/recovery\/keym2\.py$/); + }); + + test("keym2.py and requirements.txt are served and are the real thing", async ({ page, baseURL }) => { + const py = await page.request.get(`${baseURL}${appPath("/recovery/keym2.py")}`); + expect(py.status(), "keym2.py is not being served").toBe(200); + expect(await py.text(), "keym2.py is not the v2 reference").toContain("KEYM v2"); + const req = await page.request.get(`${baseURL}${appPath("/recovery/requirements.txt")}`); + expect(req.status(), "requirements.txt is not being served").toBe(200); + expect(await req.text()).toMatch(/argon2-cffi/); }); test("the service worker precaches the kit for offline use", async ({ From c8c61ad790cf4425c9fe8910d901518ddfa74f03 Mon Sep 17 00:00:00 2001 From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com> Date: Wed, 23 Sep 2026 08:08:02 +0000 Subject: [PATCH 03/34] fix(scripts): close the shares dialog with its button in the audits The previous commit stopped Escape from closing the one-time shares dialog. The palette and icon audits closed it with Escape, so both then timed out waiting for the Decrypt tab behind a dialog that was still open. --- scripts/icon-audit.mjs | 4 +++- scripts/palette-audit.mjs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/icon-audit.mjs b/scripts/icon-audit.mjs index 34c0572..a6ce112 100644 --- a/scripts/icon-audit.mjs +++ b/scripts/icon-audit.mjs @@ -151,7 +151,9 @@ try { await sharesDialog.getByTestId('rehearsal-result').waitFor({ timeout: 60_000 }); await page.waitForTimeout(400); icons.push(...(await scan(page, 'shares dialog · rehearsal'))); - await page.keyboard.press('Escape'); + // The shares dialog ignores Escape (closing it destroys the shares), so + // it is closed with its own button. + await sharesDialog.getByRole('button', { name: 'I have saved these shares' }).click(); // The receipt the seal left behind, now that the dialog is out of the way. await page.getByTestId('seal-receipt').waitFor({ timeout: 15_000 }); diff --git a/scripts/palette-audit.mjs b/scripts/palette-audit.mjs index 3ef1835..5e8d1d6 100644 --- a/scripts/palette-audit.mjs +++ b/scripts/palette-audit.mjs @@ -363,7 +363,9 @@ try { await sharesDialog.getByTestId('rehearsal-result').waitFor({ timeout: 60_000 }); await page.waitForTimeout(400); collect(await scan(page, 'shares dialog · rehearsal')); - await page.keyboard.press('Escape'); + // The shares dialog ignores Escape (closing it destroys the shares), so + // it is closed with its own button. + await sharesDialog.getByRole('button', { name: 'I have saved these shares' }).click(); // The receipt the seal left behind, now that the dialog is out of the way. await page.getByTestId('seal-receipt').waitFor({ timeout: 15_000 }); From add117cff4b3adddc8683fa267ebe5f9460e13bd Mon Sep 17 00:00:00 2001 From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com> Date: Wed, 23 Sep 2026 08:08:02 +0000 Subject: [PATCH 04/34] fix: carrier, service worker and recovery-script defects, and doc drift Audio: a carrier decoded through Web Audio came back with the low bit flipped on every sample above 16384. Browsers decode a 16-bit sample s as s / 32768, and the inverse scaled positives by 32767, so a stego WAV revealed through that path lost its payload and was reported as a wrong password. One scale, 32768, then clamp. The carrier path also chose the exact WAV parser by name or MIME type alone, so a stego WAV that lost its extension in a messenger went through Web Audio and was resampled; WAVs are now also recognised by their RIFF/WAVE bytes. New test:audio-decode-scale round-trips all 65,536 sample values; the old scale fails 16,383 of them. Service worker: install fetched the un-hashed shell with addAll's default cache mode, so a deploy inside GitHub Pages' max-age window could freeze the previous index.html (and crypto-worker.js, the recovery kit) into the new version's cache: a false tamper report beside the new SHA256SUMS, and a blank page offline. The shell is now fetched with cache: 'reload'. requirements.txt joins the precached kit, since the kit dialog offers it. New test:sw-precache runs public/sw.js in a stub worker scope. keym2.py: a text backup with a blank first line or a leading space was "unknown" to detect(), fell through to the binary parser, and printed "decryption failed". Section 7 says every reader strips ASCII whitespace; the text prefixes are now sniffed after it. A leading BOM is still refused, as the spec does not cover it (the app accepts one; that divergence is recorded as an open decision, not changed here). keym.py: an 8-14 byte v1 file escaped as a struct.error or IndexError traceback. It is now a KeymError, and the self-test truncates a real header at every length. RECOVERY.md told heirs to "use the recovery shares" and never said how. It now has a section with the keym2.py --shares-from command, and recovery_test.py runs it against a share set issued by the shipping enrolment: two of three strips (one lower-cased, hyphens typed as spaces) open the backup with nothing on stdin, and one strip does not. The "slots" paragraph no longer claims app containers have exactly one. Docs: README named a --outfile flag that does not exist (it is --out). SECURITY.md said length leaks "to within a 1 MiB chunk" (FORMAT-V2 section 8 says exactly) and called v2 the format the app writes (it writes v3). The in-app docs said 99 d6 rolls clear 256 bits (255.9; the tool itself requires 100). Controls: each new check was run against the reverted code and fails. --- .github/workflows/ci.yml | 20 ++++++ README.md | 6 +- SECURITY.md | 11 +-- docs/RECOVERY.md | 23 +++++- package.json | 2 + public/sw.js | 15 +++- reference/keym.py | 22 ++++++ reference/keym2.py | 23 ++++-- reference/recovery_test.py | 51 ++++++++++++++ scripts/audio-decode-scale-test.mjs | 105 ++++++++++++++++++++++++++++ scripts/sw-precache-test.mjs | 94 +++++++++++++++++++++++++ src/components/audio-stego-tool.tsx | 3 +- src/components/docs-guide.tsx | 2 +- src/lib/audio-stego.ts | 27 +++++-- 14 files changed, 383 insertions(+), 21 deletions(-) create mode 100644 scripts/audio-decode-scale-test.mjs create mode 100644 scripts/sw-precache-test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5858b24..016dace 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,26 @@ jobs: - name: Malformed audio carriers are all typed carrier errors run: npm run test:audio-malformed + # A carrier decoded through Web Audio must come back sample-exact. Web + # Audio returns a 16-bit sample s as s / 32768; scaling positives back by + # 32767 flipped the low bit of every sample above 16384, which is where + # the payload lives. A WAV is also recognised by its RIFF/WAVE bytes, not + # only its name, so a renamed stego file keeps the exact parser. Controls + # bite: the 32767 scale fails 16,383 values; a sniff that never matches + # fails the byte check. + - name: Audio decode is sample-exact, and WAVs are sniffed by content + run: npm run test:audio-decode-scale + + # The service worker's shell URLs are not content-hashed, so install must + # fetch them past the HTTP cache (cache: 'reload'). Otherwise a deploy + # inside Pages' max-age window froze the previous index.html into the new + # version's cache: a false tamper report beside the new SHA256SUMS, and a + # blank page offline. Runs public/sw.js itself in a stub worker scope. + # Controls bite: plain-string addAll fails the reload check; dropping + # requirements.txt from the shell fails the recovery-kit check. + - name: Service worker precaches a fresh shell and the whole recovery kit + run: npm run test:sw-precache + # The "sealed" verdict must rest on the whole egress-relevant CSP set, not # connect-src alone: connect-src 'none' stops fetch/XHR/WebSocket but a # form POST is governed by form-action, which does not fall back to diff --git a/README.md b/README.md index 676b1bd..788f4c9 100644 --- a/README.md +++ b/README.md @@ -444,7 +444,7 @@ None of the three protects a compromised device. That is the next section. | Wrong password indistinguishable from corruption | **By design.** Errors are generic, to avoid an oracle. | | Key material is wiped | **Best-effort.** Buffers are zero-filled; the JavaScript GC may retain copies. | | A hostile container cannot burn your CPU unannounced | **Disclosed, not refused.** Unlocking derives a key for every password slot in turn, before anything is authenticated. §6 bounds each slot and caps the count at 8, so the total is bounded — but high: measured at 41 s for eight Argon2id slots at the ceiling and 315 s for eight PBKDF2 ones. The app cannot refuse such a file without also stranding a conforming backup, so it prices the header before starting, says how much longer than usual it will take, and gives you a **Stop** button that terminates the derivation and keeps what you typed. | -| Recovered plaintext on disk | **Enforced for `--outfile`, and only there.** `reference/keym2.py` writes decrypted output at `0600` and narrows an existing file to match, so a recovery on a shared machine is not readable by other accounts. Redirecting stdout instead hands file creation to the shell, which uses your umask — usually world-readable. | +| Recovered plaintext on disk | **Enforced for `--out`, and only there.** `reference/keym2.py` writes decrypted output at `0600` and narrows an existing file to match, so a recovery on a shared machine is not readable by other accounts. Redirecting stdout instead hands file creation to the shell, which uses your umask — usually world-readable. | | Clipboard is cleared | **Best-effort, and only the current entry.** The browser may refuse the write. More importantly, clipboard *history* — Windows Win+V, a clipboard manager, phone keyboard history, cloud clipboard sync — keeps its own copy that no website can reach or even detect. If you copy a seed phrase on a machine with history enabled, treat it as still there. | | A backup too big for the app is not a lost backup | **Bounded by this build, not by the format.** A browser tab holds the container and the recovered file at once, so the app stops at 100 MB. §5's chunking means the format has no such limit and `reference/keym2.py` has none either — a 150 MiB backup round-trips through it in about two seconds. An oversized container is refused with the command that opens it, rather than being told to pick a smaller file, which is not a thing you can do to a backup. | @@ -644,9 +644,9 @@ under whatever is installed. `reference/requirements.txt` records the versions these scripts were actually run against, if you would rather pin. **What it does so a recovery does not leak what it just recovered.** With -`--outfile`, the plaintext is written `0600` — owner only — and an existing +`--out`, the plaintext is written `0600` — owner only — and an existing file at that path is narrowed to match rather than keeping the permissions it -already had. That covers `--outfile` and nothing else: redirect stdout instead +already had. That covers `--out` and nothing else: redirect stdout instead (`decrypt --in backup.keym > seed.txt`) and the *shell* creates the file, at your umask, which on most systems is world-readable. Separately, passing a secret as an argument — `--password`, `--share`, `--prf-output` — prints a diff --git a/SECURITY.md b/SECURITY.md index 24c0862..ad11f8e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -36,9 +36,9 @@ Keymaker is a **client-side** browser encryption PWA. In scope: - The cryptographic core (`src/lib/crypto.ts` — frozen legacy format; `src/lib/keymaker-crypto.ts` — KEYM v1; `src/lib/keym-v2.ts` and - `src/lib/keym-v2-shamir.ts` — KEYM v2, the format the app writes today) and - its wire formats (IBTZ v0/v1, KEYM v1, KEYM v2). See `docs/FORMAT.md` and - `docs/FORMAT-V2-DESIGN.md`. + `src/lib/keym-v2-shamir.ts` — KEYM v2 and v3; the app writes v3) and + its wire formats (IBTZ v0/v1, KEYM v1, KEYM v2, KEYM v3). See `docs/FORMAT.md`, + `docs/FORMAT-V2-DESIGN.md` and `docs/FORMAT-V3-DESIGN.md`. - Key derivation, nonce/salt generation, memory handling (`secureErase`), and authentication of ciphertext and header metadata (AAD). - The static export's content security policy and supply-chain (dependency) @@ -61,8 +61,9 @@ Out of scope / known limitations: 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 — to within a 1 MiB chunk for KEYM v2, - plus a small constant for v1. If the *size* of what is being protected is + 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. diff --git a/docs/RECOVERY.md b/docs/RECOVERY.md index 0e7e816..4480252 100644 --- a/docs/RECOVERY.md +++ b/docs/RECOVERY.md @@ -152,8 +152,9 @@ 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 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 -exactly one — your password. If yours says more, any of the secrets listed will +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 work, and you only need one of them. ## Step 4 — Decrypt @@ -170,6 +171,24 @@ reported one was required. Omit `--out` to print to the terminal. It lands in your shell history and is visible to every other user on the machine while the key derivation runs — seconds, for Argon2id. +### With recovery shares instead of the password + +If you were given recovery strips rather than the password, you need as many as +the strip itself says ("Any 2 of them open it"). Put the code from each strip +(the line that starts `KMSHARE2:` or `KMSHARE1:`) into a plain text file, one +per line, then: + +```bash +python3 keym2.py decrypt --in backup.keym --shares-from shares.txt --out recovered.txt +``` + +No password is asked for. Case, spaces and hyphens inside a code do not matter, +lines starting with `#` are ignored, and it does not matter which strips you +use or in what order. If it reports `decryption failed`, you have fewer strips +than the backup needs, or one of them was mistyped. `--share KMSHARE2:…`, +repeated once per strip, also works, but like `--password` it leaves the codes +in your shell history. + --- ## If the web app said the backup was too large diff --git a/package.json b/package.json index df48316..4a81a30 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "test:dearmor-whitespace": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/dearmor-whitespace-test.mjs", "test:audio-wav-bounds": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/audio-wav-bounds-test.mjs", "test:audio-malformed": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/audio-malformed-test.mjs", + "test:audio-decode-scale": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/audio-decode-scale-test.mjs", + "test:sw-precache": "node scripts/sw-precache-test.mjs", "test:seal-verdict": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/seal-verdict-test.mjs", "test:csp-egress": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/csp-egress-gate-test.mjs", "test:release-notes": "node scripts/release-notes-test.mjs", diff --git a/public/sw.js b/public/sw.js index 30a4bf6..fb26a14 100644 --- a/public/sw.js +++ b/public/sw.js @@ -54,6 +54,9 @@ const APP_SHELL = [ `${BASE}/recovery/RECOVERY.md`, `${BASE}/recovery/keym.py`, `${BASE}/recovery/keym2.py`, + // The pinned dependency list the kit dialog offers beside the scripts, and + // the file RECOVERY.md's install step reads. + `${BASE}/recovery/requirements.txt`, `${BASE}/logo.svg`, // The hero background plate. Named here rather than left to runtime caching // for the same reason as everything else in this list: isCacheableAsset() @@ -106,7 +109,17 @@ self.addEventListener('install', (event) => { // chunks, which include the lazily imported crypto dependencies // (hash-wasm for Argon2id, @noble/ciphers for ChaCha, the EFF wordlist) // that a user may not touch until after the network is gone. - return cache.addAll(APP_SHELL).then(() => cache.addAll(PRECACHE_ASSETS)); + // + // The shell is fetched with `cache: 'reload'`. Its URLs are not + // content-hashed, so the browser's HTTP cache may still hold the + // previous deploy's copy (GitHub Pages serves them with max-age=600), + // and addAll's default fetch would take that copy and freeze it into + // this version's cache: old HTML beside a new SHA256SUMS, which the + // sealed status reports as tampering, and which offline asks for chunks + // the activate step has already deleted. The chunks are hashed and + // immutable, so an HTTP-cached copy of one is the right copy. + const shell = APP_SHELL.map((url) => new Request(url, { cache: 'reload' })); + return cache.addAll(shell).then(() => cache.addAll(PRECACHE_ASSETS)); }) ); diff --git a/reference/keym.py b/reference/keym.py index fbef87e..824bc4b 100644 --- a/reference/keym.py +++ b/reference/keym.py @@ -211,6 +211,12 @@ def parse(data: bytes) -> Header: raise KeymError(f"unknown cipher_id {cipher_id}") off = 7 + # The KDF parameters and the flags byte must be present before anything + # unpacks them. Without this, an 8-12 byte file reached struct.unpack_from + # and escaped as a struct.error traceback instead of a KeymError, which the + # CLI reports cleanly: a truncated backup is what a recovery tool meets. + if len(data) < off + (4 if kdf_id == KDF_PBKDF2 else 7) + 1: + raise KeymError("too short for declared parameters") if kdf_id == KDF_PBKDF2: # section 3: 4 bytes, uint32 big-endian iteration count. (iterations,) = struct.unpack_from(">I", data, off) @@ -369,6 +375,22 @@ def _selftest() -> int: ok = got == pt print(f" {'ok ' if ok else 'FAIL'} kdf={kdf_id} cipher={cipher_id} keyfile={kf is not None}") failures += 0 if ok else 1 + # Every truncation of a real header is a KeymError, never a raw struct or + # index error: a truncated file is the ordinary case for a recovery tool. + for kdf_id, params in ((KDF_PBKDF2, fast_pbkdf2), (KDF_ARGON2ID, fast_argon)): + ct = encrypt(b"x", "pw", None, kdf_id, params, CIPHER_AES_256_GCM) + escaped = [] + for n in range(len(ct)): + try: + parse(ct[:n]) + except KeymError: + pass + except Exception as exc: # noqa: BLE001 + escaped.append(f"{n}:{type(exc).__name__}") + ok = not escaped + print(f" {'ok ' if ok else 'FAIL'} kdf={kdf_id} every truncation is a KeymError" + + ("" if ok else f" (escaped: {', '.join(escaped)})")) + failures += 0 if ok else 1 print("selftest passed" if not failures else f"{failures} failures") return 1 if failures else 0 diff --git a/reference/keym2.py b/reference/keym2.py index e65606d..0349c2e 100644 --- a/reference/keym2.py +++ b/reference/keym2.py @@ -2817,20 +2817,29 @@ def detect(data: bytes) -> str: §7.2's self-extracting page is the exception to "prefix", and the comment at that branch explains why the exception costs nothing. + + The text encodings are sniffed after leading ASCII whitespace, because §7 + says every reader strips it. A backup saved with a blank first line, or + pasted with a stray space, was "unknown" here, so `decrypt` handed the text + to the binary parser and printed "decryption failed": a wrong-password + message for a file the app opens. Whitespace bytes start none of the + prefixes, so this keeps the cases disjoint. The binary checks still read + the raw bytes: whitespace is not part of any binary format. """ - if data.startswith(ARMOR_PREFIX): + text = data.lstrip() + if text.startswith(ARMOR_PREFIX): return "keym2-armor" - if data.startswith(b"KEYM1:"): + if text.startswith(b"KEYM1:"): return "keym1-armor" # Both share versions route the same: the label is what the reader does with # it, and §4.6's KMSHARE1 and §4.6-v2's KMSHARE2 are the same box. - if data.startswith(SHARE_PREFIX.encode()) or data.startswith(SHARE2_PREFIX.encode()): + if text.startswith(SHARE_PREFIX.encode()) or text.startswith(SHARE2_PREFIX.encode()): return "keym2-share" # §7.1. A part is the likeliest wrong-box paste of them all: reassembling a # paper backup means scanning symbols one at a time, and the first one has # to go somewhere. Naming it is the only useful thing to say. KMPART2 (§7.3) # is the same box. - if data.startswith(PART_PREFIX.encode()) or data.startswith(PART2_PREFIX.encode()): + if text.startswith(PART_PREFIX.encode()) or text.startswith(PART2_PREFIX.encode()): return "keym2-part" if data.startswith(MAGIC): return f"keym-binary-v{data[4]}" if len(data) > 4 else "keym-binary" @@ -3408,6 +3417,12 @@ def forge(core_bytes: bytes, slot_prefix: bytes) -> bytes: check("armor round-trips", dearmor(armor(base)) == base) check("armor is unpadded base64url", "=" not in armor(base)) check("armor prefix is case-sensitive", detect(b"KEYM2:abc") != "keym2-armor") + check("armor after a blank line and spaces is armor (§7 strips ASCII whitespace)", + detect(b"\n \r\n\tkeym2:AAAA") == "keym2-armor") + check("a share after leading whitespace is a share", + detect(b"\n" + SHARE2_PREFIX.encode() + b"AAAA") == "keym2-share") + check("whitespace before the binary magic is not a binary container", + not detect(b"\n" + MAGIC + b"\x03").startswith("keym-binary")) check("v2 armor detected", detect(armor(base).encode()) == "keym2-armor") check("binary detected", detect(base) == "keym-binary-v2") check("v1 armor no longer collides with the magic", diff --git a/reference/recovery_test.py b/reference/recovery_test.py index 08e3689..c4c315f 100644 --- a/reference/recovery_test.py +++ b/reference/recovery_test.py @@ -208,6 +208,45 @@ def main() -> int: ) (tmp / "out.bin").unlink(missing_ok=True) + # ------------------------------------------------------------------ + # Step 4, with recovery shares instead of the password. + # ------------------------------------------------------------------ + # The share set is issued by the shipping enrolment (addShamirSlotKeym2, + # through the bridge) on a container the shipping encryptor wrote, and + # opened with the page's own command: two of three strips in a file + # with a comment line, one of them lower-cased with its hyphens typed + # as spaces, the way a person copying it by hand might, and nothing on + # stdin. + print("\nStep 4 — decrypt with recovery shares, no password:") + base = js_encrypt(3, SECRET, "pbkdf2", "aes", None, tmp, tag="-shares") + shared, issued = tmp / "shared.keym", tmp / "issued.txt" + r = subprocess.run( + ["node", str(BRIDGE), "addshares", "--password", PASSWORD, + "--in", str(base), "--out", str(shared), "--shares-out", str(issued), + "--threshold", "2", "--shares", "3", + "--salt", os.urandom(32).hex(), "--share-secret", os.urandom(32).hex(), + "--share-coefficients", os.urandom(32).hex()], + capture_output=True, text=True, cwd=ROOT) + strips = [ln for ln in issued.read_text().split("\n") if ln.strip()] if r.returncode == 0 else [] + check(len(strips) == 3, "the shipping enrolment issued three strips", r.stderr.strip()[:160]) + if len(strips) == 3: + prefix, _, code = strips[2].partition(":") + (tmp / "shares.txt").write_text( + "# strips 1 and 3 of 3\n" + strips[0] + "\n" + + prefix + ":" + code.lower().replace("-", " ") + "\n") + r = cli(2, ["decrypt", "--in", str(shared), "--shares-from", + str(tmp / "shares.txt"), "--out", str(tmp / "out.bin")], stdin="") + recovered = (tmp / "out.bin").read_bytes() if r.returncode == 0 else b"" + check(r.returncode == 0 and recovered == SECRET, + "two of three strips open the backup with no password", + r.stderr.strip()[:160]) + (tmp / "out.bin").unlink(missing_ok=True) + (tmp / "one.txt").write_text(strips[1] + "\n") + r = cli(2, ["decrypt", "--in", str(shared), "--shares-from", + str(tmp / "one.txt"), "--out", str(tmp / "out.bin")], stdin="") + check(r.returncode != 0 and not (tmp / "out.bin").exists(), + "one strip of a 2-of-3 set does not", r.stderr.strip()[:160]) + # ------------------------------------------------------------------ # Both wire forms, for both versions. # ------------------------------------------------------------------ @@ -233,6 +272,18 @@ def main() -> int: r.stderr.strip()[:160]) check("=" not in v2_body, "v2 armor is unpadded, as the page shows it") + # A text backup that picked up a blank first line or a leading space on + # the way into a notes app or an email. §7 says readers strip ASCII + # whitespace, and the app always has; keym2.py sniffed the prefix on + # the raw bytes, missed it, and fell through to "decryption failed", + # which sends the reader to retype a password that was never wrong. + padded = tmp / "v2-padded.txt" + padded.write_text("\n \n\tkeym2:" + v2_body + "\n") + r = cli(2, ["decrypt", "--in", str(padded)], stdin=PASSWORD + "\n") + check(r.returncode == 0 and SECRET.decode() in r.stdout, + "keym2: text form after a blank line and leading spaces", + r.stderr.strip()[:160]) + # A pasted backup often arrives wrapped by whatever stored it. The page # says line breaks are fine; that has to be true of both. # diff --git a/scripts/audio-decode-scale-test.mjs b/scripts/audio-decode-scale-test.mjs new file mode 100644 index 0000000..8bf92e8 --- /dev/null +++ b/scripts/audio-decode-scale-test.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * A carrier decoded through Web Audio comes back sample-exact, and a stego WAV + * is recognised by its bytes rather than its name. + * + * Web Audio decoders hand back a 16-bit sample s as the float s / 32768. The + * old inverse scaled positives by 32767, which returned s - 1 for every + * s > 16384: the low bit, which is exactly where an LSB payload lives, flipped + * on half the positive range, and the reveal failed as a wrong password. + * + * decodeToPcm16 takes an AudioContext factory, so a fake context that decodes + * the way browsers do stands in for one. Every int16 value is round-tripped. + * + * Controls shown to bite: restoring the 32767 positive scale fails the + * round-trip on 16,383 values; making isWavBytes return false for everything + * fails the sniff cases. + */ +import esbuild from "esbuild"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, join } from "node:path"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const src = join(HERE, "..", "src", "lib", "audio-stego.ts"); +const out = join(mkdtempSync(join(tmpdir(), "kaud-scale-")), "audio-stego.mjs"); +await esbuild.build({ entryPoints: [src], bundle: true, format: "esm", platform: "node", outfile: out }); + +const { decodeToPcm16, isWavBytes, writePcm16Wav, embedContainer, extractContainer } = await import( + pathToFileURL(out).href +); + +let failed = 0; +const ok = (cond, msg) => { + if (!cond) { console.error("FAIL:", msg); failed++; } + else console.log("ok ", msg); +}; + +/** A stand-in AudioContext whose decode is the browsers': int16 / 32768. */ +function fakeContext(int16, channels) { + return () => ({ + async decodeAudioData() { + const frames = int16.length / channels; + const data = []; + for (let c = 0; c < channels; c++) { + const ch = new Float32Array(frames); + for (let f = 0; f < frames; f++) ch[f] = int16[f * channels + c] / 32768; + data.push(ch); + } + return { + numberOfChannels: channels, + length: frames, + sampleRate: 44100, + getChannelData: (c) => data[c], + }; + }, + close() {}, + }); +} + +// 1. Every int16 value survives the decode unchanged. +{ + const all = new Int16Array(65536); + for (let i = 0; i < 65536; i++) all[i] = i - 32768; + const pcm = await decodeToPcm16(new ArrayBuffer(8), fakeContext(all, 1)); + let wrong = 0; + for (let i = 0; i < all.length; i++) if (pcm.samples[i] !== all[i]) wrong++; + ok(wrong === 0, `every 16-bit sample round-trips through a Web Audio decode (${wrong} changed)`); +} + +// 2. The end to end consequence: a payload embedded in loud audio is extracted +// intact after a Web Audio decode. Loud, because the old scale only broke +// samples above 16384. +{ + const frames = 40_000; + const carrier = new Int16Array(frames * 2); + for (let i = 0; i < carrier.length; i++) carrier[i] = 20_000 + (i % 7000); + const container = new Uint8Array(512); + for (let i = 0; i < container.length; i++) container[i] = (i * 131 + 7) & 0xff; + const stego = embedContainer({ sampleRate: 44100, channels: 2, samples: carrier }, container); + const decoded = await decodeToPcm16(new ArrayBuffer(8), fakeContext(stego.samples, 2)); + let same = false; + try { + const got = extractContainer(decoded); + same = got.length === container.length && got.every((b, i) => b === container[i]); + } catch { + same = false; + } + ok(same, "a payload in loud audio survives a Web Audio decode"); +} + +// 3. A WAV is known by its bytes, whatever it is called. +{ + const wav = writePcm16Wav({ sampleRate: 44100, channels: 1, samples: new Int16Array(16) }); + ok(isWavBytes(wav), "a RIFF/WAVE file is recognised from its bytes"); + ok(!isWavBytes(new TextEncoder().encode("fLaC\0\0\0\"...................")), "a FLAC header is not a WAV"); + ok(!isWavBytes(new TextEncoder().encode("RIFF\0\0\0\0AVI LIST")), "a RIFF that is not WAVE is not a WAV"); + ok(!isWavBytes(new Uint8Array(4)), "a short file is not a WAV, and does not throw"); +} + +if (failed) { + console.error(`\n${failed} check(s) failed`); + process.exit(1); +} +console.log("\nAudio decode scale and WAV sniffing hold."); diff --git a/scripts/sw-precache-test.mjs b/scripts/sw-precache-test.mjs new file mode 100644 index 0000000..9dd7185 --- /dev/null +++ b/scripts/sw-precache-test.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * The service worker's install step fetches the un-hashed app shell past the + * HTTP cache, and the shell includes the whole recovery kit. + * + * `cache.addAll(urls)` fetches with the default cache mode, so a URL the + * browser's HTTP cache still holds from the previous deploy is stored as it + * was: the new version's cache gets the old `/`, which disagrees with the new + * SHA256SUMS (the sealed status reads that as tampering) and, offline, asks + * for chunks the activate step has deleted. The shell URLs are not + * content-hashed, so they must be fetched with `cache: 'reload'`. + * + * This runs public/sw.js itself in a stub worker scope and records what the + * install handler hands to Cache Storage, rather than reading the source. + * + * Controls shown to bite: passing APP_SHELL to addAll as plain strings fails + * the reload check; dropping requirements.txt from APP_SHELL fails the kit + * check. + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import vm from "node:vm"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const source = readFileSync(join(HERE, "..", "public", "sw.js"), "utf8") + .replace("__BUILD_ID__", "test") + .replace("__PRECACHE_ASSETS__", JSON.stringify(["/Keymaker-v2/_next/static/chunks/app-0123abcd.js"])); + +let failed = 0; +const ok = (cond, msg) => { + if (!cond) { console.error("FAIL:", msg); failed++; } + else console.log("ok ", msg); +}; + +class StubRequest { + constructor(url, init = {}) { + this.url = url; + this.cache = init.cache ?? "default"; + } +} + +const added = []; +const handlers = {}; +const scope = { + location: new URL("https://example.test/Keymaker-v2/sw.js"), + addEventListener: (type, fn) => { handlers[type] = fn; }, + registration: { scope: "https://example.test/Keymaker-v2/" }, + clients: { claim: async () => {} }, + skipWaiting: () => {}, +}; +const context = vm.createContext({ + self: scope, + URL, + Request: StubRequest, + Response: class {}, + console, + caches: { + open: async () => ({ + addAll: async (items) => { added.push(...items); }, + put: async () => {}, + }), + keys: async () => [], + match: async () => undefined, + delete: async () => true, + }, + fetch: async () => { throw new Error("no network in this test"); }, +}); +vm.runInContext(source, context, { filename: "sw.js" }); + +let pending; +handlers.install({ waitUntil: (p) => { pending = p; } }); +await pending; + +const asUrl = (item) => (typeof item === "string" ? item : item.url); +const shell = added.filter((item) => !asUrl(item).includes("/_next/static/")); +ok(shell.length > 0, `the install step precached a shell (${shell.length} entries)`); +const stale = shell.filter((item) => typeof item === "string" || item.cache !== "reload"); +ok( + stale.length === 0, + "every un-hashed shell URL is fetched with cache: 'reload'" + + (stale.length ? ` (not: ${stale.map(asUrl).join(", ")})` : "") +); +const urls = added.map(asUrl); +for (const file of ["RECOVERY.md", "keym2.py", "keym.py", "requirements.txt"]) { + ok(urls.includes(`/Keymaker-v2/recovery/${file}`), `the recovery kit's ${file} is precached`); +} +ok(urls.includes("/Keymaker-v2/_next/static/chunks/app-0123abcd.js"), "the hashed chunks are still precached"); + +if (failed) { + console.error(`\n${failed} check(s) failed`); + process.exit(1); +} +console.log("\nThe service worker precaches a fresh shell and the whole kit."); diff --git a/src/components/audio-stego-tool.tsx b/src/components/audio-stego-tool.tsx index f632b8a..28d48d3 100644 --- a/src/components/audio-stego-tool.tsx +++ b/src/components/audio-stego-tool.tsx @@ -44,6 +44,7 @@ import { embedContainer, extractContainer, audioCapacityBytes, + isWavBytes, type Pcm16, } from "@/lib/audio-stego"; @@ -85,7 +86,7 @@ function looksLikeWav(file: File): boolean { * therefore any embedded LSBs) survive; everything else goes through Web Audio. */ async function decodeCarrier(file: File): Promise { const buffer = await file.arrayBuffer(); - if (looksLikeWav(file)) { + if (looksLikeWav(file) || isWavBytes(new Uint8Array(buffer))) { return parseWavToPcm16(new Uint8Array(buffer)); } return decodeToPcm16(buffer); diff --git a/src/components/docs-guide.tsx b/src/components/docs-guide.tsx index df26aa6..66e7fc6 100644 --- a/src/components/docs-guide.tsx +++ b/src/components/docs-guide.tsx @@ -515,7 +515,7 @@ export function DocsGuide({ onNavigate, assetBase = "" }: { onNavigate?: ((targe

Under Tools. It answers one question: how many physical dice rolls do you need for 128 or 256 bits of entropy? Bits per roll is log2 of the number of sides, so - a six-sided die gives about 2.58 bits and 99 rolls clear 256. It computes bits. It does not generate a + a six-sided die gives about 2.58 bits, so 100 rolls clear 256 (99 give 255.9). It computes bits. It does not generate a seed. Roll real dice and turn the rolls into a seed on an air-gapped device with dedicated software.

diff --git a/src/lib/audio-stego.ts b/src/lib/audio-stego.ts index c68c377..75d28ac 100644 --- a/src/lib/audio-stego.ts +++ b/src/lib/audio-stego.ts @@ -144,6 +144,20 @@ function readAscii(view: DataView, offset: number, length: number): string { * refused with a message rather than misread, because the LSB scheme is defined * on 16-bit integers. */ +/** + * Is this a RIFF/WAVE file, going by its bytes rather than its name? + * + * The carrier path used to choose the exact WAV parser by MIME type or a + * `.wav` extension alone. A stego WAV sent through a messenger or saved under + * another name loses both, falls through to Web Audio, and is resampled to the + * device's rate on the way in, which destroys an LSB payload outright. + */ +export function isWavBytes(bytes: Uint8Array): boolean { + if (bytes.length < 12) return false; + const tag = (at: number) => String.fromCharCode(bytes[at]!, bytes[at + 1]!, bytes[at + 2]!, bytes[at + 3]!); + return tag(0) === "RIFF" && tag(8) === "WAVE"; +} + export function parseWavToPcm16(bytes: Uint8Array): Pcm16 { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); if (bytes.length < 44 || readAscii(view, 0, 4) !== "RIFF" || readAscii(view, 8, 4) !== "WAVE") { @@ -257,10 +271,15 @@ export async function decodeToPcm16( for (let c = 0; c < channels; c++) chans.push(audio.getChannelData(c)); for (let f = 0; f < frames; f++) { for (let c = 0; c < channels; c++) { - // Clamp to [-1, 1] then scale. -32768..32767 is asymmetric, so use 32767 - // for positives and 32768 for negatives to reach the full range. - const v = Math.max(-1, Math.min(1, chans[c]![f]!)); - samples[f * channels + c] = v < 0 ? Math.round(v * 32768) : Math.round(v * 32767); + // One scale, 32768, both signs, then clamp. Web Audio decoders turn a + // 16-bit sample s into s / 32768, so this is the exact inverse and every + // sample, low bit included, comes back as it was written. Scaling + // positives by 32767 instead (to reach +1.0 exactly) moved every sample + // above 16384 down by one, which flips the one bit the payload lives + // in: a stego WAV revealed through this path lost its payload and was + // reported as a wrong password. + const v = Math.round(chans[c]![f]! * 32768); + samples[f * channels + c] = Math.max(-32768, Math.min(32767, v)); } } return { sampleRate: audio.sampleRate, channels, samples }; From 43582c1e0cf6d4550cb0a3eaea93e29b1efec39d Mon Sep 17 00:00:00 2001 From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com> Date: Wed, 23 Sep 2026 08:15:02 +0000 Subject: [PATCH 05/34] fix: returning from Tools no longer wipes the form; changelog handleModeChange skipped the reset on the way into Tools, with a comment saying a peek at Tools should not wipe an in-progress form, but coming back out was an ordinary mode change and reset everything anyway: Encrypt, Tools, Encrypt lost the typed secret and the password. A return to the form Tools was opened from now keeps it; going to a different form still resets. Test in uat-polish.spec.ts; restoring the unconditional reset fails it. CHANGELOG gains an Unreleased section covering this branch. --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++ src/components/encryptor-tool.tsx | 15 ++++++++--- tests/browser/uat-polish.spec.ts | 30 +++++++++++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e36ee4a..5254438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## Unreleased + +No container format change. Everything here reads and writes the bytes v2.2.0 +did; the fixture corpus and both parity gates are unchanged and pass. + +### Added +- **Recovery strips scan back in.** The paper vault has always printed a QR on + every recovery strip, and nothing in the app could read one into the shares + box. The shares box now has *Scan share QR images*, and a scan on the Decrypt + tab sorts what it reads: shares to the shares box, container parts to the + container box, so a whole printed backup can be photographed and dropped in + at once. A strip scanned twice is entered once. +- **RECOVERY.md explains recovering with shares**, with the `keym2.py + --shares-from` command, and `recovery_test.py` runs it against a share set + issued by the shipping enrolment. + +### Fixed +- **The Recovery kit offered only `keym.py`, which reads KEYM v1 only.** It now + offers `keym2.py` first, plus `requirements.txt`, and labels `keym.py` as + v1-only. `requirements.txt` is precached with the rest of the kit. +- **Escape or a click outside the one-time shares dialog discarded the + shares.** It now closes only from its X or *I have saved these shares*, and + it scrolls: with five or more shares its lower half was off-screen. +- **The idle lock kept the shares but wiped the container they open** (Text + mode, where the container exists only on screen). +- **Choosing recovery shares after a passkey left the passkey in charge**, so + an heir with enough shares was asked for a tap and told there was no passkey. +- **Returning from Tools wiped the Encrypt or Decrypt form.** +- **The QR scanner could not read the paper vault's own container symbol** off + its canvas; it now retries at several scales before reporting a miss. +- **The encrypt-side inspector never showed the passkey slot** it was about to + write. +- **Audio: a hidden payload revealed through Web Audio lost its low bits** on + every sample above 16384 and was reported as a wrong password. A WAV is also + recognised by its bytes now, not only its name. +- **Service worker: a deploy inside the HTTP cache window could freeze the + previous `index.html` into the new cache**, which the sealed status then + reported as tampering. The shell is fetched with `cache: 'reload'`. +- **`keym2.py` reported "decryption failed" for a text backup with a blank + first line or a leading space.** §7 says readers strip ASCII whitespace. +- **`keym.py` crashed with a traceback on an 8 to 14 byte file.** +- Documentation: README's `--outfile` (the flag is `--out`); SECURITY.md's + length-leak wording (it is exact) and format scope (the app writes v3); the + in-app dice note (100 d6 rolls clear 256 bits, not 99). + ## Keymaker v2.2.0 A design release. Nothing about the container format, the ciphers or the key diff --git a/src/components/encryptor-tool.tsx b/src/components/encryptor-tool.tsx index 3f92953..8998d1f 100644 --- a/src/components/encryptor-tool.tsx +++ b/src/components/encryptor-tool.tsx @@ -1011,6 +1011,9 @@ async function preparePaperParts( export function EncryptorTool() { const [mode, setMode] = useState("encrypt"); + /** The last mode that owns a form (anything but Tools), so a return from a + * Tools peek can be told apart from a switch to a different form. */ + const formModeRef = useRef("encrypt"); const [workspacePage, setWorkspacePage] = useState<"workbench" | "workspace" | "recovery" | "docs">("workbench"); const [compactNavigation, setCompactNavigation] = useState(false); useEffect(() => { @@ -2098,9 +2101,15 @@ export function EncryptorTool() { setMode(newMode as Mode); // The Tools tab has no shared state with encrypt/decrypt — resetting // would only wipe an in-progress form when the user peeks at Tools. - if (newMode !== "tools") { - resetState(); - } + if (newMode === "tools") return; + // And the peek has two halves. Skipping the reset on the way *in* was + // not enough: coming back out was an ordinary mode change, so Encrypt, + // Tools, Encrypt still wiped the secret and password the first half had + // just spared. Returning to the form Tools was opened from is not a mode + // change; going anywhere else still is. + const returning = mode === "tools" && newMode === formModeRef.current; + formModeRef.current = newMode as Mode; + if (!returning) resetState(); }, [mode, resetState]); const handleInputTypeChange = useCallback((newType: InputChoice) => { diff --git a/tests/browser/uat-polish.spec.ts b/tests/browser/uat-polish.spec.ts index e1593c8..cf4fa56 100644 --- a/tests/browser/uat-polish.spec.ts +++ b/tests/browser/uat-polish.spec.ts @@ -361,6 +361,36 @@ test.describe("U2b — the dice log survives a tab switch", () => { ).toHaveValue("64"); }); + test("the encrypt form survives a peek at Tools", async ({ page }) => { + await page.goto("/"); + await useTextMode(page); + const secret = "a secret typed before checking the dice calculator"; + await visible(page.getByPlaceholder("Enter text to encrypt")).fill(secret); + await visible(page.getByPlaceholder("Enter a strong password")).fill(STRONG_PASSWORD); + + // U2's first half, both ways. Going *to* Tools already skipped the reset; + // coming back was an ordinary mode change and wiped the form anyway. + await visible(page.getByRole("tab", { name: "Tools" })).click(); + await expect(visible(page.getByLabel("Rolls completed"))).toBeVisible(); + await visible(page.getByRole("tab", { name: "Encrypt" })).click(); + + await expect( + visible(page.getByPlaceholder("Enter text to encrypt")), + "returning from Tools wiped the secret being typed" + ).toHaveValue(secret); + await expect( + visible(page.getByPlaceholder("Enter a strong password")), + "returning from Tools wiped the password" + ).toHaveValue(STRONG_PASSWORD); + + // Going somewhere else is still a real mode change, and still resets. + await visible(page.getByRole("tab", { name: "Tools" })).click(); + await visible(page.getByRole("tab", { name: "Decrypt" })).click(); + await visible(page.getByRole("tab", { name: "Encrypt" })).click(); + await useTextMode(page); + await expect(visible(page.getByPlaceholder("Enter text to encrypt"))).toHaveValue(""); + }); + /** * The price of forceMount, and the two tests that make sure it is not being * paid. From a0c37b01aff8f2415c826d01c38a95c964399df7 Mon Sep 17 00:00:00 2001 From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com> Date: Wed, 23 Sep 2026 08:20:12 +0000 Subject: [PATCH 06/34] fix: print paper-vault symbols as SVG so multi-part backups scan back A container larger than one symbol is split into full version-40 parts, 181 modules wide with their margin. The sheet drew each into a 300px QRCodeCanvas, and a canvas is a bitmap: at a devicePixelRatio of 1 each module got 1.66 pixels, and printing could only stretch that aliased bitmap to 46mm. A captured part from a 12-part sheet did not decode at any scale from 0.75x to 3x, nearest or cubic. The component's comment assumed 300px gave a 600dpi printer real modules, which holds for vector output only. The sheet now uses QRCodeSVG for parts and strips, so the printer draws each module at its own resolution. Print CSS follows the element change. The existing scan-back tests pasted the parts' text, never their pixels, which is why this went unnoticed. A new paper-vault test prints a multi-part sheet, rasterises each symbol the way paper would receive it (a canvas as the bitmap it is, an SVG at 800px over 46mm, about 440dpi) and feeds them all to the app's own scanner; the container must reassemble and decrypt. Restoring QRCodeCanvas fails it. capturePrintedSymbols in helpers.ts is shared with the strip-scanning tests. --- CHANGELOG.md | 6 +++ src/app/globals.css | 4 +- src/components/encryptor-tool.tsx | 2 +- src/components/paper-vault.tsx | 16 ++++--- tests/browser/helpers.ts | 74 +++++++++++++++++++++++++++++++ tests/browser/paper-vault.spec.ts | 53 ++++++++++++++++++++-- tests/browser/shamir-ui.spec.ts | 42 ++++-------------- 7 files changed, 151 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5254438..375b48a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ did; the fixture corpus and both parity gates are unchanged and pass. issued by the shipping enrolment. ### Fixed +- **The paper vault printed its container symbols as a low-resolution bitmap.** + A full part is a version-40 symbol, and it was drawn into a 300px canvas: at + a devicePixelRatio of 1 that is 1.66 pixels per module, which the printer + could only stretch. No scale of it decodes. The sheet now draws every symbol + as SVG, which the printer renders at its own resolution, and a new test scans + every symbol of a multi-part sheet back into the container. - **The Recovery kit offered only `keym.py`, which reads KEYM v1 only.** It now offers `keym2.py` first, plus `requirements.txt`, and labels `keym.py` as v1-only. `requirements.txt` is precached with the rest of the kit. diff --git a/src/app/globals.css b/src/app/globals.css index 10a3598..1297b18 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -519,7 +519,7 @@ break-inside: avoid; } - .pv-qr canvas { + .pv-qr svg { width: 46mm !important; height: 46mm !important; } @@ -679,7 +679,7 @@ align-items: flex-start; } - .pv-strip-body canvas { + .pv-strip-body svg { width: 30mm !important; height: 30mm !important; flex: none; diff --git a/src/components/encryptor-tool.tsx b/src/components/encryptor-tool.tsx index 8998d1f..1e60c3a 100644 --- a/src/components/encryptor-tool.tsx +++ b/src/components/encryptor-tool.tsx @@ -1427,7 +1427,7 @@ export function EncryptorTool() { `window.print()` snapshots the document synchronously, so calling it in the same tick as setState prints the previous render — which is an empty sheet. The double rAF waits for React to commit and the browser to lay the QR - canvases out; printing between those two produces a page of blank squares. + symbols out; printing between those two produces a page of blank squares. The sheet is cleared afterwards so the container bytes do not sit in state for the rest of the session. diff --git a/src/components/paper-vault.tsx b/src/components/paper-vault.tsx index 02dc877..f80fd53 100644 --- a/src/components/paper-vault.tsx +++ b/src/components/paper-vault.tsx @@ -1,6 +1,6 @@ "use client"; -import { QRCodeCanvas } from "qrcode.react"; +import { QRCodeSVG } from "qrcode.react"; import { parseKeym2CoreHeader, keym2SlotCountOffset } from "@/lib/keym-v2"; import { byteMapSpans } from "@/components/container-inspector"; @@ -241,9 +241,15 @@ export function PaperVault({
{parts.map((part, i) => (
- {/* Level M, and 300px so a 600dpi printer has real modules to - work with rather than resampling a screen-sized bitmap. */} - + {/* Level M, drawn as SVG so the printer renders every module + at its own resolution. This was a 300px canvas, which is a + bitmap: a full part is a version-40 symbol, 181 modules + wide with its margin, so at a devicePixelRatio of 1 each + module got 1.66 pixels and the printer stretched that + aliased bitmap to 46mm. Measured: no scale of that bitmap + decodes. The old comment's premise, that 300px gave a 600dpi + printer real modules, was true of vector output only. */} +
part {i + 1} of {parts.length}
@@ -352,7 +358,7 @@ export function PaperVault({ Held by ______________________
- + {share}

diff --git a/tests/browser/helpers.ts b/tests/browser/helpers.ts index 5ab78dc..72c01ac 100644 --- a/tests/browser/helpers.ts +++ b/tests/browser/helpers.ts @@ -120,3 +120,77 @@ export async function decryptText(page: Page, container: string, password: strin /** A password that satisfies the strength gate. */ export const STRONG_PASSWORD = "correct-horse-battery-staple-9271!X"; + +/** The symbols on the printed paper vault, as PNG bytes a scan would see. */ +export interface PrintedSymbols { + /** Container parts, in sheet order. */ + parts: Buffer[]; + /** Recovery strips, in sheet order. */ + strips: Buffer[]; +} + +/** + * Click a "Print paper vault" button and return every symbol on the sheet as + * a PNG, snapshotted from inside `window.print()` (the stub throws, which is + * what leaves the sheet mounted long enough to read). + * + * Each symbol is turned into pixels the way it would reach paper. An SVG is + * vector, and a printer draws it at its own resolution, so it is rasterised + * here at `printPx` on its longest side (800px over the sheet's 46mm is about + * 440dpi, below any laser printer). A canvas is already a bitmap, and a + * printer can only stretch it, so it is taken exactly as it is. That is the + * difference the scan-back test exists to see. + */ +export async function capturePrintedSymbols( + page: Page, + printButton: Locator, + printPx = 800 +): Promise { + await page.evaluate(() => { + const w = window as unknown as { __symbols?: unknown; print: () => void }; + w.__symbols = null; + w.print = () => { + const sheet = document.querySelector(".paper-vault"); + const grab = (el: Element) => + el instanceof HTMLCanvasElement + ? { kind: "png", data: el.toDataURL("image/png") } + : { kind: "svg", data: new XMLSerializer().serializeToString(el) }; + const symbols = Array.from(sheet?.querySelectorAll(".pv-qr canvas, .pv-qr svg, .pv-strip canvas, .pv-strip svg") ?? []); + w.__symbols = { + parts: symbols.filter((el) => !el.closest(".pv-strip")).map(grab), + strips: symbols.filter((el) => el.closest(".pv-strip")).map(grab), + }; + throw new Error("print stubbed"); + }; + }); + await visible(printButton).click(); + await page.waitForFunction( + () => (window as unknown as { __symbols: unknown }).__symbols !== null, + null, + { timeout: 30_000 } + ); + const urls = await page.evaluate(async (px: number) => { + type Grabbed = { kind: "png" | "svg"; data: string }; + const got = (window as unknown as { __symbols: { parts: Grabbed[]; strips: Grabbed[] } }).__symbols; + const toPng = async (g: Grabbed): Promise => { + if (g.kind === "png") return g.data; + const img = new Image(); + img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(g.data)}`; + await img.decode(); + const canvas = document.createElement("canvas"); + canvas.width = px; + canvas.height = px; + const ctx = canvas.getContext("2d")!; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, px, px); + ctx.drawImage(img, 0, 0, px, px); + return canvas.toDataURL("image/png"); + }; + return { + parts: await Promise.all(got.parts.map(toPng)), + strips: await Promise.all(got.strips.map(toPng)), + }; + }, printPx); + const toBuffer = (d: string) => Buffer.from(d.split(",")[1] as string, "base64"); + return { parts: urls.parts.map(toBuffer), strips: urls.strips.map(toBuffer) }; +} diff --git a/tests/browser/paper-vault.spec.ts b/tests/browser/paper-vault.spec.ts index 9a4c5bc..2e0a07b 100644 --- a/tests/browser/paper-vault.spec.ts +++ b/tests/browser/paper-vault.spec.ts @@ -4,7 +4,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { encodePaperParts, encodePaperPartsV2 } from "../../src/lib/keym-v2-paper"; import { dearmorKeym2, keym2SlotCountOffset, KEYM2_VERSION_V3 } from "../../src/lib/keym-v2"; -import { visible, useTextMode, selectCrypto, STRONG_PASSWORD } from "./helpers"; +import { visible, useTextMode, selectCrypto, STRONG_PASSWORD, capturePrintedSymbols } from "./helpers"; /** * Roadmap 4.2 — the paper vault print kit. @@ -144,7 +144,7 @@ async function capturePrint(page: Page, from: "form" | "dialog" = "form"): Promi const strips = el ? Array.from(el.querySelectorAll(".pv-strip")) : []; w.__snap = { sheets: document.querySelectorAll(".paper-vault").length, - symbols: el ? el.querySelectorAll(".pv-qr canvas").length : 0, + symbols: el ? el.querySelectorAll(".pv-qr svg").length : 0, firstCaption: el?.querySelector(".pv-qr figcaption")?.textContent ?? "", rules: el ? el.querySelectorAll(".pv-rule").length : 0, text: el?.textContent ?? "", @@ -154,7 +154,7 @@ async function capturePrint(page: Page, from: "form" | "dialog" = "form"): Promi slotSegments: el ? el.querySelectorAll('.pv-bytemap [data-kind="slot"]').length : 0, strips: strips.length, heldBy: strips.filter((s) => /Held by/.test(s.textContent ?? "")).length, - stripsPageSymbols: el ? el.querySelectorAll(".pv-strips .pv-qr canvas").length : 0, + stripsPageSymbols: el ? el.querySelectorAll(".pv-strips .pv-qr svg").length : 0, rehearsal: el?.querySelector(".pv-rehearsal")?.textContent ?? "", }; throw new Error("print stubbed — see capturePrint()"); @@ -174,6 +174,51 @@ async function capturePrint(page: Page, from: "form" | "dialog" = "form"): Promi return page.evaluate(() => (window as unknown as { __snap: PrintSnapshot }).__snap); } +/** + * The symbols a multi-part sheet prints can be scanned back in. + * + * A container bigger than one symbol is split into full version-40 parts, 181 + * modules wide with their margin. The sheet drew them into a 300px canvas, so + * at a devicePixelRatio of 1 each module got 1.66 pixels and the printer could + * only stretch that aliased bitmap: no scale of it decodes. The existing + * scan-back tests pasted the parts' *text*, which is why nothing caught it. + * This one feeds every printed symbol, as pixels, to the app's own scanner. + */ +test("every symbol on a multi-part sheet scans back into the container", async ({ page }) => { + await page.goto("/"); + await useTextMode(page); + await selectCrypto(page, "pbkdf2", "aes"); + const secret = Array.from({ length: 900 }, (_, i) => `note line ${i}`).join("\n"); + await visible(page.getByPlaceholder("Enter text to encrypt")).fill(secret); + await visible(page.getByPlaceholder("Enter a strong password")).fill(STRONG_PASSWORD); + await visible(page.getByRole("button", { name: /^Encrypt Text$/i })).click(); + await page.waitForFunction( + () => (document.querySelector("#output-text") as HTMLTextAreaElement | null)?.value.startsWith("keym2:"), + null, + { timeout: 90_000 } + ); + const armored = await page.evaluate( + () => (document.querySelector("#output-text") as HTMLTextAreaElement).value + ); + + const printed = await capturePrintedSymbols(page, page.getByRole("button", { name: /Print paper vault/i })); + expect(printed.parts.length, "this test needs a backup that spans several full symbols").toBeGreaterThan(2); + + await visible(page.getByRole("tab", { name: "Decrypt" })).click(); + await useTextMode(page); + await page.locator("#qr-scan-input").setInputFiles( + printed.parts.map((buffer, i) => ({ name: `part-${i + 1}.png`, mimeType: "image/png", buffer })) + ); + await expect( + visible(page.locator("#text-secret")), + "the printed symbols did not scan back into the container" + ).toHaveValue(armored, { timeout: 60_000 }); + + await visible(page.getByPlaceholder("Enter decryption password")).fill(STRONG_PASSWORD); + await visible(page.getByRole("button", { name: /^Decrypt Text$/i })).click(); + await expect(visible(page.locator("#output-text"))).toHaveValue(secret, { timeout: 90_000 }); +}); + test.describe("paper vault", () => { test("prints a sheet of scannable parts for the container", async ({ page }) => { await encryptSomething(page); @@ -181,7 +226,7 @@ test.describe("paper vault", () => { expect(snap.sheets, "no paper vault sheet was in the document when it printed").toBe(1); - // One canvas per §7.1 part. The encoding itself is gated by the Python + // One symbol per §7.1 part. The encoding itself is gated by the Python // conformance suite comparing emitted strings; this gates the wiring. expect(snap.symbols).toBe(1); expect(snap.firstCaption).toMatch(/part 1 of 1/); diff --git a/tests/browser/shamir-ui.spec.ts b/tests/browser/shamir-ui.spec.ts index d59faf8..1677e97 100644 --- a/tests/browser/shamir-ui.spec.ts +++ b/tests/browser/shamir-ui.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { visible, useTextMode, selectCrypto } from "./helpers"; +import { visible, useTextMode, selectCrypto, capturePrintedSymbols } from "./helpers"; /** * Phase 4.1d — recovery shares, driven the way a user reaches them. @@ -234,7 +234,7 @@ test.describe("the inheritance path", () => { * them into the shares box: a strip scanned on the Decrypt tab was only told it * was in the wrong box, and a whole printed backup scanned in one batch (parts * and strips together) failed as one bad paste. The images here are the strip - * and part canvases of the real print sheet, snapshotted from inside + * and part symbols of the real print sheet, snapshotted from inside * `window.print()`, so the round trip is through the artefact a person would * photograph rather than a fixture that could drift from it. */ @@ -265,45 +265,19 @@ async function encryptAndPrintWithShares( s.startsWith("KMSHARE2:") ); - // The print stub throws, which leaves the sheet mounted long enough to read - // its canvases; the same technique paper-vault.spec.ts uses. - await page.evaluate(() => { - const w = window as unknown as { __pngs?: unknown; print: () => void }; - w.__pngs = null; - w.print = () => { - const sheet = document.querySelector(".paper-vault"); - const png = (c: Element) => (c as HTMLCanvasElement).toDataURL("image/png"); - w.__pngs = { - strips: Array.from(sheet?.querySelectorAll(".pv-strip canvas") ?? [], png), - parts: Array.from(sheet?.querySelectorAll(".pv-qr canvas") ?? []) - .filter((c) => !c.closest(".pv-strip")) - .map(png), - }; - throw new Error("print stubbed"); - }; - }); - await visible(page.getByRole("dialog").getByRole("button", { name: /Print paper vault/i })).click(); - await page.waitForFunction( - () => (window as unknown as { __pngs: unknown }).__pngs !== null, - null, - { timeout: 30_000 } - ); - const pngs = await page.evaluate( - () => (window as unknown as { __pngs: { strips: string[]; parts: string[] } }).__pngs + // The strip and part symbols of the real print sheet, as a scan would see + // them; see capturePrintedSymbols. + const printed = await capturePrintedSymbols( + page, + page.getByRole("dialog").getByRole("button", { name: /Print paper vault/i }) ); - const toBuffer = (d: string) => Buffer.from(d.split(",")[1] as string, "base64"); await page.getByRole("button", { name: "I have saved these shares" }).click(); await expect(page.getByText(/Save these/)).toHaveCount(0); const armored = await page.evaluate( () => (document.querySelector("#output-text") as HTMLTextAreaElement).value ); - return { - armored, - shares, - stripPngs: pngs.strips.map(toBuffer), - partPngs: pngs.parts.map(toBuffer), - }; + return { armored, shares, stripPngs: printed.strips, partPngs: printed.parts }; } const png = (name: string, buffer: Buffer) => ({ name, mimeType: "image/png", buffer }); From bf3608ab59f8df9ccae21133ba956d3ccc7c2a81 Mon Sep 17 00:00:00 2001 From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:21:05 +0000 Subject: [PATCH 07/34] build: resolve script paths with fileURLToPath so a spaced checkout builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply-csp-hashes.mjs, apply-build-id.mjs, build-manifest.mjs and generate-wordlist.mjs resolved their output paths with new URL('../out', import.meta.url).pathname. A URL pathname is percent-encoded, so under a checkout path containing a space or a non-ASCII character the scripts looked for `key%20maker%20%C3%BCn%C3%AF/out` and `npm run build` failed with ENOENT right after `next build`. fileURLToPath decodes it. These were the only `.pathname` uses on an import.meta.url in scripts/. Regression gate: the second reproducible-elsewhere leg in ci.yml now checks out into "a checkout directory named nothing like the other one ünï". It still varies the path, as before, and now also builds from a path with a space and non-ASCII letters, and its manifest is compared with the other legs', so the path must not leak into the bytes either. No extra runner. Shown locally, copying the repo into a directory named "key maker ünï": - before this change `npm run build` exits 1 (ENOENT on the %20 path); - after it exits 0, and its SHA256SUMS is byte-identical to a build of the same commit from a plain path, with and without KEYMAKER_BASE_PATH=/Keymaker-v2. --- .github/workflows/ci.yml | 11 ++++++++--- scripts/apply-build-id.mjs | 6 +++++- scripts/apply-csp-hashes.mjs | 6 +++++- scripts/build-manifest.mjs | 6 +++++- scripts/generate-wordlist.mjs | 6 +++++- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5858b24..1d7be8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -334,7 +334,12 @@ jobs: # # - **the checkout path**, because an absolute path that leaks into a bundle # or a source map is the classic way a build stops being portable, and it - # is invisible to anyone who only ever builds in one directory; + # is invisible to anyone who only ever builds in one directory. The + # second leg's path also has a space and non-ASCII letters in it, because + # a verifier's home directory might, and the build broke there once: + # `new URL('../out', import.meta.url).pathname` is percent-encoded, so + # the post-build scripts looked for `…/a%20checkout…/out`, which does not + # exist. That leg failing is the regression test for it; # - **the Node major**, because README.md tells people 22.22.2 *or newer* is # supported, and a verifier on a newer major who gets different bytes has # been told to expect a match. @@ -353,9 +358,9 @@ jobs: - id: node22 node: 22 dir: keymaker - - id: node22-longer-checkout-path + - id: node22-spaced-non-ascii-checkout-path node: 22 - dir: a-checkout-directory-named-nothing-like-the-other-one + dir: "a checkout directory named nothing like the other one ünï" - id: node24 node: 24 dir: keymaker diff --git a/scripts/apply-build-id.mjs b/scripts/apply-build-id.mjs index cc3d7e8..eceef3b 100644 --- a/scripts/apply-build-id.mjs +++ b/scripts/apply-build-id.mjs @@ -23,8 +23,12 @@ import { createHash } from 'node:crypto'; import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; -const OUT_DIR = new URL('../out', import.meta.url).pathname; +// fileURLToPath, not `.pathname`: the URL form is percent-encoded, so a +// checkout under a path with a space or a non-ASCII character would resolve +// to a directory that does not exist (`key%20maker`). +const OUT_DIR = fileURLToPath(new URL('../out', import.meta.url)); const SW = join(OUT_DIR, 'sw.js'); const PLACEHOLDER = '__BUILD_ID__'; const ASSETS_PLACEHOLDER = '__PRECACHE_ASSETS__'; diff --git a/scripts/apply-csp-hashes.mjs b/scripts/apply-csp-hashes.mjs index bcdf42f..a26fdef 100644 --- a/scripts/apply-csp-hashes.mjs +++ b/scripts/apply-csp-hashes.mjs @@ -21,9 +21,13 @@ import { createHash } from 'node:crypto'; import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { egressViolations } from './csp-egress-gate.mjs'; -const OUT_DIR = new URL('../out', import.meta.url).pathname; +// fileURLToPath, not `.pathname`: the URL form is percent-encoded, so a +// checkout under a path with a space or a non-ASCII character would resolve +// to a directory that does not exist (`key%20maker`). +const OUT_DIR = fileURLToPath(new URL('../out', import.meta.url)); function htmlFiles(dir) { const found = []; diff --git a/scripts/build-manifest.mjs b/scripts/build-manifest.mjs index 5fc04d5..aa513c8 100644 --- a/scripts/build-manifest.mjs +++ b/scripts/build-manifest.mjs @@ -39,8 +39,12 @@ import { createHash } from 'node:crypto'; import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; -const OUT_DIR = new URL('../out', import.meta.url).pathname; +// fileURLToPath, not `.pathname`: the URL form is percent-encoded, so a +// checkout under a path with a space or a non-ASCII character would resolve +// to a directory that does not exist (`key%20maker`). +const OUT_DIR = fileURLToPath(new URL('../out', import.meta.url)); const MANIFEST = join(OUT_DIR, 'SHA256SUMS'); /** Names that must never appear in the manifest. */ diff --git a/scripts/generate-wordlist.mjs b/scripts/generate-wordlist.mjs index 274a3e0..b91af92 100644 --- a/scripts/generate-wordlist.mjs +++ b/scripts/generate-wordlist.mjs @@ -38,8 +38,12 @@ import { execFileSync } from 'node:child_process'; import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; -const OUT = new URL('../src/lib/eff-wordlist.ts', import.meta.url).pathname; +// fileURLToPath, not `.pathname`: the URL form is percent-encoded, so a +// checkout under a path with a space or a non-ASCII character would resolve +// to a directory that does not exist (`key%20maker`). +const OUT = fileURLToPath(new URL('../src/lib/eff-wordlist.ts', import.meta.url)); // Pinned by exact version and, where the registry publishes one, by the // integrity hash of the archive itself. An unpinned fetch would make this From 016877594209242405775714e8e2011d967c49a4 Mon Sep 17 00:00:00 2001 From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:21:09 +0000 Subject: [PATCH 08/34] ci: refuse to sign a manifest the independent builds did not reproduce deploy.yml and release.yml sign the SHA256SUMS their own `build` job produced. No suite ever sees those bytes: the suites build their own copy of the commit. deploy.yml's comment asserted the two were byte-identical, but nothing compared them, so a nondeterminism that only appeared on the publishing runner would have been signed and published with every suite green. The sign job in both publishers now downloads the SHA256SUMS each ci.yml reproducible-elsewhere leg already uploads (sums-*, into reproduced/, outside out/) and runs scripts/check-reproduced-manifest.mjs before signing. It fails unless at least two leg manifests are present and every one is byte-identical to out/SHA256SUMS, naming the files that differ, are extra, or are missing. The called workflow runs inside the publisher's run, so the download needs no permission beyond what sign already holds; the token split and --ignore-scripts are unchanged, and the new download step reuses the repo's pinned download-artifact SHA. A release is labelled at build time (KEYMAKER_RELEASE_TAG), so it is not byte-identical to a development build. ci.yml gains a release-tag workflow_call input that the legs pass through env; release.yml sets it to github.ref_name, the same expression its build uses, and deploy.yml and PRs leave it empty, which builds the same bytes as leaving the variable unset (checked locally). Tests: - scripts/check-reproduced-manifest-test.mjs (npm run test:reproduced-manifest, now a ci.yml step) drives the checker on identical, differing, extra-file, missing-file, reordered, absent and too-few manifests. Controls: forcing the comparison to agree fails 7 checks; MIN_REPRODUCTIONS = 0 fails 3. - scripts/release-gate-test.mjs now also asserts the wiring: legs upload as sums- and honour the input, each sign job downloads and compares before sign-manifest.mjs and runs after verify-ci, and each publisher passes the channel it publishes. Seven controls (delete the compare step, delete the download, move the compare after signing, drop or add the release-tag, drop the leg env, rename the upload) each fail the gate. --- .github/workflows/ci.yml | 29 +++++ .github/workflows/deploy.yml | 29 ++++- .github/workflows/release.yml | 24 +++- package.json | 1 + scripts/check-reproduced-manifest-test.mjs | 126 +++++++++++++++++++++ scripts/check-reproduced-manifest.mjs | 117 +++++++++++++++++++ scripts/release-gate-test.mjs | 54 +++++++++ 7 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 scripts/check-reproduced-manifest-test.mjs create mode 100644 scripts/check-reproduced-manifest.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d7be8b..50aabc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,16 @@ name: CI on: pull_request: workflow_call: + inputs: + # release.yml passes the tag it is about to build, so the + # reproducible-elsewhere legs below build the same channel it publishes + # and its sign job compares like with like. Empty (every PR, and + # deploy.yml) builds the development channel, which is what deploy.yml + # publishes. + release-tag: + type: string + required: false + default: "" workflow_dispatch: permissions: {} @@ -174,6 +184,15 @@ jobs: - name: Publication is gated on the test suites run: npm run test:release-gate + # The step in deploy.yml and release.yml that refuses to sign a manifest + # the reproducible-elsewhere legs below did not reproduce byte for byte. + # It only ever runs inside a publish, which is too late to learn it + # accepts a mismatch, so its refusals are exercised here on every PR. The + # control bites: a comparison that always agrees passes a differing, + # extra or missing file, and a minimum of zero signs on no evidence. + - name: Signing refuses a manifest no independent build reproduced + run: npm run test:reproduced-manifest + - name: KEYM v1 regression suite run: npm run test:keymaker @@ -387,13 +406,23 @@ jobs: # reason verify-reproducible.mjs pins it: a PR checkout is a merge commit # that does not exist upstream, and letting each leg resolve it from git # would be comparing git rather than the build. + # + # The channel is the caller's: empty on a PR and under deploy.yml, the + # tag under release.yml. It arrives through `env`, never interpolated into + # a script, because a tag name is text its author controls. - name: Build run: npm run build working-directory: ${{ matrix.dir }} env: KEYMAKER_BASE_PATH: /Keymaker-v2 KEYMAKER_BUILD_ID: ${{ github.sha }} + KEYMAKER_RELEASE_TAG: ${{ inputs.release-tag }} + # Consumed twice: by the comparison job below, and, when deploy.yml or + # release.yml called this workflow, by their sign job, which refuses to + # sign a manifest these legs did not reproduce + # (scripts/check-reproduced-manifest.mjs). Keep the `sums-` prefix; both + # download by it. - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: sums-${{ matrix.id }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7e83591..5f0d80a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -52,9 +52,12 @@ jobs: build: # Gate the whole publish chain on verification. build -> sign -> deploy, so - # gating build gates all three. The bytes built here are byte-identical to - # the ones verify-ci built and proved reproducible, so "publish the tested - # artifact" holds without threading the artifact between workflows. + # gating build gates all three. The bytes built here should be + # byte-identical to the ones verify-ci built and proved reproducible, so + # "publish the tested artifact" holds without threading the artifact + # between workflows. `sign` checks that rather than assuming it: it refuses + # to sign unless this job's SHA256SUMS matches every reproducible-elsewhere + # leg's. needs: [verify-crypto, verify-ci, verify-conformance, verify-browser] runs-on: ubuntu-latest permissions: @@ -97,8 +100,13 @@ jobs: # therefore never held while untrusted transitive install scripts execute. # The same reasoning gives this job `--ignore-scripts`: it holds the token, # so nothing it installs is allowed to run code. + # + # `verify-ci` is named here as well as through `build` because this job reads + # its artifacts: the SHA256SUMS each reproducible-elsewhere leg uploaded. A + # called workflow runs inside this run, so they download like `site` does, + # with no permission beyond the ones below. sign: - needs: build + needs: [build, verify-ci] runs-on: ubuntu-latest permissions: contents: read @@ -122,6 +130,19 @@ jobs: name: site path: out + # Outside out/, so none of it is published. + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: sums-* + path: reproduced + + # The manifest about to be signed must be the one the verified builds + # produced. verify-ci's legs each built this commit on their own runner; + # this job's `site` came from `build`, which no suite ever looked at. If + # they differ, the signature would certify bytes nothing tested. + - name: The manifest being signed is the one independent builds reproduced + run: node scripts/check-reproduced-manifest.mjs out/SHA256SUMS reproduced + - name: Sign the build manifest run: node scripts/sign-manifest.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a60eac..beb2181 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,11 +123,17 @@ jobs: contents: read uses: ./.github/workflows/crypto-regression.yml + # The tag goes to ci.yml so its reproducible-elsewhere legs build the release + # channel, the same expression `build` below sets KEYMAKER_RELEASE_TAG from. + # Without it they would build the development label, and `sign` would be + # comparing the release against a different artifact. verify-ci: needs: preflight permissions: contents: read uses: ./.github/workflows/ci.yml + with: + release-tag: ${{ github.ref_name }} verify-conformance: needs: preflight @@ -182,8 +188,12 @@ jobs: # Signing and packaging happen here, in the job that holds `id-token: write` # but has run no install scripts. Packaging is here rather than in publish so # that the job holding `contents: write` installs nothing at all. + # + # `verify-ci` is named here as well as through `build` because this job reads + # its artifacts: the SHA256SUMS each reproducible-elsewhere leg uploaded. + # Same run, so no permission beyond the ones below. See deploy.yml. sign: - needs: build + needs: [build, verify-ci] runs-on: ubuntu-latest permissions: contents: read @@ -207,6 +217,18 @@ jobs: name: site path: out + # Outside out/, so none of it is signed, packaged or attached. + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: sums-* + path: reproduced + + # The release manifest must be the one the verified builds produced, not + # only one this job's `build` produced. verify-ci's legs built this tag, + # labelled as this release, each on its own runner. + - name: The manifest being signed is the one independent builds reproduced + run: node scripts/check-reproduced-manifest.mjs out/SHA256SUMS reproduced + - name: Sign the build manifest run: node scripts/sign-manifest.mjs diff --git a/package.json b/package.json index df48316..2448995 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "test:release-notes": "node scripts/release-notes-test.mjs", "test:release-recipe": "node scripts/release-recipe-test.mjs", "test:release-gate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/release-gate-test.mjs", + "test:reproduced-manifest": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/check-reproduced-manifest-test.mjs", "test:verify-recipe": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/verify-recipe-test.mjs", "test:palette": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/palette-audit.mjs", "test:icons": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/icon-audit.mjs", diff --git a/scripts/check-reproduced-manifest-test.mjs b/scripts/check-reproduced-manifest-test.mjs new file mode 100644 index 0000000..037866e --- /dev/null +++ b/scripts/check-reproduced-manifest-test.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +/** + * The sign jobs refuse a manifest that the independent builds did not + * reproduce. + * + * scripts/check-reproduced-manifest.mjs is the step in deploy.yml and + * release.yml that stands between "the publishing runner built something" and + * "sign it". It only ever runs on GitHub, inside a publish, so this drives it + * as a subprocess against manifests laid out the way actions/download-artifact + * lays them out, and asserts it refuses every case that should stop a + * signature and accepts the one that should not. + * + * Control shown to bite: make the comparison always succeed (replace + * `theirs.equals(built)` with `true`) and the differing, extra-file and + * missing-file cases below pass the checker, so their assertions fail. Drop + * MIN_REPRODUCTIONS to 0 and the no-manifest and one-manifest cases do. + */ +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const CHECKER = join(dirname(fileURLToPath(import.meta.url)), 'check-reproduced-manifest.mjs'); + +let failed = 0; +const ok = (cond, msg, detail = '') => { + if (!cond) { + console.error(`FAIL: ${msg}${detail ? `\n${detail}` : ''}`); + failed++; + } else console.log(`ok ${msg}`); +}; + +const hex = (c) => c.repeat(64); +const manifest = (rows) => rows.map(([digest, path]) => `${digest} ${path}\n`).join(''); +const BASE = [ + [hex('a'), '_next/static/chunks/app.js'], + [hex('b'), 'index.html'], + [hex('c'), 'sw.js'], +]; + +const root = mkdtempSync(join(tmpdir(), 'keymaker-reproduced-')); +let caseNo = 0; + +/** + * Lay out one case: the build's manifest, and a directory of legs, each a + * subdirectory holding SHA256SUMS. Returns the checker's exit status and + * combined output. + */ +function run(buildRows, legRows, { build = true, legsDir = true } = {}) { + const dir = join(root, `case-${++caseNo}`); + mkdirSync(dir, { recursive: true }); + const buildPath = join(dir, 'out', 'SHA256SUMS'); + if (build) { + mkdirSync(dirname(buildPath), { recursive: true }); + writeFileSync(buildPath, buildRows === null ? '' : manifest(buildRows)); + } + const reproduced = join(dir, 'reproduced'); + if (legsDir) { + mkdirSync(reproduced); + legRows.forEach((rows, i) => { + mkdirSync(join(reproduced, `sums-leg${i}`)); + writeFileSync(join(reproduced, `sums-leg${i}`, 'SHA256SUMS'), manifest(rows)); + }); + } + const r = spawnSync(process.execPath, [CHECKER, buildPath, reproduced], { encoding: 'utf8' }); + return { status: r.status, out: `${r.stdout}${r.stderr}` }; +} + +// Positive: every leg agrees, so the manifest may be signed. +{ + const r = run(BASE, [BASE, BASE, BASE]); + ok(r.status === 0, 'three identical reproductions pass', r.out); +} + +// A digest differs on one leg: the publishing runner built different bytes. +{ + const changed = BASE.map(([d, p]) => (p === 'sw.js' ? [hex('d'), p] : [d, p])); + const r = run(BASE, [BASE, changed, BASE]); + ok(r.status === 1, 'one leg with a different digest refuses to sign', r.out); + ok(/differs:\s+sw\.js/.test(r.out), 'the refusal names the differing file', r.out); +} + +// The signed build carries a file no verified build produced. +{ + const extra = [...BASE, [hex('e'), 'injected.js']]; + const r = run(extra, [BASE, BASE]); + ok(r.status === 1, 'a file only the publishing runner produced refuses to sign', r.out); + ok(/only in the signed build: injected\.js/.test(r.out), 'the refusal names the extra file', r.out); +} + +// The verified builds have a file the signed build dropped. +{ + const r = run(BASE.slice(0, 2), [BASE, BASE]); + ok(r.status === 1, 'a file the publishing runner dropped refuses to sign', r.out); + ok(/only in .*sums-leg0: sw\.js/.test(r.out), 'the refusal names the missing file', r.out); +} + +// Same entries, different bytes (a reordered manifest is a different manifest, +// and so a different signature). +{ + const r = run(BASE, [[...BASE].reverse(), BASE]); + ok(r.status === 1, 'a manifest that differs only in order refuses to sign', r.out); +} + +// Nothing to compare against must never read as agreement. +{ + const r = run(BASE, [], { legsDir: false }); + ok(r.status === 1, 'no downloaded manifests at all refuses to sign', r.out); + const empty = run(BASE, []); + ok(empty.status === 1, 'an empty download directory refuses to sign', empty.out); + const one = run(BASE, [BASE]); + ok(one.status === 1, 'a single reproduction is below the minimum and refuses to sign', one.out); +} + +// No build manifest, or an empty one, is not something to sign either. +{ + const r = run(BASE, [BASE, BASE], { build: false }); + ok(r.status === 1, 'a missing build manifest refuses to sign', r.out); + const empty = run(null, [BASE, BASE]); + ok(empty.status === 1, 'an empty build manifest refuses to sign', empty.out); +} + +rmSync(root, { recursive: true, force: true }); +console.log(failed === 0 ? '\nAll reproduced-manifest checks passed.' : `\n${failed} check(s) FAILED.`); +process.exit(failed === 0 ? 0 : 1); diff --git a/scripts/check-reproduced-manifest.mjs b/scripts/check-reproduced-manifest.mjs new file mode 100644 index 0000000..cc8d6e6 --- /dev/null +++ b/scripts/check-reproduced-manifest.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +/** + * Refuse to sign a manifest that no independent runner reproduced. + * + * deploy.yml and release.yml build the site once, in their own `build` job, + * and sign that job's out/SHA256SUMS. The suites they wait on never see those + * bytes: they build their own copy of the same commit. "Publish the tested + * artifact" therefore rests on a claim, that every build of this commit is + * byte-identical, and until this script nothing checked the claim against the + * bytes actually being signed. A nondeterminism that only showed up on the + * publishing runner would have been signed, deployed and published, with + * every suite green. + * + * ci.yml's `reproducible-elsewhere` legs each build the same commit on a + * separate runner and upload their SHA256SUMS as `sums-`. They run in the + * same workflow run as the publisher (it calls ci.yml through `workflow_call`), + * so the sign job can download them. This compares each one, byte for byte, + * against the manifest about to be signed, and fails naming the files that + * differ. SHA256SUMS covers every served file, so equal manifests mean equal + * artifacts. + * + * Usage: + * node scripts/check-reproduced-manifest.mjs

+ * + * where holds one subdirectory per downloaded artifact, each containing + * a SHA256SUMS (the layout actions/download-artifact produces for a pattern). + * + * No dependencies: the sign job installs with --ignore-scripts and holds an + * OIDC token, so this reads files and compares them and does nothing else. + */ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Fewer than this is a comparison that proves too little to sign on. Two is + * also what ci.yml's own cross-runner comparison requires, so a publish run + * that got this far always has at least that many. + */ +const MIN_REPRODUCTIONS = 2; + +function fail(message) { + console.error(`reproduced: FAILED - ${message}`); + process.exit(1); +} + +/** path -> digest, for naming what differs rather than only that something does. */ +function entries(bytes) { + const map = new Map(); + for (const line of bytes.toString('utf8').split('\n')) { + if (!line) continue; + const m = /^([a-f0-9]{64}) {2}(.+)$/.exec(line); + map.set(m ? m[2] : ` ${line.slice(0, 80)}`, m ? m[1] : ''); + } + return map; +} + +const [buildPath, dir] = process.argv.slice(2); +if (!buildPath || !dir) { + fail('usage: check-reproduced-manifest.mjs '); +} + +let built; +try { + built = readFileSync(buildPath); +} catch { + fail(`${buildPath} not found. There is no build manifest to compare.`); +} +if (built.length === 0) fail(`${buildPath} is empty.`); + +let legs = []; +try { + legs = readdirSync(dir) + .sort() + .map((name) => join(dir, name)) + .filter((p) => statSync(p).isDirectory() && existsSync(join(p, 'SHA256SUMS'))); +} catch { + // A missing directory is the zero-reproductions case below. +} + +if (legs.length < MIN_REPRODUCTIONS) { + fail( + `found ${legs.length} reproduced manifest(s) in ${dir}, need at least ${MIN_REPRODUCTIONS}. ` + + 'Comparing against nothing would pass while proving nothing, so this refuses to sign.' + ); +} + +const reference = entries(built); +let differing = 0; +for (const leg of legs) { + const theirs = readFileSync(join(leg, 'SHA256SUMS')); + if (theirs.equals(built)) { + console.log(`reproduced: ok ${leg} is byte-identical (${reference.size} files)`); + continue; + } + differing++; + const other = entries(theirs); + console.error(`reproduced: FAIL ${leg} differs from ${buildPath}:`); + for (const [path, digest] of reference) { + if (!other.has(path)) console.error(` only in the signed build: ${path}`); + else if (other.get(path) !== digest) console.error(` differs: ${path}`); + } + for (const path of other.keys()) { + if (!reference.has(path)) console.error(` only in ${leg}: ${path}`); + } +} + +if (differing > 0) { + fail( + `${differing} of ${legs.length} independent build(s) disagree with the manifest about to be ` + + 'signed. The publishing runner produced bytes the verified builds did not, so signing ' + + 'them would certify an artifact nobody tested or reproduced. Find the nondeterminism ' + + 'rather than relaxing this.' + ); +} +console.log( + `reproduced: the signed manifest matches ${legs.length} independent build(s) byte for byte` +); diff --git a/scripts/release-gate-test.mjs b/scripts/release-gate-test.mjs index d601717..a53db10 100644 --- a/scripts/release-gate-test.mjs +++ b/scripts/release-gate-test.mjs @@ -159,6 +159,60 @@ for (const [verifyJob, file] of Object.entries(SUITES)) { else bad("a release verify job does not need preflight; the fast-fail checks no longer run first"); } +// ---------------------------------------------------------------- reproduced +// +// The needs edges above prove the suites passed on this commit. They do not +// prove the bytes being signed are the bytes the suites built: each publisher +// signs what its own `build` job made, and no suite ever sees that. The sign +// job closes the gap by comparing that manifest with the ones ci.yml's +// reproducible-elsewhere legs uploaded (scripts/check-reproduced-manifest.mjs). +// This asserts the wiring: the legs upload under the prefix the sign jobs +// download, each sign job compares before it signs, and each publisher has the +// legs build the channel it publishes. +// +// To watch the control bite: delete the "The manifest being signed..." step +// from deploy.yml's sign job, or the `release-tag:` line from release.yml's +// verify-ci, and re-run. +{ + const ci = readFileSync(join(WF, "ci.yml"), "utf8"); + const legs = jobBlock(ci, "reproducible-elsewhere") ?? ""; + if (/^\s*name:\s*sums-\$\{\{ matrix\.id \}\}\s*$/m.test(legs)) + ok("ci.yml reproducible-elsewhere uploads each leg's manifest as sums-"); + else bad("ci.yml reproducible-elsewhere no longer uploads sums-; the sign jobs would find nothing to compare"); + if (/^\s*KEYMAKER_RELEASE_TAG:\s*\$\{\{ inputs\.release-tag \}\}\s*$/m.test(legs)) + ok("ci.yml reproducible-elsewhere builds the channel its caller names"); + else bad("ci.yml reproducible-elsewhere ignores inputs.release-tag; a release would be compared against a development build"); + if (/^\s{4}inputs:\s*\n(?:\s{6,}.*\n)*?\s{6}release-tag:\s*$/m.test(ci)) + ok("ci.yml declares the release-tag workflow_call input"); + else bad("ci.yml does not declare the release-tag workflow_call input"); + + for (const [file, releaseTag] of [ + ["deploy.yml", null], + ["release.yml", "${{ github.ref_name }}"], + ]) { + const src = readFileSync(join(WF, file), "utf8"); + const sign = jobBlock(src, "sign") ?? ""; + const download = sign.search(/^\s*pattern:\s*sums-\*\s*\n\s*path:\s*reproduced\s*$/m); + const compare = sign.indexOf("node scripts/check-reproduced-manifest.mjs out/SHA256SUMS reproduced"); + const signing = sign.indexOf("node scripts/sign-manifest.mjs"); + if (download !== -1 && compare > download && signing > compare) + ok(`${file} sign compares the reproduced manifests before it signs`); + else bad(`${file} sign does not download sums-* and run check-reproduced-manifest.mjs before signing`); + + if (ancestors(src, "sign").has("verify-ci")) ok(`${file} sign runs after verify-ci, whose manifests it reads`); + else bad(`${file} sign does not wait for verify-ci, so the manifests it compares may not exist yet`); + + const passed = (jobBlock(src, "verify-ci") ?? "").match(/^\s*release-tag:\s*(.*?)\s*$/m)?.[1] ?? null; + if (passed === releaseTag) + ok(`${file} has ci.yml build the ${releaseTag ? "release" : "development"} channel it publishes`); + else + bad( + `${file} verify-ci passes release-tag ${JSON.stringify(passed)}, expected ${JSON.stringify(releaseTag)}; ` + + "the legs would build a different channel from the one being signed" + ); + } +} + if (failures > 0) { console.log(`\n${failures} check(s) failed: a publish path is not gated on its tests.`); process.exit(1); From 8b81cd796022b3f50a01293d7221b7a25b8016da Mon Sep 17 00:00:00 2001 From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:21:13 +0000 Subject: [PATCH 09/34] ci: run the reference self-tests on Python 3.10, the RECOVERY.md floor docs/RECOVERY.md promises the recovery scripts run on "Python 3.10 or newer", but every job ran 3.12, so a 3.11-only construct in keym.py or keym2.py would have shipped green. The pinned cffi and argon2-cffi-bindings wheels also declare Requires-Python >=3.10, so a routine bump could drop the floor unnoticed. New conformance.yml job, reference-python-floor: setup-python 3.10 (the repo's existing pinned SHA), an assertion that python3 really is 3.10, the hash-pinned install, then `keym.py selftest` and `keym2.py selftest`. Finding: the pinned file does not install on 3.10 as written. `pip install --require-hashes -r reference/conformance-requirements.txt` fails there, because cryptography 50.0.1 declares typing-extensions>=4.13.2 for python_full_version < '3.11' and the closure is resolved for 3.12 only (PY_VERSION in scripts/pin-conformance-deps.py). Every pinned wheel does support 3.10. The job therefore installs with --no-deps: still hash-checked, exactly the pinned bytes, nothing unpinned. It cannot mask a missing module, since the self-tests would fail on import; cryptography uses typing-extensions only in hazmat.asn1, which neither script imports. Pins unchanged. Shown locally in a fresh 3.10 venv: the install and both self-tests pass (keym2: 550 checks). Control: with `import tomllib` (3.11+) added to copies of both scripts, both self-tests pass on 3.12 with the same pins and fail on 3.10 with ModuleNotFoundError. The interpreter assertion exits 1 under 3.11. --- .github/workflows/conformance.yml | 52 +++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 5757255..676f9fd 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -87,6 +87,58 @@ jobs: - name: Recovery procedure works as documented run: npm run test:recovery + # ---------------------------------------------------------------- oldest supported Python + # + # docs/RECOVERY.md tells whoever is recovering a backup that the scripts need + # "Python 3.10 or newer", and every other job here runs 3.12, so nothing + # tested the floor of that promise. A 3.11-only construct in keym.py or + # keym2.py (`except*`, `tomllib`, `typing.Self`) would pass the job above and + # fail on exactly the old machine the page is written for. The pinned wheels + # sit on the same floor: cffi 2.1.1 and argon2-cffi-bindings 26.1.0 both + # declare Requires-Python >=3.10, so a routine bump could drop 3.10 with + # nothing else noticing. + # + # The self-tests only: they are the scripts' own known-answer vectors, which + # is the part an heir's interpreter has to run. The cross-tests drive the + # TypeScript and add nothing about the Python version. + reference-python-floor: + name: Reference self-tests on Python 3.10 (the RECOVERY.md floor) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.10" + + # A job that quietly ran the runner's default interpreter would pass while + # testing 3.12 a second time. + - name: The interpreter is the documented floor + run: | + python3 --version + python3 -c 'import sys; sys.exit(0 if sys.version_info[:2] == (3, 10) else "expected Python 3.10, got " + sys.version)' + + # Hash-checked, and --no-deps on purpose. The pinned closure is resolved + # for 3.12 (scripts/pin-conformance-deps.py). On 3.10 cryptography also + # declares typing-extensions (for python_full_version < '3.11'), which the + # file does not pin, so without --no-deps hash-checking mode refuses the + # whole install. This installs exactly the pinned bytes and nothing + # unpinned. It cannot hide a missing module: one the scripts need would + # fail the self-tests below on import. cryptography uses + # typing-extensions only in hazmat.asn1, which neither script imports. + - name: Install the pinned reference dependencies + run: pip install --require-hashes --no-deps -r reference/conformance-requirements.txt + + - name: Reference self-test (KEYM v1) on Python 3.10 + run: python3 reference/keym.py selftest + + - name: Reference self-test (KEYM v2 and v3) on Python 3.10 + run: python3 reference/keym2.py selftest + # ---------------------------------------------------------------- dependency audit # # Its own job, not a step in `conformance`. The two answer different From 986cf153c9d059438c914f46642bb5c7e4f9270e Mon Sep 17 00:00:00 2001 From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:21:17 +0000 Subject: [PATCH 10/34] ci: correct stale workflow comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - conformance.yml said "KEYM v2 has no TypeScript yet, so there is nothing to cross-test against", two steps above the v2/v3 byte-for-byte cross-test, and counted four spec findings where §11 of FORMAT-V2-DESIGN.md now records eight. It now describes the self-test as what it is (the spec-first reference on its own) without a count that will drift again, and the step name covers v3. - conformance.yml's header named one reference written from FORMAT.md; there are two, keym.py (v1) and keym2.py (v2 and the v3 delta). - crypto-regression.yml said it runs "on every push and PR" directly above the note that it no longer runs on push, and "Node 22 across every workflow" although ci.yml deliberately builds one leg on Node 24. - ci.yml's header summarised it as "Build + typecheck + the KEYM v1 regression suite". - A stray blank line inside the conformance2 step is removed. Comments only, apart from the conformance step's display name. --- .github/workflows/ci.yml | 4 +++- .github/workflows/conformance.yml | 25 +++++++++++++------------ .github/workflows/crypto-regression.yml | 17 ++++++++++------- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50aabc1..6a4372c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,8 @@ name: CI -# Build + typecheck + the KEYM v1 regression suite. +# Typecheck, the Node-side suites (KEYM regression, fuzzing, the workflow and +# document gates), the production build with its palette and icon audits, and +# the checks that the build is reproducible. # # This is deliberately separate from crypto-regression.yml. That workflow # gates the frozen IBTZ core and runs with no `npm ci` at all, so no diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 676f9fd..29221c6 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -1,7 +1,9 @@ name: Format conformance -# Cross-tests the TypeScript implementation against an independent Python -# reference written only from docs/FORMAT.md. +# Cross-tests the TypeScript implementation against independent Python +# references written only from the format documents: reference/keym.py from +# docs/FORMAT.md (v1), and reference/keym2.py from docs/FORMAT-V2-DESIGN.md and +# the v3 delta in docs/FORMAT-V3-DESIGN.md. # # The frozen fixtures prove Keymaker stays compatible with itself. They cannot # prove it matches its own specification, because the implementation that @@ -57,15 +59,15 @@ jobs: - name: Reference self-test (KEYM v1) run: python3 reference/keym.py selftest - # KEYM v2 has no TypeScript yet, so there is nothing to cross-test - # against — which is the point. The reference is written from - # docs/FORMAT-V2-DESIGN.md *first*, so the specification is what gets - # debugged, before an implementation exists to enshrine its gaps. It has - # already found four (see §11 of that document, and §A of keym2.py). - # - # Running it in CI from now keeps those findings from rotting as the - # proposal is edited. - - name: Reference self-test (KEYM v2, spec-first) + # The reference on its own, before it is compared with anything. Each + # format change is written into docs/FORMAT-V2-DESIGN.md first and + # implemented in keym2.py from that section alone, before the TypeScript, + # so the specification is what gets debugged rather than an + # implementation's reading of it. The findings that produced are in §11 + # of that document and §A of keym2.py; this keeps them from rotting as + # the spec is edited. The byte-for-byte cross-test against the + # TypeScript follows. + - name: Reference self-test (KEYM v2 and v3, spec-first) run: npm run test:keym2 # Byte equality, not just round-trips. For v1 a bidirectional round-trip @@ -75,7 +77,6 @@ jobs: # decode correctly — so two writers could disagree, round-trip perfectly # in both directions, and still produce incompatible files. - name: Bidirectional conformance (KEYM v2 and v3, byte-for-byte) - run: npm run test:conformance2 - name: Bidirectional conformance diff --git a/.github/workflows/crypto-regression.yml b/.github/workflows/crypto-regression.yml index e41cbfb..388cf84 100644 --- a/.github/workflows/crypto-regression.yml +++ b/.github/workflows/crypto-regression.yml @@ -2,8 +2,9 @@ name: Crypto regression # src/lib/crypto.ts is frozen — people hold long-horizon secrets encrypted # with it. This runs the fixture-based regression suite (real ciphertexts -# from earlier releases) on every push and PR, so a dependency bump or -# toolchain change can never silently break decryption of existing files. +# from earlier releases) on every PR and before every deploy and release, so a +# dependency bump or toolchain change can never silently break decryption of +# existing files. # No `push` on main: deploy.yml calls this via `workflow_call` before it # publishes (R01), so main is verified once as a deploy prerequisite rather @@ -26,11 +27,13 @@ jobs: with: persist-credentials: false - # Node 22 across every workflow — build, test and deploy. The suite is - # TypeScript executed directly via native type stripping (on by default - # since 22.18), which avoids adding a transpiler dependency. Keeping one - # runtime everywhere removes a class of "green in CI, different on - # deploy" divergence from a pipeline that ships cryptographic software. + # Node 22 for every build, test and deploy job; the one exception is the + # reproducible-elsewhere leg in ci.yml that exists to try Node 24. The + # suite is TypeScript executed directly via native type stripping (on by + # default since 22.18), which avoids adding a transpiler dependency. + # Keeping one runtime everywhere removes a class of "green in CI, + # different on deploy" divergence from a pipeline that ships + # cryptographic software. - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 From 26425684fc8e63c79c580c696fda934d55a8b6f8 Mon Sep 17 00:00:00 2001 From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:27:56 +0000 Subject: [PATCH 11/34] fix: erase the secret copies the v2 unlock path made and dropped Five review findings on the unlock and enrolment path, each verified against the code before being fixed. 1. keym-v2.ts: buildKdfInput left the NFC password bytes and their length-prefixed copy unerased, keyfileDigest's join left a complete key-file copy, the key-file digest was never erased, and lp() made an unerased length-prefixed copy of the share secret and the PRF output. Once per slot attempted, every unlock and every enrolment. lp() is now lpConcat(), which writes LP(x0) || LP(x1) || ... into one buffer (byte identical, conformance2 unchanged), so the caller's erase reaches every copy; buildKdfInput erases its intermediates in a finally, as v1's buildBaseMaterial does; keyfileDigest erases its join in a finally. 2. keymaker-crypto.ts: decryptData returned result.data.buffer.slice(), which always copies, and never erased the original: a second complete plaintext per decrypt, 100 MB at the cap. The v1 ChaCha path had the same slice. takePlaintextBuffer() hands over the buffer itself when the view spans it, and otherwise copies and zeroes the source. 3. crypto-worker.ts: the erase of keyFileForSlots was a trailing call, so an enrolment that threw (the catch turns it into a response) left the worker's key-file copy in its heap. Now in a finally, matching the crypto-client.ts fallback. 4. keym-v2-shamir.ts: combineShares decoded with a Promise.all that ran before its try/finally, so one malformed share left every decoded share value unerased, including ones still decoding when it rejected. Decoding moved inside the try with allSettled; the first rejection is rethrown unchanged. 6. keym-v2.ts: the Shamir module import in slotSecretFor was a bare import() outside any typing, so on the main-thread fallback a chunk that failed to load reached the user as "the password or key file may be incorrect". It now goes through loadShamir(), which raises the same typed dependency-unavailable error as loadHashWasm/loadNoble and does not cache the rejection. dependencyUnavailable is exported for it. Test: scripts/secret-erase-core-test.mjs (npm run test:secret-erase-core, wired into CI). The copies are internal, so it takes a census: the global Uint8Array constructor is wrapped in a Proxy, and Uint8Array.prototype.slice and TextEncoder.prototype.encode are wrapped, recording every buffer the core allocates during one call; afterwards each recorded buffer is searched for the secret. The Shamir chunk failure is modelled with a real dynamic import of a file that does not exist yet, then is written, and the same call retried. Negative controls (each patched source typechecks; esbuild bundles it): 1a keyfileDigest join not erased: FAIL encrypt/unlock: no copy of the key file survives [83] FAIL worker: key-file copy erased when enrolment throws [83, 83] FAIL worker: key-file copy erased on success too [83, 83] 1b buildKdfInput finally removed: FAIL encrypt/unlock: no copy of the password bytes survives [49] FAIL encrypt/unlock: no copy of the key-file digest survives [32] 1c per-field LP copies restored: FAIL encrypt/unlock: password bytes [53], key-file digest [36] FAIL unlock: no copy of the reconstructed share secret survives [36] FAIL unlock: no copy of the PRF output survives [36] 2 decryptData back to buffer.slice at both sites: FAIL v3-pbkdf2-aes256gcm: returned buffer is the only plaintext copy [44] FAIL pbkdf2-chacha20poly1305: returned buffer is the only copy [47] 3 worker erase back after the try (success only): FAIL worker: the key-file copy is erased when the enrolment throws [64] 4 combineShares back to Promise.all before the try: FAIL the shares that did decode are erased when another is malformed [32, 32] 6a bare import() restored: FAIL a Shamir chunk that fails to load is a typed dependency-unavailable error (got ERR_MODULE_NOT_FOUND) 6b loader caches its rejection: FAIL once the chunk is reachable the same call opens the container Not covered, and said so in the test: copies inside Web Crypto (imported keys, digest inputs) have no JS handle, and JS strings are immutable. The decoded share record that b32Decode returns also carries the share value and is not erased; that belongs to decodeShare/b32Decode and is left to the change already in progress there. --- .github/workflows/ci.yml | 10 + package.json | 1 + scripts/secret-erase-core-test.mjs | 364 +++++++++++++++++++++++++++++ src/lib/crypto-worker.ts | 83 ++++--- src/lib/keym-v2-shamir.ts | 15 +- src/lib/keym-v2.ts | 102 ++++++-- src/lib/keymaker-crypto.ts | 40 +++- 7 files changed, 548 insertions(+), 67 deletions(-) create mode 100644 scripts/secret-erase-core-test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5858b24..19db295 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,16 @@ jobs: - name: Owned secret buffers are zeroed on every path run: npm run test:secret-erase + # The copies the crypto core makes for itself: the length-prefixed KDF + # input, the joined key file behind its digest, a second plaintext left + # by decryptData, share values decoded before a malformed share rejected + # the set, and the worker's key-file copy when an enrolment throws. None + # is visible to a caller, so this takes a census of every Uint8Array the + # core allocates during a call and searches what survives. Also pins a + # Shamir chunk that fails to load as a typed dependency error. + - name: Internal secret copies are zeroed by the crypto core + run: npm run test:secret-erase-core + # The plaintext input buffer is transferred (and so detached/erased) on the # worker path, but the no-worker fallback hands it to encryptContainer # in-thread and never transfers it — and neither encryptContainer nor diff --git a/package.json b/package.json index df48316..18d622f 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:recovery-envelopes": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/recovery-envelopes-test.mjs", "test:passkey-binding": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/passkey-binding-test.mjs", "test:secret-erase": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/secret-erase-test.mjs", + "test:secret-erase-core": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/secret-erase-core-test.mjs", "test:encrypt-input-erase": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/encrypt-input-erase-test.mjs", "test:dearmor-whitespace": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/dearmor-whitespace-test.mjs", "test:audio-wav-bounds": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/audio-wav-bounds-test.mjs", diff --git a/scripts/secret-erase-core-test.mjs b/scripts/secret-erase-core-test.mjs new file mode 100644 index 0000000..0574e13 --- /dev/null +++ b/scripts/secret-erase-core-test.mjs @@ -0,0 +1,364 @@ +#!/usr/bin/env node +/** + * The copies of a secret that the crypto core makes for itself are erased + * before it returns, on success and on failure. + * + * `secret-erase-test.mjs` checks buffers the caller can see. The ones here are + * internal: the length-prefixed copy of a password inside the KDF input, the + * joined key file a digest is taken over, a share value decoded from a set that + * then turned out to contain a malformed share. None of them is reachable from + * outside the call that made it, so this takes a census instead. + * + * ## The census + * + * While an operation runs, every `Uint8Array` the core allocates is recorded: + * the global constructor is wrapped in a Proxy (so `new Uint8Array(n)`, + * `new Uint8Array(arrayBuffer)` and `Uint8Array.from` are seen), and so are + * `Uint8Array.prototype.slice` and `TextEncoder.prototype.encode`, the two + * other ways the core makes a fresh buffer. The instances are ordinary + * intrinsic Uint8Arrays, so `instanceof` and the libraries behave as normal. + * When the operation settles, each recorded buffer is searched for the secret. + * A buffer that still holds it is a copy nobody will erase, because nobody but + * the census holds a reference to it. + * + * What the census cannot see is said here rather than implied: copies made + * inside Web Crypto (an imported key, a digest input) are engine memory with no + * JS handle, and JS strings are immutable. Neither is erasable from JS, so + * neither is tested. + * + * Each section names the finding it pins; every one fails with its fix + * reverted (see the commit that introduced it for the control output). + */ +import esbuild from "esbuild"; +import { createHash } from "node:crypto"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, join } from "node:path"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, ".."); +const LIB = join(ROOT, "src", "lib"); +const OUT = mkdtempSync(join(tmpdir(), "erase-core-")); +const FIXTURES = join(HERE, "fixtures", "keymaker"); + +let failed = 0; +const ok = (cond, msg, detail = "") => { + if (!cond) { console.error("FAIL:", msg, detail); failed++; } + else console.log("ok ", msg); +}; + +// --------------------------------------------------------------------------- +// The census. Installed before any bundle is imported, and only *recording* +// while `observe` is running, so the test's own buffers are never counted. +// --------------------------------------------------------------------------- +const U8 = globalThis.Uint8Array; +let census = null; +const note = (view) => { + if (census) census.push(view); + return view; +}; +const Recording = new Proxy(U8, { + construct(target, args, newTarget) { + return note(Reflect.construct(target, args, newTarget === Recording ? target : newTarget)); + }, +}); +globalThis.Uint8Array = Recording; +const nativeSlice = U8.prototype.slice; +U8.prototype.slice = function (...args) { + return note(nativeSlice.apply(this, args)); +}; +const nativeEncode = TextEncoder.prototype.encode; +TextEncoder.prototype.encode = function (text) { + return note(nativeEncode.call(this, text)); +}; + +/** + * Run `fn` with the census recording. `settleMs` keeps it recording after `fn` + * settles, for work `fn` started and did not wait for: exactly the stragglers a + * `Promise.all` that rejected early leaves behind. + */ +async function observe(fn, settleMs = 0) { + census = []; + let value; + let error = null; + try { + value = await fn(); + } catch (e) { + error = e; + } + if (settleMs) await new Promise((r) => setTimeout(r, settleMs)); + const seen = census; + census = null; + return { value, error, seen }; +} + +/** + * Sizes of the recorded buffers still holding `needle`. `exact` matches a + * buffer that *is* the needle rather than one containing it; `except` names + * buffers that are allowed to hold it (the caller's own, or the one returned). + */ +function holders(seen, needle, { exact = false, except = [] } = {}) { + const want = Buffer.from(needle.buffer, needle.byteOffset, needle.byteLength); + const visited = new Set(); + const found = []; + for (const view of seen) { + const buf = view.buffer; + if (visited.has(buf) || except.includes(buf)) continue; + visited.add(buf); + if (buf.byteLength === 0) continue; // detached by a transfer + const hay = Buffer.from(buf); + if (exact ? hay.equals(want) : hay.indexOf(want) !== -1) found.push(hay.length); + } + return found; +} + +const none = (found) => (found.length ? `${found.length} buffer(s) still hold it, sizes [${found.join(", ")}]` : ""); +const toAB = (bytes) => { + const copy = new U8(bytes.length); + copy.set(bytes); + return copy.buffer; +}; +const hex = (s) => U8.from(Buffer.from(s, "hex")); + +async function bundle(name, contents, plugins = []) { + const outfile = join(OUT, `${name}.mjs`); + await esbuild.build({ + stdin: { contents, resolveDir: LIB, loader: "ts", sourcefile: `${name}.ts` }, + bundle: true, + format: "esm", + platform: "node", + outfile, + logLevel: "warning", + plugins, + }); + return import(pathToFileURL(outfile).href); +} + +const m = await bundle( + "core", + `export { decryptData, encryptContainer, KdfId, CipherId } from "./keymaker-crypto"; + export { combineShares, decodeShareAny } from "./keym-v2-shamir";` +); + +const meta = JSON.parse(readFileSync(join(FIXTURES, "fixtures.json"), "utf8")); +const fixture = (name) => { + const entry = meta.fixtures.find((f) => f.name === name); + if (!entry) throw new Error(`fixture ${name} is missing from fixtures.json`); + return { entry, bytes: U8.from(readFileSync(join(FIXTURES, entry.file))) }; +}; + +const PASSWORD = "erase-canary: correct horse battery staple 7f3a9c"; +const PASSWORD_BYTES = U8.from(Buffer.from(PASSWORD.normalize("NFC"), "utf8")); +const KEY_FILE = U8.from({ length: 64 }, (_, i) => (i * 29 + 101) & 0xff); +// §4.2: SHA-256("keymaker.v2.keyfile" || key file). The domain string is spec +// text; the tripwire below keeps this needle from going stale silently, which +// would turn the digest checks into checks for a value that never exists. +const KEY_FILE_DIGEST = U8.from(createHash("sha256").update("keymaker.v2.keyfile").update(KEY_FILE).digest()); +ok(readFileSync(join(LIB, "keym-v2.ts"), "utf8").includes('"keymaker.v2.keyfile"'), + "tripwire: the key-file digest needle uses the domain string keym-v2.ts actually hashes with"); +const OPTIONS = { kdf: { kdf: m.KdfId.PBKDF2, params: { iterations: 600_000 } }, cipher: m.CipherId.AES_256_GCM }; +const settle = () => new Promise((r) => setTimeout(r, 25)); + +// --------------------------------------------------------------------------- +// Finding 1: the passphrase slot's KDF input. `buildKdfInput` left the NFC +// password bytes and their length-prefixed copy unerased, `keyfileDigest`'s +// join left a whole key-file copy, and the digest itself (the key file's half +// of the slot secret) was never erased either. Once per slot, every unlock and +// every enrolment. +// --------------------------------------------------------------------------- +let container; +{ + const plaintext = U8.from(Buffer.from("erase-canary plaintext for the passphrase slot")); + const enc = await observe(() => m.encryptContainer(toAB(plaintext), PASSWORD, toAB(KEY_FILE), OPTIONS)); + ok(enc.error === null && enc.value instanceof ArrayBuffer, "encrypt with password + key file succeeds", String(enc.error)); + container = enc.value; + ok(holders(enc.seen, PASSWORD_BYTES).length === 0, + "encrypt: no copy of the password bytes survives", none(holders(enc.seen, PASSWORD_BYTES))); + ok(holders(enc.seen, KEY_FILE).length === 0, + "encrypt: no copy of the key file survives", none(holders(enc.seen, KEY_FILE))); + ok(holders(enc.seen, KEY_FILE_DIGEST).length === 0, + "encrypt: no copy of the key-file digest survives", none(holders(enc.seen, KEY_FILE_DIGEST))); + + const dec = await observe(() => m.decryptData(container.slice(0), PASSWORD, toAB(KEY_FILE))); + ok(dec.error === null && Buffer.from(dec.value.data).equals(Buffer.from(plaintext)), + "decrypt with password + key file round-trips", String(dec.error)); + // The census is not blind: the core assembled this plaintext in a buffer it + // allocated, so at least one recorded buffer has to hold it. If none did, + // every "no copy survives" line in this file would be vacuous. + ok(holders(dec.seen, plaintext).length > 0, + "tripwire: the census records the core's own allocations (it saw the decrypted plaintext)"); + ok(holders(dec.seen, PASSWORD_BYTES).length === 0, + "unlock: no copy of the password bytes survives", none(holders(dec.seen, PASSWORD_BYTES))); + ok(holders(dec.seen, KEY_FILE).length === 0, + "unlock: no copy of the key file survives", none(holders(dec.seen, KEY_FILE))); + ok(holders(dec.seen, KEY_FILE_DIGEST).length === 0, + "unlock: no copy of the key-file digest survives", none(holders(dec.seen, KEY_FILE_DIGEST))); +} + +// Finding 1, the other two slot types: `lp()` made a length-prefixed copy of +// the share secret and of the PRF output, and nothing erased it. +{ + const { entry, bytes } = fixture("v3-shamir-aes256gcm"); + const shares = entry.shamir.shares.slice(0, entry.shamir.threshold); + const secret = await m.combineShares(shares); // census off: the test's own copy + const dec = await observe(() => m.decryptData(toAB(bytes), "", null, shares)); + ok(dec.error === null && Buffer.from(dec.value.data).toString() === entry.plaintext, + "unlock with a share set succeeds", String(dec.error)); + ok(holders(dec.seen, secret).length === 0, + "unlock: no copy of the reconstructed share secret survives", none(holders(dec.seen, secret))); +} +{ + const { entry, bytes } = fixture("v3-passkey-aes256gcm"); + const prf = hex(entry.passkey.prfOutputHex); + const dec = await observe(() => m.decryptData(toAB(bytes), "", null, undefined, prf)); + ok(dec.error === null && Buffer.from(dec.value.data).toString() === entry.plaintext, + "unlock with a passkey PRF output succeeds", String(dec.error)); + ok(holders(dec.seen, prf, { except: [prf.buffer] }).length === 0, + "unlock: no copy of the PRF output survives (the caller's own excepted)", + none(holders(dec.seen, prf, { except: [prf.buffer] }))); +} + +// --------------------------------------------------------------------------- +// Finding 2: decryptData returned `result.data.buffer.slice(...)`, which always +// copies, and never erased what it copied from: a second complete plaintext per +// decrypt, 100 MB of it at the cap. The v1 ChaCha path had the same slice. +// --------------------------------------------------------------------------- +for (const [name, password, keyFile] of [ + ["v3-pbkdf2-aes256gcm", meta.password, null], + ["pbkdf2-chacha20poly1305", meta.password, hex(meta.keyFileHex)], +]) { + const { entry, bytes } = fixture(name); + if (entry.keyFile !== (keyFile !== null)) throw new Error(`${name}: key-file expectation drifted`); + const want = U8.from(Buffer.from(entry.plaintext)); + const dec = await observe(() => m.decryptData(toAB(bytes), password, keyFile ? toAB(keyFile) : null)); + ok(dec.error === null && Buffer.from(dec.value.data).equals(Buffer.from(want)), + `${name}: decrypts`, String(dec.error)); + const extra = dec.error ? [] : holders(dec.seen, want, { except: [dec.value.data] }); + ok(extra.length === 0, `${name}: the returned buffer is the only plaintext copy left`, none(extra)); +} + +// --------------------------------------------------------------------------- +// Finding 4: combineShares decoded with a `Promise.all` that sat outside its +// `try/finally`. One malformed share rejected it before the `try` was entered, +// so the shares that decoded were never erased, and those still decoding +// resolved afterwards with no one left to erase them. +// +// Matched exactly (a buffer that *is* a share value), because the decoded +// record b32Decode returns also carries the value inside it, and that buffer +// belongs to decodeShare, not to combineShares. It is not asserted here. +// --------------------------------------------------------------------------- +{ + const { entry } = fixture("v3-shamir-aes256gcm"); + const good = entry.shamir.shares.slice(0, 2); + const values = []; + for (const text of good) values.push((await m.decodeShareAny(text)).value); + const bad = "KMSHARE1:NOT-A-SHARE"; + const run = await observe(() => m.combineShares([...good, bad]), 100); + ok(run.error !== null, "combineShares rejects a set containing a malformed share"); + const left = values.flatMap((v) => holders(run.seen, v, { exact: true })); + ok(left.length === 0, "the shares that did decode are erased when another one is malformed", none(left)); +} + +// --------------------------------------------------------------------------- +// Finding 3: the worker's copy of the key file (taken so a share or passkey +// enrolment can still read it after encryptContainer zeroes the original) was +// erased after the enrolments, not in a `finally`. The handler's `catch` turns +// a throw into an ordinary response, so a failed enrolment left it in the +// worker heap. Driven through the real message handler with a stub `self`. +// --------------------------------------------------------------------------- +{ + const listeners = []; + const posted = []; + const previousSelf = globalThis.self; + globalThis.self = { + addEventListener: (type, fn) => { if (type === "message") listeners.push(fn); }, + postMessage: (msg) => posted.push(msg), + }; + await bundle("worker", `import "./crypto-worker";`); + globalThis.self = previousSelf; + ok(listeners.length === 1, "the worker registered its message handler"); + + const request = (id, shamir) => ({ + data: { + id, op: "encrypt", data: toAB(U8.from(Buffer.from("worker plaintext"))), password: PASSWORD, + keyFile: toAB(KEY_FILE), options: OPTIONS, shamir, + }, + }); + + // threshold 1 is below §4.6's minimum: shamirSplit throws, after the slot has + // been unwrapped with the key-file copy, inside the enrolment. + const failing = await observe(() => listeners[0](request(1, { threshold: 1, count: 3 }))); + const failRes = posted.find((p) => p.id === 1); + ok(failRes && failRes.ok === false, "an enrolment that throws comes back as an error response", JSON.stringify(failRes)); + ok(holders(failing.seen, KEY_FILE).length === 0, + "worker: the key-file copy is erased when the enrolment throws", none(holders(failing.seen, KEY_FILE))); + + const passing = await observe(() => listeners[0](request(2, { threshold: 2, count: 3 }))); + const passRes = posted.find((p) => p.id === 2); + ok(passRes && passRes.ok === true && passRes.shares?.length === 3, + "worker: the same request with a valid threshold still enrols the share set", JSON.stringify(passRes?.message)); + ok(holders(passing.seen, KEY_FILE).length === 0, + "worker: the key-file copy is erased on success too", none(holders(passing.seen, KEY_FILE))); +} + +// --------------------------------------------------------------------------- +// Finding 6 (not an erasure, but the same unlock path): the Shamir module is a +// separate chunk on the main-thread fallback, and a chunk that fails to load +// used to reach the user as "the password or key file may be incorrect". +// +// Modelled faithfully rather than mocked: this bundle leaves `keym-v2-shamir` +// as a real dynamic import of a file that does not exist yet, so the failure is +// the runtime's own module-not-found, exactly what an unreachable chunk is. +// Then the file is written and the same call is made again, which is what +// "the failure is not cached" has to mean to someone who reconnects. +// --------------------------------------------------------------------------- +{ + const CHUNK = "keym-v2-shamir.late-chunk.mjs"; + const missingChunk = { + name: "missing-shamir-chunk", + setup(build) { + build.onResolve({ filter: /keym-v2-shamir$/ }, () => ({ path: `./${CHUNK}`, external: true })); + }, + }; + const offline = await bundle( + "offline", + `export { decryptData, isUserFacingError } from "./keymaker-crypto";`, + [missingChunk] + ); + const { entry, bytes } = fixture("v3-shamir-aes256gcm"); + const shares = entry.shamir.shares.slice(0, entry.shamir.threshold); + + let first = null; + try { + await offline.decryptData(toAB(bytes), "", null, shares); + } catch (e) { + first = e; + } + ok(first !== null && offline.isUserFacingError(first) && first.code === "dependency-unavailable", + "a Shamir chunk that fails to load is a typed dependency-unavailable error, not a wrong password", + first ? `${first.code ?? "(untyped)"}: ${String(first.message).slice(0, 100)}` : "no error"); + ok(first !== null && /were not checked/.test(first.message), + "and it tells the user the file and password were never checked", first ? first.message.slice(0, 100) : ""); + + // The chunk arrives: bundle the real Shamir module at the path the first + // attempt could not reach, and try again with the same bundle. + await esbuild.build({ + entryPoints: [join(LIB, "keym-v2-shamir.ts")], + bundle: true, format: "esm", platform: "node", outfile: join(OUT, CHUNK), logLevel: "warning", + }); + let second = null; + let opened = null; + try { + opened = await offline.decryptData(toAB(bytes), "", null, shares); + } catch (e) { + second = e; + } + ok(second === null && Buffer.from(opened.data).toString() === entry.plaintext, + "once the chunk is reachable the same call opens the container (the failure was not cached)", + second ? String(second.message).slice(0, 100) : ""); +} + +await settle(); +console.log(failed === 0 ? "\nAll core secret-erase checks passed." : `\n${failed} check(s) FAILED.`); +process.exit(failed === 0 ? 0 : 1); diff --git a/src/lib/crypto-worker.ts b/src/lib/crypto-worker.ts index 02df63a..bb22753 100644 --- a/src/lib/crypto-worker.ts +++ b/src/lib/crypto-worker.ts @@ -218,49 +218,56 @@ ctx.addEventListener("message", async (event: MessageEvent) => { req.keyFile && (req.shamir || req.passkey) ? new Uint8Array(req.keyFile.slice(0)) : null; - let out = await encryptContainer(req.data, req.password, req.keyFile, req.options); + let out: ArrayBuffer; let shares: string[] | undefined; + try { + out = await encryptContainer(req.data, req.password, req.keyFile, req.options); - if (req.shamir) { - // §4.6. Enrolled here rather than in the page so the share secret and - // the coefficients are generated, used and dropped inside the worker's - // heap — the same reason the derivation lives here. - const { addShamirSlotKeym2 } = await import("./keym-v2"); - const enrolled = await addShamirSlotKeym2( - new Uint8Array(out), - { password: req.password, keyFile: keyFileForSlots }, - req.shamir.threshold, - req.shamir.count - ); - out = enrolled.container.buffer.slice( - enrolled.container.byteOffset, - enrolled.container.byteOffset + enrolled.container.byteLength - ) as ArrayBuffer; - shares = enrolled.shares; - } + if (req.shamir) { + // §4.6. Enrolled here rather than in the page so the share secret and + // the coefficients are generated, used and dropped inside the worker's + // heap — the same reason the derivation lives here. + const { addShamirSlotKeym2 } = await import("./keym-v2"); + const enrolled = await addShamirSlotKeym2( + new Uint8Array(out), + { password: req.password, keyFile: keyFileForSlots }, + req.shamir.threshold, + req.shamir.count + ); + out = enrolled.container.buffer.slice( + enrolled.container.byteOffset, + enrolled.container.byteOffset + enrolled.container.byteLength + ) as ArrayBuffer; + shares = enrolled.shares; + } - if (req.passkey) { - // §4.7. Added after encryption for the same reason a share set is: the - // container has to exist before a slot can be added to it. The rule - // that a passkey never travels alone is satisfied structurally here — - // `out` already carries the passphrase slot encryptContainer wrote. - const { addPasskeySlotKeym2 } = await import("./keym-v2"); - const enrolled = await addPasskeySlotKeym2( - new Uint8Array(out), - { password: req.password, keyFile: keyFileForSlots }, - req.passkey.prfOutput, - req.passkey.salt - ); - out = enrolled.buffer.slice( - enrolled.byteOffset, - enrolled.byteOffset + enrolled.byteLength - ) as ArrayBuffer; + if (req.passkey) { + // §4.7. Added after encryption for the same reason a share set is: the + // container has to exist before a slot can be added to it. The rule + // that a passkey never travels alone is satisfied structurally here — + // `out` already carries the passphrase slot encryptContainer wrote. + const { addPasskeySlotKeym2 } = await import("./keym-v2"); + const enrolled = await addPasskeySlotKeym2( + new Uint8Array(out), + { password: req.password, keyFile: keyFileForSlots }, + req.passkey.prfOutput, + req.passkey.salt + ); + out = enrolled.buffer.slice( + enrolled.byteOffset, + enrolled.byteOffset + enrolled.byteLength + ) as ArrayBuffer; + } + } finally { + // The copy taken above so the enrolments could still read it. Same + // standard encryptContainer applies to the original. In a `finally`, + // as crypto-client.ts's fallback already does: the copy outlives three + // awaits that can each throw, and the `catch` below turns a throw into + // an ordinary response, so an enrolment that failed part-way left half + // the key material in this heap with nothing left to erase it. + secureErase(keyFileForSlots); } - // The copy taken above so the enrolments could still read it. Same - // standard encryptContainer applies to the original. - if (keyFileForSlots) secureErase(keyFileForSlots); - const response: CryptoResponse = { id: req.id, ok: true, op: "encrypt", data: out, shares }; ctx.postMessage(response, [out]); return; diff --git a/src/lib/keym-v2-shamir.ts b/src/lib/keym-v2-shamir.ts index af35f5f..bef0ee7 100644 --- a/src/lib/keym-v2-shamir.ts +++ b/src/lib/keym-v2-shamir.ts @@ -408,7 +408,7 @@ export async function decodeShare(text: string): Promise { */ export async function combineShares(texts: string[], expectedSetId?: Uint8Array): Promise { if (texts.length === 0) reject(); - const shares = await Promise.all(texts.map((t) => decodeShareAny(t))); + const shares: Share[] = []; // Everything from here is inside the `finally`, because a decoded share value // is key material of the same class as the secret it reconstructs — k of them @@ -418,6 +418,19 @@ export async function combineShares(texts: string[], expectedSetId?: Uint8Array) // holds a reference to them. `shamirCombine` allocates its own output, so // erasing the parts afterwards cannot reach it. try { + // Decoding is inside the `try` too. It used to be a `Promise.all` above it, + // and one malformed share rejected that before the `try` was entered: the + // shares that had decoded were never erased, and the ones still decoding + // resolved afterwards into nothing that could erase them. `allSettled` + // waits for every decode, so each value that exists is in `shares` by the + // time any failure is raised. + const settled = await Promise.allSettled(texts.map((t) => decodeShareAny(t))); + for (const result of settled) { + if (result.status === "fulfilled") shares.push(result.value); + } + const failed = settled.find((r): r is PromiseRejectedResult => r.status === "rejected"); + if (failed) throw failed.reason; + const thresholds = new Set(shares.map((s) => s.threshold)); if (thresholds.size !== 1) reject(); const k = shares[0]?.threshold as number; diff --git a/src/lib/keym-v2.ts b/src/lib/keym-v2.ts index bfaebe2..303b2fb 100644 --- a/src/lib/keym-v2.ts +++ b/src/lib/keym-v2.ts @@ -47,6 +47,7 @@ import { CipherId, + dependencyUnavailable, describeWeakKdf, isUserFacingError, KdfId, @@ -725,11 +726,24 @@ async function requireAuthenticSlotTable(parsed: Keym2Container, master: Uint8Ar // Key derivation (§4) // --------------------------------------------------------------------------- -/** §4.1 `LP(x) = uint32_be(len(x)) || x`. */ -function lp(x: Uint8Array): Uint8Array { - const out = new Uint8Array(4 + x.length); - new DataView(out.buffer).setUint32(0, x.length, false); - out.set(x, 4); +/** + * §4.1 `LP(x0) || LP(x1) || ...`, where `LP(x) = uint32_be(len(x)) || x`. + * + * Written straight into one buffer rather than as `concat(fields.map(lp))`. + * The fields are secrets (a password, a share secret, a PRF output), and the + * two-step form allocated a length-prefixed copy of each one that nobody held a + * reference to and so nobody erased. One buffer means the caller's + * `secureErase` on the result reaches every copy this function made. + */ +function lpConcat(fields: Uint8Array[]): Uint8Array { + const out = new Uint8Array(fields.reduce((n, f) => n + 4 + f.length, 0)); + const view = new DataView(out.buffer); + let at = 0; + for (const field of fields) { + view.setUint32(at, field.length, false); + out.set(field, at + 4); + at += 4 + field.length; + } return out; } @@ -738,10 +752,19 @@ function lp(x: Uint8Array): Uint8Array { * * This is the half of KM-05 that length prefixes alone do not solve, and it is * what makes a large key file free: the KDF sees 32 bytes, not 100 MB. + * + * Web Crypto has no streaming digest, so the domain string and the key file + * have to be joined first, and that join is a complete copy of the key file. + * Erased in a `finally`: it is half the key material, it is made once per slot + * the walk attempts, and nothing else holds a reference to it. */ async function keyfileDigest(keyFile: Uint8Array): Promise { - const digest = await crypto.subtle.digest("SHA-256", concat([CTX_KEYFILE, keyFile]) as BufferSource); - return new Uint8Array(digest); + const input = concat([CTX_KEYFILE, keyFile]); + try { + return new Uint8Array(await crypto.subtle.digest("SHA-256", input as BufferSource)); + } finally { + secureErase(input); + } } /** @@ -751,18 +774,30 @@ async function keyfileDigest(keyFile: Uint8Array): Promise { * Worth being precise about what closes KM-05, because the obvious answer is * wrong: for this particular pair it is §4.2's *hashing*, not the length * prefixes. A fixed-width 32-byte field cannot slide, so `("ab","c")` and - * `("a","bc")` differ either way. What `lp()` buys is injectivity of the + * `("a","bc")` differ either way. What `LP` buys is injectivity of the * concatenation as a whole, which is what keeps the encoding sound as fields * are added. (Established by a negative control on the Python reference: * stubbing `LP` to the identity left every injectivity test green.) + * + * The returned buffer is the caller's to erase, and it is the only copy of the + * password bytes that survives this function. `normalized` and the key-file + * digest are erased here, in a `finally` so a failed digest cannot skip it; the + * same pattern as v1's `buildBaseMaterial`. This runs once per passphrase slot + * the walk attempts, on every unlock and every enrolment. */ async function buildKdfInput(password: string, keyFile: Uint8Array | null): Promise { const normalized = textEncoder.encode(password.normalize("NFC")); - // "No key file" is LP("") rather than an omitted field — one shape, one code - // path, and the absent case explicitly encoded. It cannot collide with a - // present-but-empty key file, which hashes to 32 bytes. - const digest = keyFile ? await keyfileDigest(keyFile) : new Uint8Array(0); - return concat([lp(CTX_KDF_INPUT), lp(normalized), lp(digest)]); + let digest: Uint8Array | null = null; + try { + // "No key file" is LP("") rather than an omitted field — one shape, one code + // path, and the absent case explicitly encoded. It cannot collide with a + // present-but-empty key file, which hashes to 32 bytes. + digest = keyFile ? await keyfileDigest(keyFile) : new Uint8Array(0); + return lpConcat([CTX_KDF_INPUT, normalized, digest]); + } finally { + secureErase(normalized); + secureErase(digest); + } } /** @@ -774,7 +809,7 @@ async function buildKdfInput(password: string, keyFile: Uint8Array | null): Prom */ function buildShamirInput(shareSecret: Uint8Array): Uint8Array { if (shareSecret.length !== MASTER_KEY_LEN) reject(); - return concat([lp(CTX_SHAMIR_INPUT), lp(shareSecret)]); + return lpConcat([CTX_SHAMIR_INPUT, shareSecret]); } /** §4.7. WebAuthn's PRF extension returns 32 bytes. */ @@ -811,7 +846,7 @@ export async function derivePrfSalt(slotSalt: Uint8Array): Promise { */ function buildPasskeyInput(prfOutput: Uint8Array): Uint8Array { if (prfOutput.length !== KEYM2_PRF_OUTPUT_LEN) reject(); - return concat([lp(CTX_PASSKEY_INPUT), lp(prfOutput)]); + return lpConcat([CTX_PASSKEY_INPUT, prfOutput]); } /** §4.3. The slot key, from that slot's own KDF, salt and parameters. */ @@ -1101,6 +1136,29 @@ export interface Keym2Secrets { prfOutput?: Uint8Array | undefined; } +/** + * The Shamir module, loaded on first use and typed when it cannot be. + * + * On the worker path this is bundled inline and cannot fail. On the main-thread + * fallback it is a separate chunk, and a chunk can be unreachable: offline + * before the precache has landed, or a deploy that moved under the page. The + * bare `import()` rejected with the browser's own error, `decryptData` passed it + * through untyped, and the UI reported "the password or key file may be + * incorrect" for a share set that was never read. Same failure, same answer as + * `loadHashWasm` and `loadNoble`: a typed `dependency-unavailable` error, and the + * rejection is not cached, so a later attempt imports again. + */ +let shamirModulePromise: Promise | null = null; +function loadShamir(): Promise { + if (!shamirModulePromise) { + shamirModulePromise = import("./keym-v2-shamir").catch((cause: unknown) => { + shamirModulePromise = null; + throw dependencyUnavailable("the recovery-share decoder", cause); + }); + } + return shamirModulePromise; +} + /** * §4.1 / §4.6. The slot secret this caller can offer *this* slot, or null if it * holds nothing of the kind the slot wants. @@ -1117,15 +1175,21 @@ async function slotSecretFor(slot: Keym2Slot, secrets: Keym2Secrets): Promise { * moved under them, would retype a correct password into a container that * was never opened. In a backup tool that is the wrong answer to send * someone hunting with. + * + * Exported for keym-v2.ts, whose own lazily loaded module (the Shamir code) has + * the same failure and owes the user the same answer. */ -function dependencyUnavailable(what: string, cause: unknown): KeymakerError { +export function dependencyUnavailable(what: string, cause: unknown): KeymakerError { const detail = cause instanceof Error && cause.message ? ` (${cause.message})` : ""; return new KeymakerError( "dependency-unavailable", @@ -1153,6 +1156,31 @@ async function legacyDecryptWithNormalizationFallback( } } +/** + * Hand a decrypted plaintext back as an ArrayBuffer without leaving a second + * copy of it behind. + * + * `buffer.slice()` always copies, and the view it copied from was never erased: + * every decrypt that went through it left a complete second plaintext in the + * heap until the collector reached it, 100 MB of it at the cap. When the view + * already spans its whole buffer (what `decryptKeym2` and the ChaCha path both + * return) the buffer itself is handed over and there is no copy. Otherwise the + * copy is taken and the region it came from is zeroed, so exactly one copy + * leaves this function either way. + */ +function takePlaintextBuffer(plain: Uint8Array): ArrayBuffer { + if ( + plain.buffer instanceof ArrayBuffer && + plain.byteOffset === 0 && + plain.byteLength === plain.buffer.byteLength + ) { + return plain.buffer; + } + const copy = plain.buffer.slice(plain.byteOffset, plain.byteOffset + plain.byteLength) as ArrayBuffer; + secureErase(plain); + return copy; +} + export async function decryptData( encryptedBuffer: ArrayBuffer, password: string, @@ -1202,10 +1230,7 @@ export async function decryptData( prfOutput ); return { - data: result.data.buffer.slice( - result.data.byteOffset, - result.data.byteOffset + result.data.byteLength - ) as ArrayBuffer, + data: takePlaintextBuffer(result.data), format, keyFileUsed: result.keyFileUsed, slotTableAuthentic: result.slotTableAuthentic, @@ -1273,10 +1298,7 @@ export async function decryptData( } } - const out = - plain instanceof Uint8Array - ? (plain.buffer.slice(plain.byteOffset, plain.byteOffset + plain.byteLength) as ArrayBuffer) - : plain; + const out = plain instanceof Uint8Array ? takePlaintextBuffer(plain) : plain; return { data: out, format, From 83492854c1ae31e3b28e11530a23ed3bdb76cd8f Mon Sep 17 00:00:00 2001 From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:28:13 +0000 Subject: [PATCH 12/34] fix: size the container ceiling from the format's worst case MAX_CONTAINER_SIZE was MAX_PLAINTEXT_SIZE + 4096, a v1-era allowance (71-byte header, 32 bytes of tags). A v3 chained container with the 8 slots section 6 allows, holding a plaintext at the 100 MiB cap, is 57 (slot table offset) + 8 x 112 (chained slot) + 100 x 32 (chunk tags) = 4153 bytes over the plaintext so a legal backup at the cap was refused by decryptData as too large, with the "open it with keym2.py" message, by the build that wrote it. The ceiling is now computed from the format constants: the largest slot table offset, slot length, slot count and per-chunk tag, with the chunk count taken as max(1, ceil(n / chunk)), the rule the writer uses. keymaker-crypto.ts restates the constants rather than importing keym-v2.ts (the dependency runs one way), so keym2-dispatch.mts holds them to the format module's exports, as it already does for KEYM2_HEADER_PEEK_BYTES: - MAX_CONTAINER_SIZE >= the worst case over v2/v3 x all three ciphers, computed from keym2SlotTableOffset, keym2SlotLen, KEYM2_MAX_SLOTS and KEYM2_CHUNK_SIZE, with the per-chunk tag measured from real containers; - the overhead stays under 64 KiB, so it cannot pass by being unbounded; - the worst case is built for real (v3, chained, a MAX_PLAINTEXT_SIZE plaintext, 1 passphrase + 7 passkey slots), its length matches the arithmetic, and decryptData opens it byte-identically. About 3 s and 0.5 GiB RSS locally. Negative control (typechecks): ceiling back to MAX_PLAINTEXT_SIZE + 4096 FAIL the container ceiling covers the largest container a capped plaintext can become worst case is 104861753 bytes (v3 + chained, 8 slots, 100 chunks); the ceiling is 104861696. FAIL decryptData opens the worst-case legal container at the plaintext cap, byte-identical This backup is larger than the 100 MB this app can open in a browser tab... 66 passed, 2 failed --- scripts/keym2-dispatch.mts | 108 +++++++++++++++++++++++++++++++++++++ src/lib/keymaker-crypto.ts | 51 ++++++++++++++---- 2 files changed, 150 insertions(+), 9 deletions(-) diff --git a/scripts/keym2-dispatch.mts b/scripts/keym2-dispatch.mts index 8f8a904..ae28772 100644 --- a/scripts/keym2-dispatch.mts +++ b/scripts/keym2-dispatch.mts @@ -21,6 +21,7 @@ import { decryptData, detectFormat, MAX_CONTAINER_SIZE, + MAX_PLAINTEXT_SIZE, oversizeRecoveryHelp, encryptData, type KdfParams, @@ -28,9 +29,11 @@ import { import { KEYM2_ARMOR_PREFIX, KEYM2_CORE_HEADER_LEN, + addPasskeySlotKeym2, armorKeym2, dearmorKeym2, encryptKeym2, + KEYM2_CHUNK_SIZE, KEYM2_VERSION_V2, KEYM2_HEADER_PEEK_BYTES, KEYM2_MAX_SLOTS, @@ -484,6 +487,111 @@ check( ); } +// --------------------------------------------------------------------------- +// The container ceiling has to admit every legal container at the plaintext cap +// --------------------------------------------------------------------------- +// +// MAX_CONTAINER_SIZE was `MAX_PLAINTEXT_SIZE + 4096`, a v1-era allowance. A v3 +// chained container with the 8 slots §6 permits, holding a plaintext at the +// cap, is 4153 bytes over the plaintext: a legal backup this build wrote, or +// could have, refused as "too large" by the build that has to open it. +// +// keymaker-crypto.ts restates the format constants rather than importing +// keym-v2.ts, so this holds the ceiling to the format module's own exports, and +// the per-chunk tag overhead to real bytes rather than to a second restatement. +// Then it builds that worst case for real and opens it through decryptData. +{ + const CIPHERS: [string, CipherId][] = [ + ["aes-256-gcm", CipherId.AES_256_GCM], + ["chacha20-poly1305", CipherId.CHACHA20_POLY1305], + ["chained", CipherId.CHAINED], + ]; + const chunks = Math.max(1, Math.ceil(MAX_PLAINTEXT_SIZE / KEYM2_CHUNK_SIZE)); + + // Tag bytes per chunk, measured: a one-slot, one-chunk container is the slot + // table, one slot, the plaintext and one chunk's tags, so the tags are what + // is left over. + const tagPerChunk = new Map(); + for (const [, cipher] of CIPHERS) { + const small = await encryptKeym2(enc.encode("tag probe"), PASSWORD, null, { kdf: FAST, cipher }); + tagPerChunk.set( + cipher, + small.length - keym2SlotTableOffset(KEYM2_VERSION_V3) - keym2SlotLen(cipher) - enc.encode("tag probe").length + ); + } + + let worst = 0; + let worstLabel = ""; + for (const version of [KEYM2_VERSION_V2, KEYM2_VERSION_V3]) { + for (const [name, cipher] of CIPHERS) { + const size = + MAX_PLAINTEXT_SIZE + + keym2SlotTableOffset(version) + + KEYM2_MAX_SLOTS * keym2SlotLen(cipher) + + chunks * (tagPerChunk.get(cipher) as number); + if (size > worst) { + worst = size; + worstLabel = `v${version} + ${name}`; + } + } + } + check( + MAX_CONTAINER_SIZE >= worst, + "the container ceiling covers the largest container a capped plaintext can become", + `worst case is ${worst} bytes (${worstLabel}, ${KEYM2_MAX_SLOTS} slots, ${chunks} chunks); ` + + `the ceiling is ${MAX_CONTAINER_SIZE}. A legal backup at the cap is refused as too large.` + ); + // Covering the worst case by being enormous would pass the line above while + // letting an arbitrarily large paste reach the parser. + check( + MAX_CONTAINER_SIZE - MAX_PLAINTEXT_SIZE <= 64 * 1024, + "the container ceiling is still the plaintext cap plus overhead, not an unbounded allowance", + `${MAX_CONTAINER_SIZE - MAX_PLAINTEXT_SIZE} bytes over the plaintext cap` + ); + + // The worst case, built: v3, chained, a plaintext of exactly MAX_PLAINTEXT_SIZE, + // and seven passkey slots on top of the passphrase slot. Passkey slots because + // they unwrap through HKDF, so only the first enrolment pays for PBKDF2. + // About 3 s and 0.5 GiB here, which is the price of proving the cap holds on + // the one container that reaches it. + const plaintext = new Uint8Array(MAX_PLAINTEXT_SIZE); + for (let i = 0; i < plaintext.length; i += 4093) plaintext[i] = (i * 31 + 7) & 0xff; + let container = await encryptKeym2(plaintext, PASSWORD, null, { kdf: FAST, cipher: CipherId.CHAINED }); + const prf = new Uint8Array(32).fill(0x5c); + for (let i = 1; i < KEYM2_MAX_SLOTS; i++) { + container = await addPasskeySlotKeym2( + container, + i === 1 ? { password: PASSWORD } : { prfOutput: prf }, + prf, + webcrypto.getRandomValues(new Uint8Array(32)) + ); + } + const expected = + MAX_PLAINTEXT_SIZE + + keym2SlotTableOffset(KEYM2_VERSION_V3) + + KEYM2_MAX_SLOTS * keym2SlotLen(CipherId.CHAINED) + + chunks * (tagPerChunk.get(CipherId.CHAINED) as number); + check( + container.length === expected, + "a real 8-slot chained v3 container at the cap is exactly the size the arithmetic predicts", + `built ${container.length}, predicted ${expected}` + ); + + let opened: Uint8Array | null = null; + let message = ""; + try { + const result = await decryptData(toArrayBuffer(container), PASSWORD, null); + opened = new Uint8Array(result.data); + } catch (e) { + message = e instanceof Error ? e.message.slice(0, 120) : String(e); + } + check( + opened !== null && opened.length === plaintext.length && Buffer.compare(opened, plaintext) === 0, + "decryptData opens the worst-case legal container at the plaintext cap, byte-identical", + message || "plaintext differs" + ); +} + // --------------------------------------------------------------------------- // The worker probe retry policy // --------------------------------------------------------------------------- diff --git a/src/lib/keymaker-crypto.ts b/src/lib/keymaker-crypto.ts index d49a67d..4d90142 100644 --- a/src/lib/keymaker-crypto.ts +++ b/src/lib/keymaker-crypto.ts @@ -106,14 +106,6 @@ const MAX_FILE_SIZE = MAX_PLAINTEXT_SIZE; */ export const MAX_PASSWORD_LENGTH = 1024; -/** - * Largest container we will even attempt to decrypt: the 100 MB plaintext cap - * plus the largest possible header (71 B) and tags (32 B), rounded up. - * - * The file picker already refuses oversized files, but pasted base64 reaches - * decryptData() without passing that check. A core crypto API should enforce - * its own resource limits rather than trusting whichever UI calls it. - */ /** * What to tell someone whose backup is too big for this app to open. * @@ -144,7 +136,48 @@ export function oversizeRecoveryHelp(): string { ); } -export const MAX_CONTAINER_SIZE = MAX_PLAINTEXT_SIZE + 4096; +/** + * The KEYM v2/v3 format constants the container ceiling is computed from. + * + * Restated rather than imported: this module must not depend on keym-v2.ts at + * evaluation time (the dependency runs one way, see `decryptData`). So each one + * is asserted against the format module's own exports in `keym2-dispatch.mts`, + * the same arrangement that holds `KEYM2_HEADER_PEEK_BYTES` to the slot table. + * + * Each is the largest value any version and cipher this build reads can take: + * + * - the slot table starts at byte 57 in v3 (9 in v2); + * - a chained slot is 48 + 32 + 2 x 16 = 112 bytes (96 for a single cipher); + * - §6 allows at most 8 slots; + * - a chained chunk carries 32 bytes of tag (16 for a single cipher), and the + * chunk size is a format constant of 1 MiB. + */ +const KEYM2_MAX_SLOT_TABLE_OFFSET = 57; +const KEYM2_MAX_SLOT_LEN = 112; +const KEYM2_MAX_SLOT_COUNT = 8; +const KEYM2_MAX_TAG_PER_CHUNK = 32; +const KEYM2_PAYLOAD_CHUNK_SIZE = 1024 * 1024; + +/** + * Largest container we will even attempt to decrypt: the largest container a + * MAX_PLAINTEXT_SIZE plaintext can legally become. + * + * The file picker already refuses oversized files, but pasted base64 reaches + * decryptData() without passing that check. A core crypto API should enforce + * its own resource limits rather than trusting whichever UI calls it. + * + * Computed, because the hand-picked `+ 4096` it replaces was sized for v1 (a + * 71-byte header and 32 bytes of tags) and v2 outgrew it: a v3 chained container + * with 8 slots at the plaintext cap is 57 + 8 x 112 + 100 x 32 = 4153 bytes over + * the plaintext, so a legal backup at the cap was refused as too large. v1's 103 + * bytes of overhead are well inside this; the chunk count is `max(1, ceil(n / + * chunk))`, the same rule the writer uses. + */ +export const MAX_CONTAINER_OVERHEAD = + KEYM2_MAX_SLOT_TABLE_OFFSET + + KEYM2_MAX_SLOT_COUNT * KEYM2_MAX_SLOT_LEN + + Math.max(1, Math.ceil(MAX_PLAINTEXT_SIZE / KEYM2_PAYLOAD_CHUNK_SIZE)) * KEYM2_MAX_TAG_PER_CHUNK; +export const MAX_CONTAINER_SIZE = MAX_PLAINTEXT_SIZE + MAX_CONTAINER_OVERHEAD; const MAX_CIPHERTEXT_SIZE = MAX_CONTAINER_SIZE; /** From d319e66393925c276fbec01b581e8b1310800ffb Mon Sep 17 00:00:00 2001 From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:38:55 +0000 Subject: [PATCH 13/34] fix: UI correctness pass (lock, scans, notices, filenames, a11y) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these was found in review, confirmed by reading or by a test, and now has a test that fails without the fix. - The idle lock fired in the middle of an operation: an unlock can take minutes (the §6 ceiling is 315 s), and the lock cancelled it and wiped the password. An operation in progress now counts as activity. (Test holds the operation open with a passkey prompt that never answers; an earlier derivation-length version raced the CPU.) - A QR scan that finished after a tab switch or Wipe now wrote its result into whatever form was showing. It now checks the op counter. - The Decrypt password field was painted red by the encrypt policy, and the Encrypt border ignored the generated-passphrase exemption the button uses. - "Nothing was pasted" was appended to every container-box notice, including the three that keep the paste (a share, a part, a damaged page). - The scan toast said "type the password" after a scan that was not a complete backup; the text handler now reports whether it accepted input. - Names containing ".." ("Notes... draft.txt") were refused as invalid; a browser File.name never carries a path. - Stop pressed while a large file was still being read did not stop: the disowned operation went on to start a worker and a full derivation. - A text-form backup chosen as a file on Decrypt (a .txt of armor, a saved self-extracting page, paper parts, a shares file) was read as a legacy blob, ran 1M PBKDF2 iterations and blamed the password. - Accessibility: the reveal, copy and QR buttons under the output and every toast's close button had no accessible name; the lock warning and the clipboard countdown were live regions re-announced every second. New axe scans cover the encrypt and decrypt result states, which were never scanned. - The dice tool printed "Infinity" rolls for an invalid die. - The inheritance plan opened on File mode, where its own paper-vault step cannot be followed; step 4 now also says what to do with a sealed file. - A rehearsal result survived into the next seal, so a new backup's dialog and printed sheet claimed a rehearsal that never happened. - The Recovery page said "Confirm in your downloads" for a container kept on screen, and "No result shown" after a successful rehearsal. - Service worker: offline lookups searched every cache on the (shared Pages) origin; they now use this worker's own cache. /verify.html is precached, so offline it no longer serves the home page under its URL. --- .github/workflows/ci.yml | 7 +- public/sw.js | 21 +- scripts/sw-precache-test.mjs | 54 ++++- src/components/dice-entropy-tool.tsx | 22 +- src/components/encryptor-tool.tsx | 243 +++++++++++++++++++-- src/components/inheritance-plan.tsx | 6 +- src/components/ui/toast.tsx | 5 +- tests/browser/a11y.spec.ts | 21 +- tests/browser/dice.spec.ts | 2 + tests/browser/download-filename.spec.ts | 28 +++ tests/browser/failure-reporting.spec.ts | 57 +++++ tests/browser/inheritance.spec.ts | 7 +- tests/browser/paper-vault.spec.ts | 18 ++ tests/browser/qr-decrypt.spec.ts | 32 +++ tests/browser/rehearsal.spec.ts | 38 ++++ tests/browser/shamir-ui.spec.ts | 6 + tests/browser/uat-polish.spec.ts | 16 ++ tests/browser/verify-recovery-lock.spec.ts | 79 +++++++ tests/browser/worker.spec.ts | 56 ++++- 19 files changed, 670 insertions(+), 48 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 016dace..0eed90a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,9 +135,12 @@ jobs: # fetch them past the HTTP cache (cache: 'reload'). Otherwise a deploy # inside Pages' max-age window froze the previous index.html into the new # version's cache: a false tamper report beside the new SHA256SUMS, and a - # blank page offline. Runs public/sw.js itself in a stub worker scope. + # blank page offline. Runs public/sw.js itself in a stub worker scope, + # and also checks offline lookups use the worker's own cache (Pages puts + # every project site on one origin) and that /verify.html opens offline. # Controls bite: plain-string addAll fails the reload check; dropping - # requirements.txt from the shell fails the recovery-kit check. + # requirements.txt from the shell fails the recovery-kit check; going + # back to caches.match fails the three offline lookups. - name: Service worker precaches a fresh shell and the whole recovery kit run: npm run test:sw-precache diff --git a/public/sw.js b/public/sw.js index fb26a14..5563296 100644 --- a/public/sw.js +++ b/public/sw.js @@ -57,6 +57,11 @@ const APP_SHELL = [ // The pinned dependency list the kit dialog offers beside the scripts, and // the file RECOVERY.md's install step reads. `${BASE}/recovery/requirements.txt`, + // The verify page. Reachable from the footer and the command bar, and it + // was not precached, so offline the navigation fallback served the home + // page under /verify.html: a page that says what build you are running + // answering with a different page. + `${BASE}/verify.html`, `${BASE}/logo.svg`, // The hero background plate. Named here rather than left to runtime caching // for the same reason as everything else in this list: isCacheableAsset() @@ -258,7 +263,7 @@ self.addEventListener('fetch', (event) => { // stay exactly what the manifest describes: the bytes install() wrote. event.respondWith( fetch(event.request).catch(() => - caches.match(event.request).then((cached) => cached || caches.match(`${BASE}/`)) + ownMatch(event.request).then((cached) => cached || ownMatch(`${BASE}/`)) ) ); return; @@ -276,7 +281,7 @@ self.addEventListener('fetch', (event) => { // Anything not matched here falls through to the network untouched. if (isCacheableAsset(url.pathname)) { event.respondWith( - caches.match(event.request).then((cached) => { + ownMatch(event.request).then((cached) => { if (cached) return cached; return fetch(event.request).then((response) => cacheResponse(event.request, response) @@ -294,6 +299,18 @@ self.addEventListener('fetch', (event) => { * installed PWA needs to launch offline. Deliberately excluded: anything * dynamic, anything user-supplied, and anything not enumerated here. */ +/** + * Look a request up in this worker's own cache only. + * + * `caches.match()` searches every cache on the origin, and GitHub Pages puts + * every project site on one origin (the same reason CACHE_PREFIX exists): a + * neighbouring app's cache could answer for a URL this one serves, including + * the navigation fallback that stands in for the whole app offline. + */ +function ownMatch(request) { + return caches.open(CACHE_VERSION).then((cache) => cache.match(request)); +} + function isCacheableAsset(pathname) { if (pathname.startsWith(`${BASE}/_next/static/`)) return true; return APP_SHELL.includes(pathname); diff --git a/scripts/sw-precache-test.mjs b/scripts/sw-precache-test.mjs index 9dd7185..4ee3ad4 100644 --- a/scripts/sw-precache-test.mjs +++ b/scripts/sw-precache-test.mjs @@ -13,9 +13,15 @@ * This runs public/sw.js itself in a stub worker scope and records what the * install handler hands to Cache Storage, rather than reading the source. * + * It also dispatches offline fetches through the worker's fetch handler and + * checks they are answered from the worker's own cache. `caches.match()` + * searches every cache on the origin, and on GitHub Pages that includes other + * project sites' caches; the stub answers such a lookup with "FOREIGN". + * * Controls shown to bite: passing APP_SHELL to addAll as plain strings fails * the reload check; dropping requirements.txt from APP_SHELL fails the kit - * check. + * check; dropping verify.html fails the verify checks; going back to + * `caches.match` fails the three offline lookups. */ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -42,6 +48,9 @@ class StubRequest { const added = []; const handlers = {}; +/** What this worker's own cache holds, by URL path, for the fetch checks. */ +const owned = new Map(); +let openedNames = []; const scope = { location: new URL("https://example.test/Keymaker-v2/sw.js"), addEventListener: (type, fn) => { handlers[type] = fn; }, @@ -56,12 +65,21 @@ const context = vm.createContext({ Response: class {}, console, caches: { - open: async () => ({ - addAll: async (items) => { added.push(...items); }, - put: async () => {}, - }), + open: async (name) => { + openedNames.push(name); + return { + addAll: async (items) => { added.push(...items); }, + put: async () => {}, + match: async (req) => { + const path = new URL(typeof req === "string" ? req : req.url, "https://example.test").pathname; + return owned.get(path); + }, + }; + }, keys: async () => [], - match: async () => undefined, + // Cache Storage as a whole, which on a shared origin includes other apps' + // caches. A correct worker never asks it. + match: async () => "FOREIGN", delete: async () => true, }, fetch: async () => { throw new Error("no network in this test"); }, @@ -86,6 +104,30 @@ for (const file of ["RECOVERY.md", "keym2.py", "keym.py", "requirements.txt"]) { ok(urls.includes(`/Keymaker-v2/recovery/${file}`), `the recovery kit's ${file} is precached`); } ok(urls.includes("/Keymaker-v2/_next/static/chunks/app-0123abcd.js"), "the hashed chunks are still precached"); +ok(urls.includes("/Keymaker-v2/verify.html"), "the verify page is precached, so it opens offline"); + +// Offline lookups: a navigation with the network down, and a cacheable asset. +// Both must be answered from this worker's own cache, never Cache Storage as a +// whole, which on GitHub Pages holds every project site's caches. +owned.set("/Keymaker-v2/verify.html", "OWN verify"); +owned.set("/Keymaker-v2/", "OWN shell"); +owned.set("/Keymaker-v2/logo.svg", "OWN logo"); +async function respond(url, mode) { + let responded; + handlers.fetch({ + request: { url, mode, method: "GET" }, + respondWith: (p) => { responded = p; }, + }); + return responded === undefined ? undefined : await responded; +} +openedNames = []; +ok((await respond("https://example.test/Keymaker-v2/verify.html", "navigate")) === "OWN verify", + "offline, /verify.html is served as itself, from this worker's cache"); +ok((await respond("https://example.test/Keymaker-v2/some/deep/link", "navigate")) === "OWN shell", + "offline, an unknown navigation falls back to this worker's own shell"); +ok((await respond("https://example.test/Keymaker-v2/logo.svg", "no-cors")) === "OWN logo", + "a cacheable asset is answered from this worker's own cache"); +ok(openedNames.every((n) => n === "keymaker-test"), "every lookup opened this worker's own cache by name"); if (failed) { console.error(`\n${failed} check(s) failed`); diff --git a/src/components/dice-entropy-tool.tsx b/src/components/dice-entropy-tool.tsx index c504e65..9d4ccf3 100644 --- a/src/components/dice-entropy-tool.tsx +++ b/src/components/dice-entropy-tool.tsx @@ -131,9 +131,12 @@ export function DiceEntropyTool() { } const totalBits = rolls * bitsPerRoll; - const rollsFor128 = Math.ceil(FLOOR_BITS / bitsPerRoll); - const rollsFor256 = Math.ceil(TARGET_BITS / bitsPerRoll); - const rollsNeeded = Math.ceil(targetBits / bitsPerRoll); + // Null, not Infinity, when the die is not a die: the counts divide by + // bits per roll, which is 0 for an invalid size, and the page printed + // "Infinity /128" and "Keep rolling, Infinity more 0-sided rolls". + const rollsFor128 = sidesValid ? Math.ceil(FLOOR_BITS / bitsPerRoll) : null; + const rollsFor256 = sidesValid ? Math.ceil(TARGET_BITS / bitsPerRoll) : null; + const rollsNeeded = sidesValid ? Math.ceil(targetBits / bitsPerRoll) : null; const progress = Math.min(1, totalBits / targetBits); const verdict: Verdict = @@ -160,7 +163,7 @@ export function DiceEntropyTool() { * U21. "1 more rolls" — the last roll before a target is the one most likely * to be read, and it was the one sentence that read as unfinished. */ - const remaining = calc.rollsNeeded - calc.rolls; + const remaining = calc.rollsNeeded === null ? null : calc.rollsNeeded - calc.rolls; const rollWord = remaining === 1 ? "roll" : "rolls"; const verdictUI = { @@ -168,7 +171,10 @@ export function DiceEntropyTool() { icon: ShieldAlert, classes: "border-destructive/40 bg-destructive/10 text-destructive", title: "Below the 128-bit floor", - body: `Keep rolling — ${remaining} more ${calc.validSides}-sided ${rollWord} to reach your ${targetBits}-bit target.`, + body: + remaining === null + ? "Enter how many sides your die has to see how many rolls you need." + : `Keep rolling — ${remaining} more ${calc.validSides}-sided ${rollWord} to reach your ${targetBits}-bit target.`, }, floor: { icon: CheckCircle2, @@ -385,9 +391,9 @@ export function DiceEntropyTool() {

Rolls needed

- {calc.rollsFor128} /128 + {calc.rollsFor128 ?? "n/a"} /128 {" · "} - {calc.rollsFor256} /256 + {calc.rollsFor256 ?? "n/a"} /256

@@ -431,7 +437,7 @@ export function DiceEntropyTool() { />
- = FLOOR_BITS && "text-warning")}>128-bit floor @ {calc.rollsFor128} rolls + = FLOOR_BITS && "text-warning")}>128-bit floor @ {calc.rollsFor128 ?? "n/a"} rolls {calc.progress >= 1 ? "100%" : `${Math.floor(calc.progress * 100)}%`}
diff --git a/src/components/encryptor-tool.tsx b/src/components/encryptor-tool.tsx index 1e60c3a..3bc7f23 100644 --- a/src/components/encryptor-tool.tsx +++ b/src/components/encryptor-tool.tsx @@ -147,11 +147,22 @@ function LockWarning({ secondsLeft: number; onKeepOpen: () => void; }) { + // The visible count ticks every second; the announcement does not. A live + // region around the whole banner re-read "Locking in 29s", "28s", ... thirty + // times over, which buries the one thing a screen-reader user needs from + // it: that there is a Keep open button. So the live text changes twice, + // when the warning appears and at ten seconds. + const announcement = + secondsLeft > 10 + ? "Nothing has been touched for a while. Secrets will be cleared in 30 seconds unless you choose Keep open." + : "Secrets will be cleared in 10 seconds unless you choose Keep open."; return (
+ + {announcement} + @@ -249,6 +260,14 @@ function parseShareLines(text: string): string[] { * 8 KiB is several times the text they occupy. Past that it is a paste into the * wrong box, which is what §7 is for. */ +/** + * Said only when a paste was refused. The container box also shows notices + * for pastes it keeps (a share, a paper part, a damaged page), and appending + * this to every notice told someone looking at their own paste that nothing + * had been pasted. + */ +const NOTHING_PASTED = "Nothing was pasted, so what you already had is still here."; + const MAX_SHARE_INPUT_CHARS = 8 * 1024; const MAX_SHARE_LINES = 16; const MAX_SHARE_LINE_CHARS = 200; @@ -456,7 +475,11 @@ function exportCanvasPng(canvas: HTMLCanvasElement, filename: string) { } const validateAndSanitizeFile = (file: File) => { - if (file.name.includes('..') || + // A browser's File.name is a leaf name and never carries a path, so `..` + // inside it is only punctuation: "Notes... draft.txt" was refused as an + // invalid filename. What would still mean a directory is a name that is + // nothing but dots, and the separators below. + if (/^\.+$/.test(file.name) || file.name.includes('/') || file.name.includes('\\') || file.name.length > 255) { @@ -788,6 +811,85 @@ function qrByteLength(text: string): number { const KEYM_V1_TEXT_PREFIX = "KEYM1:"; const KEYM_V2_TEXT_PREFIX = "keym2:"; +/** + * A file chosen on Decrypt that holds a backup as *text*: `keym2:` or + * `KEYM1:` armor saved to a .txt, a self-extracting page, or a set of paper + * parts. Returns the container bytes, or null when the file is not one of + * these (a binary container, a legacy blob, or anything else), in which case + * it goes to the container reader as it is. + * + * File mode used to hand every file to the reader as raw bytes. None of these + * starts with the binary magic, so each fell through to the headerless legacy + * path, ran a million PBKDF2 iterations, and was reported as a wrong password: + * the heir holding a saved .txt or the page itself was told to retype a + * password that was right. The text box already reads all of these; this is + * the same set of readers, reached from the other input. + */ +async function containerFromTextFile(bytes: Uint8Array): Promise { + const ascii = (at: number, n: number) => String.fromCharCode(...bytes.subarray(at, at + n)); + // Binary containers: KEYM magic with a version byte (not the "1:" of v1 + // armor, which shares the first four bytes), and legacy IBTZ. + if (ascii(0, 4) === "IBTZ") return null; + if (ascii(0, 4) === "KEYM" && ascii(4, 2) !== "1:") return null; + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return null; + } + text = text.replace(/^\uFEFF/, "").trim(); + const unreadable = (what: string) => + new KeymakerError( + "invalid-input", + `That file holds ${what}, but it is not intact, so it cannot be opened. ` + + "Nothing was tried against your password. Recover from another copy." + ); + if (text.startsWith(KEYM_V2_TEXT_PREFIX)) { + const { dearmorKeym2 } = await import("@/lib/keym-v2"); + try { + return dearmorKeym2(text); + } catch { + throw unreadable("keym2: text"); + } + } + if (text.toUpperCase().startsWith(KEYM_V1_TEXT_PREFIX)) { + try { + return base64ToUint8Array(text.slice(KEYM_V1_TEXT_PREFIX.length).replace(/\s+/g, "")); + } catch { + throw unreadable("KEYM1: text"); + } + } + if (looksLikeSelfExtract(text)) { + try { + return extractSelfExtract(text); + } catch (e) { + throw new KeymakerError("invalid-input", (e as Error).message); + } + } + if (looksLikePaperPart(text)) { + try { + return await decodePaperPartsAny(splitPaperParts(text)); + } catch (e) { + throw new KeymakerError("invalid-input", (e as Error).message); + } + } + // Shares files usually open with a comment ("# strips 1 and 2"), so the + // test is on the first line that is not one. + const firstLine = text + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line !== "" && !line.startsWith("#")); + if (firstLine !== undefined && isShareText(firstLine)) { + throw new KeymakerError( + "invalid-input", + "That file holds recovery shares, not an encrypted backup. Choose the backup " + + 'here, then choose "Use recovery shares" beside the password field and put ' + + "the shares there." + ); + } + return null; +} + /** * How long a copied secret is allowed to sit in the clipboard. * @@ -1130,6 +1232,11 @@ export function EncryptorTool() { const [useKeyFile, setUseKeyFile] = useState(false); const [keyFile, setKeyFile] = useState(null); const [isLoading, setIsLoading] = useState(false); + /** Read by the idle-lock interval, which is not re-created on every change. */ + const isLoadingRef = useRef(false); + useEffect(() => { + isLoadingRef.current = isLoading; + }, [isLoading]); /** Set before the derivation starts when this container will be slow. */ const [unlockCostNotice, setUnlockCostNotice] = useState(null); const [isCryptoAvailable, setIsCryptoAvailable] = useState(true); @@ -1691,16 +1798,17 @@ export function EncryptorTool() { * is on UTF-8 bytes, and a field measured in UTF-16 code units would disagree * with it for any non-ASCII secret. */ - const handleTextSecretChange = useCallback(async (next: string) => { + const handleTextSecretChange = useCallback(async (next: string): Promise => { const decrypting = mode === 'decrypt'; if (decrypting) { if (next.length > MAX_TEXT_ARMOR_CHARS) { setTextInputRejected( `That is ${Math.round(next.length / 1024).toLocaleString()} KB of text. ` + `Encrypted text is accepted up to ${MAX_TEXT_ARMOR_CHARS / 1024} KB — ` + - `for anything larger, decrypt the .keym file itself in File mode.` + `for anything larger, decrypt the .keym file itself in File mode. ` + + NOTHING_PASTED ); - return; + return false; } } else { const bytes = new Blob([next]).size; @@ -1708,9 +1816,10 @@ export function EncryptorTool() { setTextInputRejected( `That is ${Math.round(bytes / 1024).toLocaleString()} KB. ` + `Text mode is for secrets up to ${MAX_TEXT_PLAINTEXT_BYTES / 1024} KB — ` + - `switch to File mode to encrypt something this size.` + `switch to File mode to encrypt something this size. ` + + NOTHING_PASTED ); - return; + return false; } } // §7 as amended by §4.6 — the wrong-box paste, with a real second encoding @@ -1729,7 +1838,7 @@ export function EncryptorTool() { 'then choose "Use recovery shares" beside the password field to enter it.' ); setTextSecret(next); - return; + return false; } // §7.2. A self-extracting page pasted here is the wrong-box paste that @@ -1752,6 +1861,7 @@ export function EncryptorTool() { "That was a self-extracting Keymaker page. The container has been taken " + "out of it — type the password to open it.", }); + return true; } catch (e) { // Structural, never routed through the AEAD: a page problem reported as // a decryption failure sends someone to retype a password that was @@ -1759,7 +1869,7 @@ export function EncryptorTool() { setTextInputRejected((e as Error).message); setTextSecret(next); } - return; + return false; } // §7.1/§7.3. Paper parts, and the same rule §7.2 sets for a self-extracting @@ -1790,6 +1900,7 @@ export function EncryptorTool() { title: `Paper backup reassembled from ${lines.length} ${lines.length === 1 ? "part" : "parts"}`, description: "Type the password to open it.", }); + return true; } catch (e) { // Every one of these names what is actually wrong — which part is // missing, which was scanned twice, which does not belong to this set. @@ -1804,11 +1915,12 @@ export function EncryptorTool() { ); setTextSecret(next); } - return; + return false; } setTextInputRejected(null); setTextSecret(next); + return true; }, [mode, toast]); const handlePasswordChange = useCallback((pwd: string) => { @@ -2063,6 +2175,16 @@ export function EncryptorTool() { } const id = setInterval(() => { + // An operation in progress is the user waiting on this tab, not a tab + // left alone. A container may ask for minutes of derivation (the §6 + // ceiling measured at 315 s), and the lock used to fire in the middle: + // it cancelled the unlock the user was sitting through and wiped the + // password they had just typed. The idle clock starts when it finishes. + if (isLoadingRef.current) { + lastActivityRef.current = Date.now(); + setLockSecondsLeft(null); + return; + } const left = Math.ceil((AUTO_LOCK_MS - (Date.now() - lastActivityRef.current)) / 1000); if (left <= 0) { // Re-arm before wiping. When issued shares are spared the secrets stay @@ -2173,8 +2295,15 @@ export function EncryptorTool() { const handleQrImageFiles = useCallback(async (files: readonly File[]) => { if (files.length === 0) return; setQrScanBusy(true); + // The same staleness rule every other async path here follows. Decoding a + // large photo takes a moment, and a tab switch or Wipe now in that moment + // moves opSeqRef: the result then belongs to a form that no longer exists, + // and writing it would put a container into the Encrypt field, or undo + // the wipe the user just asked for. + const seq = opSeqRef.current; try { const texts = await decodeQrImages(files); + if (opSeqRef.current !== seq) return; // A printed backup opened without the password is container parts *and* // share strips, and the person opening it photographs all of it. Each // string goes to the box that can use it: shares to the shares box, @@ -2201,10 +2330,19 @@ export function EncryptorTool() { setShareInputRejected(null); setShareInput(merged.text); } - if (rest.length > 0) handleTextSecretChange(rest.join("\n")); + // Whether the container box took what was scanned. A lone paper part, + // or a set with a page missing, is kept in the box with a note naming + // the problem, and the toast must not then say to type the password. + const accepted = rest.length > 0 ? await handleTextSecretChange(rest.join("\n")) : true; const title = files.length === 1 ? "QR image scanned" : `${files.length} QR images scanned`; - if (!merged) { + if (!accepted) { + toast({ + title, + description: "It is not a complete backup yet. The note under the box says what is missing.", + variant: "destructive", + }); + } else if (!merged) { toast({ title, description: @@ -2228,6 +2366,7 @@ export function EncryptorTool() { // the same discipline the paper-part and self-extract branches keep, for // the same reason: a bad scan reported as a decryption failure sends // someone to retype a password that was never wrong. + if (opSeqRef.current !== seq) return; const description = e instanceof QrDecodeError ? e.message @@ -2313,10 +2452,15 @@ export function EncryptorTool() { const openInheritance = useCallback(() => { setWorkspacePage("workbench"); if (mode !== "encrypt") handleModeChange("encrypt"); + // Text, because step 4 is the paper vault, and the paper vault prints a + // container that is on screen: a sealed *file* is downloaded instead, so + // the plan used to open on the one input where its own step 4 could not be + // followed. Only switched when needed, since the switch clears a result. + if (mode !== "encrypt" || inputType !== "text") handleInputTypeChange("text"); setShamirEnabled(true); setIsAdvancedOpen(true); setInheritanceOpen(true); - }, [mode, handleModeChange]); + }, [mode, inputType, handleModeChange, handleInputTypeChange]); /** * @param maxBytes Ceiling for this particular picker. Encrypting caps the @@ -2738,6 +2882,13 @@ export function EncryptorTool() { const encoder = new TextEncoder(); const inputBuffer = inputType === 'file' ? await file!.arrayBuffer() : (encoder.encode(textSecret).buffer as ArrayBuffer); + // Stop, a tab switch or the lock can land while a large file is still + // being read. Every await from here to the worker is a point where the + // operation may already be disowned, and going on would start a fresh + // worker and a full derivation whose result is then thrown away (and, + // after a Stop had terminated the worker, announce that the browser + // could not start one). + if (isStale()) return; // §4.7. The authenticator has to be asked *here*, on the main thread, // before any work is handed over: a Worker cannot reach // navigator.credentials. The slot salt is chosen first because the PRF @@ -2752,6 +2903,7 @@ export function EncryptorTool() { passkey = { prfOutput, salt: slotSalt }; } + if (isStale()) return; // §4.6. Requested in the same call that writes the container, so the // share secret is generated and dropped inside the worker and the // password is not held past the operation that already needed it. @@ -2772,6 +2924,15 @@ export function EncryptorTool() { new Uint8Array(resultBuffer.slice(0, Math.min(KEYM2_HEADER_PEEK_BYTES, resultBuffer.byteLength))) ); } + if (!isStale()) { + // A rehearsal describes the container it was run against. Only a + // wipe used to clear it, so after rehearsing one backup and sealing + // another, the new dialog showed the old result and the new paper + // vault was stamped as rehearsed when it never had been. + setRehearsal({ kind: "idle" }); + setRehearsalOpen(false); + setRehearsalInput(""); + } if (encrypted.shares && !isStale()) { // Straight to the modal. These exist exactly once — nothing can // reissue them — so they must not be left to be noticed. @@ -2842,6 +3003,10 @@ export function EncryptorTool() { let inputBuffer: ArrayBuffer; if (inputType === 'file') { inputBuffer = await file!.arrayBuffer(); + const fromText = await containerFromTextFile(new Uint8Array(inputBuffer)); + // Copied rather than `.buffer`: a reader may hand back a view into + // a larger buffer, and the container must be exactly its bytes. + if (fromText) inputBuffer = new Uint8Array(fromText).buffer as ArrayBuffer; } else { let blobText = textSecret.trim(); @@ -2944,6 +3109,10 @@ export function EncryptorTool() { // a failure the user already understands. Containers with more than one // enrolled passkey are the case this does not serve; enrol the second // key on its own copy until that changes. + // As on the encrypt side: the file read and the header peek above are + // awaits, and a disowned operation must not ask for a passkey tap or + // start a derivation. + if (isStale()) return; let prfOutput: Uint8Array | undefined; if (usePasskey) { const { passkeySlotSaltsKeym2, derivePrfSalt } = await import("@/lib/keym-v2"); @@ -2958,6 +3127,7 @@ export function EncryptorTool() { prfOutput = await assertPasskeyPrf(await derivePrfSalt(salts[0]!)); } + if (isStale()) return; const decryptResult = await decryptViaWorker( inputBuffer, mutablePassword, @@ -3203,11 +3373,16 @@ export function EncryptorTool() { } }, []); + // Encrypt only, and from the same verdict the button uses. On Decrypt the + // policy does not apply: the right password is whatever the container was + // sealed with, so a valid legacy password was painted red as if it were + // wrong. And calling meetsPasswordPolicy without the generated flag + // disagreed with the button about a generated passphrase. const getPasswordStrengthColor = useCallback(() => { - if (!password) return "border-input"; - if (meetsPasswordPolicy(password)) return "border-success"; + if (!password || mode !== "encrypt") return "border-input"; + if (passwordMeetsPolicy) return "border-success"; return "border-destructive"; - }, [password]); + }, [password, mode, passwordMeetsPolicy]); /** * U15. The button is disabled by policy and says nothing about why. @@ -3517,8 +3692,7 @@ export function EncryptorTool() { role="alert" className="animate-in fade-in-50 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] leading-snug text-destructive" > - {textInputRejected} Nothing was pasted, so what you already had is - still here. + {textInputRejected}

)} {/* @@ -4494,17 +4668,32 @@ export function EncryptorTool() { />
{currentMode === 'decrypt' && inputType === 'text' && ( - )} - {currentMode === 'encrypt' && inputType === 'text' && ( - @@ -4655,9 +4844,15 @@ export function EncryptorTool() { */} {(clipboardSecondsLeft !== null || clipboardClearPending) && (
+ {/* Announced once, not once a second for a minute: the ticking + count below is for sighted users and is not a live region. */} + + {clipboardClearPending + ? "Clipboard not cleared yet, because the tab was in the background. It will be cleared when you come back to this tab." + : "The clipboard will be cleared in about a minute. Choose Clear now to clear it sooner."} + {clipboardClearPending ? ( // The countdown ran out while the tab was in the background and @@ -5262,8 +5457,8 @@ export function EncryptorTool() {

{receipt.cipher} · {receipt.kdf}

Recovery shares
{receipt.shares ? `${receipt.shares.threshold} of ${receipt.shares.count} needed` : "Not included"}
-
Saved copy
Confirm in your downloads
-
Recovery test
No result shown for this backup
+
Saved copy
{receipt.onScreen ? "Not saved yet. It is on screen: download it or print the paper backup" : "Downloaded when encryption finished. Check your downloads folder"}
+
Recovery test
{rehearsal.kind === "ok" ? `Rehearsed on ${rehearsal.on}${rehearsal.strips.length > 0 ? ` with strips ${rehearsal.strips.join(" and ")}` : ""}` : "Not tested yet in this session"}
{receipt.onScreen ? (
diff --git a/src/components/inheritance-plan.tsx b/src/components/inheritance-plan.tsx index 870064c..004a9e7 100644 --- a/src/components/inheritance-plan.tsx +++ b/src/components/inheritance-plan.tsx @@ -78,8 +78,10 @@ export function InheritancePlan({ and cannot be reissued.
  • - Print the paper vault for the container, and put each share on its own - sheet. + Print the paper vault: the container as QR symbols, and each share on + its own strip to cut apart. The vault prints what was sealed as text; + if you sealed a file instead, keep the downloaded .keym{" "} + with the shares.
  • Keep the recovery kit, keym2.py and{" "} diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index 7153de2..8470f6f 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -66,9 +66,12 @@ const ToastClose = React.forwardRef< className )} toast-close="" + // Icon-only, so it needs a name: without one a screen reader announced an + // anonymous "button" on every notification. + aria-label="Dismiss notification" {...props} > - +
  • A phone that scans QR codes, and a computer with Python 3. The @@ -369,13 +379,24 @@ export function PaperVault({ {hasStrips ? (

    Recovery strips — cut apart, one per envelope

    -

    - Any {k} of these {n} open the backup on the owner’s - sheet without the password, so each strip is as sensitive as - the password itself. Cut along the lines, write each holder’s - name on their strip, and give them to people who would not casually - combine them. Keep this page no longer than it takes to cut it up. -

    + {stripBackupPart ? ( +

    + Any {k} of these {n} open the backup on their own: + each strip carries the backup itself as well as a share, so {k} holders + together need no password, no sheet and no file. Each strip is as + sensitive as the password. Cut along the lines, write each holder’s + name on their strip, and give them to people who would not casually + combine them. Keep this page no longer than it takes to cut it up. +

    + ) : ( +

    + Any {k} of these {n} open the backup on the owner’s + sheet without the password, so each strip is as sensitive as + the password itself. Cut along the lines, write each holder’s + name on their strip, and give them to people who would not casually + combine them. Keep this page no longer than it takes to cut it up. +

    + )} {shares!.map((share, i) => (

    ✂ cut here

    @@ -392,15 +413,31 @@ export function PaperVault({
    + {stripBackupPart ? ( +
    + +
    the backup
    +
    + ) : null} {share}
    -

    - One of {n} strips for a Keymaker backup. Any {k} of them open it - without the password; alone, this one reveals nothing. Keep it - sealed. When the backup has to be opened, bring it, or read the - code above to the person opening it —{" "} - keym2.py decrypt --share takes it. -

    + {stripBackupPart ? ( +

    + One of {n} strips for a Keymaker backup. This strip carries the backup + itself as well as a share: any {k} strips open it without the password + and without anything else. Alone, this one reveals nothing. Keep it + sealed. When the backup has to be opened, bring it, and scan both + codes. +

    + ) : ( +

    + One of {n} strips for a Keymaker backup. Any {k} of them open it + without the password; alone, this one reveals nothing. Keep it + sealed. When the backup has to be opened, bring it, or read the + code above to the person opening it —{" "} + keym2.py decrypt --share takes it. +

    + )}
  • ))} diff --git a/tests/browser/shamir-ui.spec.ts b/tests/browser/shamir-ui.spec.ts index 76dc3b8..b5b6d4b 100644 --- a/tests/browser/shamir-ui.spec.ts +++ b/tests/browser/shamir-ui.spec.ts @@ -414,6 +414,68 @@ test.describe("scanning the printed strips", () => { await expect(visible(page.locator("#output-text"))).toHaveValue(SECRET, { timeout: 90_000 }); }); + test("strips that carry the backup open it with nothing else", async ({ page }) => { + await page.goto("/"); + await useTextMode(page); + await selectCrypto(page, "pbkdf2", "aes"); + await enableShares(page, 2, 3); + await visible(page.getByPlaceholder("Enter text to encrypt")).fill(SECRET); + await visible(page.getByPlaceholder("Enter a strong password")).fill(PASSWORD); + await visible(page.getByRole("button", { name: /^Encrypt Text$/i })).click(); + await expect(page.getByText(/Save these 3 shares now/)).toBeVisible({ timeout: 90_000 }); + const shares = (await page.locator("p.font-mono").allTextContents()).filter((s) => + s.startsWith("KMSHARE2:") + ); + + // Off until chosen, and the cost is on screen beside it. + const carry = visible(page.getByRole("switch", { name: "Put the whole backup on every strip" })); + await expect(carry).toHaveAttribute("aria-checked", "false"); + await expect(page.getByTestId("strips-carry-backup")).toContainText( + "2 holders who get together need nothing else" + ); + await carry.click(); + await expect(carry).toHaveAttribute("aria-checked", "true"); + + const printed = await capturePrintedSymbols( + page, + page.getByRole("dialog").getByRole("button", { name: /Print paper vault/i }) + ); + // Two codes a strip now: its share, and the backup. + expect(printed.strips, "each strip should carry two symbols").toHaveLength(6); + + // A fresh page: no container, nothing typed, nothing from the owner. + // Strips 1 and 3, both codes of each, are all there is. + await page.reload(); + await visible(page.getByRole("tab", { name: "Decrypt" })).click(); + await useTextMode(page); + await page.locator("#qr-scan-input").setInputFiles([ + png("strip-1-share.png", printed.strips[0] as Buffer), + png("strip-1-backup.png", printed.strips[1] as Buffer), + png("strip-3-share.png", printed.strips[4] as Buffer), + png("strip-3-backup.png", printed.strips[5] as Buffer), + ]); + await expect(visible(page.locator("#text-secret"))).not.toHaveValue("", { timeout: 30_000 }); + await expect(page.locator("#share-input")).toBeVisible({ timeout: 20_000 }); + expect((await shareBoxLines(page)).sort()).toEqual([shares[0], shares[2]].sort()); + + await visible(page.getByRole("button", { name: /^Decrypt Text$/i })).click(); + await expect(visible(page.locator("#output-text"))).toHaveValue(SECRET, { timeout: 90_000 }); + }); + + test("a backup too big for one symbol is not offered on the strips", async ({ page }) => { + await page.goto("/"); + await useTextMode(page); + await selectCrypto(page, "pbkdf2", "aes"); + await enableShares(page, 2, 3); + await visible(page.getByPlaceholder("Enter text to encrypt")).fill("x".repeat(4000)); + await visible(page.getByPlaceholder("Enter a strong password")).fill(PASSWORD); + await visible(page.getByRole("button", { name: /^Encrypt Text$/i })).click(); + await expect(page.getByText(/Save these 3 shares now/)).toBeVisible({ timeout: 90_000 }); + // Given a moment to decide, since the size check is asynchronous. + await page.waitForTimeout(1_000); + await expect(page.getByTestId("strips-carry-backup")).toHaveCount(0); + }); + test("a strip scanned twice is entered once", async ({ page }) => { const backup = await encryptAndPrintWithShares(page, 2, 3); From 1e4516aa73dd3ee9a19ead0b4e93bd277ec295f3 Mon Sep 17 00:00:00 2001 From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:42:00 +0000 Subject: [PATCH 24/34] =?UTF-8?q?feat(format):=20=C2=A74.8=20password-and-?= =?UTF-8?q?shares=20slot,=20spec=20to=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new slot type, 0x03: the passphrase AND k of n shares, both needed. Additive, as §9 now says a frozen format admits: the record's shape is unchanged, and §4.4 has every older reader skip the type. Spec (FORMAT-V2-DESIGN §4.8): passphrase_key is the slot's own KDF over §4.1's input; slot_key = HKDF(LP("keymaker.v2.passphrase-and-shares") || LP(passphrase_key) || LP(share_secret), slot_salt, "keymaker.v2.slot-key"). The memory-hard cost is paid once, on the guessable half. Shares are §4.6's with this slot's set id; a reader compares it before the KDF. A reader MAY say strips need the password (the set id is public). A 0x03 slot may stand alone; the writer states the costs. A vector computed with plain hashlib/hmac. keym2.py, from the spec: the derivation, the walk, build_both_slot, encrypt_both, add_both_slot, shares_need_password, inspect, and the CLI asking for the password once the strips show they need it. Self-test: the vector, domain separation from 0x00, each half refused, wrong-set strips declined before stretching, a pre-§4.8 reader's view. TypeScript: the same, one shared single-slot writer for the passphrase and 0x03 slots, the worker and its fallback, the inspectors. Parity: crosstest2 compares the container bytes and share strings of both writers across v2/v3, three ciphers and two KDFs, cross-opens them, and has each refuse either half alone. Three frozen fixtures (v3-both-*); counts updated in crosstest2.py and keymaker-regression.mts. UI: "The strips need the password too" under Recovery shares, with its cost stated, and the passkey switch disabled. The shares dialog, the rehearsal (which now takes the password), the paper vault and the receipt say what the strips do. The Decrypt tab says the password is needed too before any work. RECOVERY.md explains it; recovery_test runs the page's shares command on such a backup. Negative controls: a one-character domain string in the TypeScript (25 parity failures); skipping the set-id check before stretching and dropping the share secret from the derivation (self-test); the switch not honoured (browser); the CLI never asking for the password (recovery_test). --- CHANGELOG.md | 19 +- docs/FORMAT-V2-DESIGN.md | 108 +++++- docs/RECOVERY.md | 5 + reference/bridge.mts | 41 ++ reference/crosstest2.py | 89 ++++- reference/keym2.py | 364 +++++++++++++++++- reference/recovery_test.py | 44 +++ scripts/fixtures/keymaker/fixtures.json | 62 ++- .../fixtures/keymaker/v3-both-aes256gcm.keym | Bin 0 -> 233 bytes .../keymaker/v3-both-chacha20poly1305.keym | Bin 0 -> 239 bytes .../fixtures/keymaker/v3-both-chained.keym | Bin 0 -> 261 bytes scripts/keymaker-generate-fixtures.mts | 44 ++- scripts/keymaker-regression.mts | 42 +- src/components/container-inspector.tsx | 11 + src/components/encryptor-tool.tsx | 200 ++++++++-- src/components/paper-vault.tsx | 75 +++- src/lib/crypto-client.ts | 22 +- src/lib/crypto-worker.ts | 27 +- src/lib/keym-v2.ts | 259 +++++++++++-- src/lib/keymaker-crypto.ts | 51 +++ tests/browser/shamir-ui.spec.ts | 85 ++++ 21 files changed, 1438 insertions(+), 110 deletions(-) create mode 100644 scripts/fixtures/keymaker/v3-both-aes256gcm.keym create mode 100644 scripts/fixtures/keymaker/v3-both-chacha20poly1305.keym create mode 100644 scripts/fixtures/keymaker/v3-both-chained.keym diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d3f9c1..2c18bd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,10 @@ ## Unreleased -No container format change. Everything here reads and writes the bytes v2.2.0 -did; the fixture corpus is unchanged, and both parity gates pass. +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 +v2.2.0 wrote reads exactly as before, the existing fixture corpus is unchanged +(three vectors are added), and both parity gates pass. ### Added - **Recovery strips scan back in.** The paper vault has always printed a QR on @@ -15,6 +17,19 @@ did; the fixture corpus is unchanged, and both parity gates pass. - **RECOVERY.md explains recovering with shares**, with the `keym2.py --shares-from` command, and `recovery_test.py` runs it against a share set issued by the shipping enrolment. +- **Strips that need the password too.** A new slot type, `0x03` (FORMAT-V2-DESIGN + §4.8), takes the password *and* k strips together: the executor holds one, + the family the other, and neither opens the backup alone. The switch sits + under Recovery shares, off by default, and states the cost where the choice + is made: a forgotten password or too few strips loses the backup, and older + readers cannot open it. No passkey can be added beside it. The shares + dialog, its rehearsal, the paper vault and the receipt all say which kind + of strip it is; the Decrypt tab tells someone with strips and no password + that the password is needed too, instead of "decryption failed", and so + does `keym2.py`, which then asks for it. Specified first, implemented in + `keym2.py` from the spec, then TypeScript; byte-identical across both + versions, three ciphers and both KDFs; three frozen fixtures; RECOVERY.md + explains it and `recovery_test.py` runs it. - **Strips that carry the backup, for a small one.** When a backup fits one printed symbol, the shares dialog offers *Put the whole backup on every strip*. Each strip then prints the backup beside its share, so any k strips diff --git a/docs/FORMAT-V2-DESIGN.md b/docs/FORMAT-V2-DESIGN.md index de93f8c..af6a939 100644 --- a/docs/FORMAT-V2-DESIGN.md +++ b/docs/FORMAT-V2-DESIGN.md @@ -502,7 +502,8 @@ a slot is 96 or 112 bytes. | 0x00 | Passphrase, optionally with a key file (§4.1) | Implemented | | 0x01 | Passkey / WebAuthn PRF (§4.7) | Implemented | | 0x02 | Shamir share set (§4.6) | Implemented | -| 0x03–0xFF | Unassigned | Reserved | +| 0x03 | Passphrase **and** a Shamir share set (§4.8) | Implemented | +| 0x04–0xFF | Unassigned | Reserved | The wire layout for 0x01 is deliberately **not** written here. This project's rule is that a specification is tested by an implementation written from it, and @@ -1197,6 +1198,101 @@ origin. Convenience and phishing resistance. Saying "hardware-grade security" over a file that a 12-character password also opens would be the KM-02 overstatement in a new place. +### 4.8 Slot secret for a passphrase-and-shares slot (`slot_type = 0x03`) + +Every other slot is one secret, and any one slot opens the container. This slot +is two: it opens only for someone holding **both** the passphrase **and** enough +shares. It exists for the case the other slots cannot express, an estate where +the executor knows the password and the family holds the strips, and neither +should be able to open the backup alone. + +``` +passphrase_key = KDF(kdf_input, slot_salt, slot_params) 32 bytes + -- §4.1's kdf_input, this slot's slot_kdf_id and parameters + +share_secret = §4.6's, reconstructed from k shares whose set id is + share_set_id_v2(slot_salt) 32 bytes + +both_input = LP("keymaker.v2.passphrase-and-shares") + || LP(passphrase_key) + || LP(share_secret) + +slot_key = HKDF-SHA-256(both_input, slot_salt, "keymaker.v2.slot-key", 32) +``` + +`LP` is §4.1's, and the domain string differs from §4.1's, §4.6's and §4.7's, so +this slot's key can never equal a key another slot type would derive from the +same bytes. + +**The shape of the record does not change**, as it did not for 0x01 and 0x02. +Same 48-byte prefix, same `wrapped_key`, bytes 3..7 reserved and zero. The +parameter block and `slot_kdf_id` are the passphrase's, bounded by §6 exactly as +for 0x00, and `slot_flags` bit 0 means what it means for 0x00: a key file is part +of `kdf_input`. `slot_kdf_id` is 0x00 or 0x01, **never** 0x02 (§6's pairing +table): the guessable half of this secret is a password, and a password under +HKDF alone is a password with no stretching. + +**The shares are §4.6's, unchanged.** A writer splits `share_secret` exactly as +§4.6 does and writes `KMSHARE2` records whose set id is +`share_set_id_v2(slot_salt)`, so a strip for this slot looks like any other strip +and carries a set code (§4.6) like any other. + +**Why two stages, and why in this order.** The memory-hard cost belongs on the +guessable input, so the passphrase goes through the slot's own KDF first, as it +would in a 0x00 slot. The share secret is 32 CSPRNG bytes and needs no +stretching, so the two are combined by HKDF, which is §4.6's construction for an +unguessable input. Feeding the share secret into Argon2id instead would pay a +memory-hard cost to defend a value that cannot be guessed; XOR-ing the two would +need its own argument for domain separation, where length-prefixed concatenation +under a domain string is the argument every other slot already uses. + +**Reading.** A reader attempts a 0x03 slot only when it holds a passphrase *and* +shares, and only after the shares' set id matches this slot's +`share_set_id_v2(slot_salt)`, compared in full (§6), **before** the KDF runs. A +set for a different slot is then declined without paying for an Argon2id +derivation on its behalf. Every other outcome is §4.4's: a wrong passphrase, too +few shares or a mistyped strip disqualifies this slot and the walk continues. + +A reader holding shares whose set id matches a 0x03 slot, but no passphrase, +**MAY** say that these shares open this backup together with the password. That +is not an oracle: the set id is derived from `slot_salt`, which is in the clear, +so the statement tells the holder nothing anyone holding the container could not +compute. It is the difference between "decryption failed", which sends an heir +to retype strips that were never wrong, and the one sentence they need. + +**Writing.** A container **MAY** have a 0x03 slot as its only slot. That is the +point of the type: a plain 0x00 slot beside it would open the container with the +passphrase alone and make the shares decoration. The cost has to be stated where +the choice is made, not discovered later: + +- **Both halves are needed, forever.** A forgotten passphrase or fewer than `k` + strips loses the backup, and no other secret can stand in for either. +- **A reader that predates this section cannot open it.** §4.4 has it skip the + unknown type, which is correct and means a container whose only slot is 0x03 + opens only in readers that implement this section. + +A writer **SHOULD** say both before writing such a container. §4.7's "never +travels alone" rule does not apply: that rule exists because a passkey is +hardware that cannot be backed up, and both halves of this slot are archival, +one in a head or a password manager and the other on paper. + +v3's `slot_table_mac` covers this slot as it covers any other (FORMAT-V3-DESIGN +§5), so removing a 0x03 slot from a v3 container is reported like any other +change to the table. + +Vector. PBKDF2 with one iteration, which §6's reader bounds admit and its +writer policy does not, so that a third implementation can check the +construction without waiting for a real derivation: + +| | | +|---|---| +| password | `correct horse` (no key file) | +| `slot_salt` | `000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f` | +| `slot_kdf_id`, parameters | 0x00 (PBKDF2), 1 iteration | +| `share_secret` | `404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f` | +| `passphrase_key` | `894e1ee6ba584197c33cfae7f050fe3691e8d79a9899caa8c71b206d604ddc21` | +| `slot_key` | `558d5bd0d8e944df524cfefacad4bf4f76602ba84ebe315d29044af809be9f8c` | + ## 5. Payload: chunked AEAD The change that removes the **format's** reason for a 100 MB cap. The app still @@ -1431,6 +1527,7 @@ is the whole of its validation. |---|---| | 0x00 passphrase | 0x00, 0x01 — **never 0x02** | | 0x02 Shamir | 0x02 — **only** | +| 0x03 passphrase and shares | 0x00, 0x01 — **never 0x02** (§4.8) | A reader MUST disqualify a slot that violates either direction, before invoking any KDF. The forbidden combinations are the two that would be silently wrong @@ -1935,6 +2032,15 @@ is the one that closed the document. 5. New fixtures are **added** to `scripts/fixtures/keymaker/`, never substituted, and the v1 fixtures stay exactly as they are. +**What "frozen" still admits: a new slot type.** Frozen means no byte a reader +already interprets changes meaning. A new `slot_type` changes none: §4.4 has +every existing reader skip a type it does not implement, so a container carrying +one still opens through its other slots in every reader ever shipped, and one +whose only slot is the new type is refused by an old reader with the same +generic failure as any other unopenable file. §4.8 was added on exactly those +terms, and any future type must be too: a fixed-width record, the same prefix, +and nothing outside the slot that an old reader would read differently. + ## 10. Review checklist Items marked ✅ are answered by `reference/keym2.py selftest`, which executes diff --git a/docs/RECOVERY.md b/docs/RECOVERY.md index be7b92e..80744b8 100644 --- a/docs/RECOVERY.md +++ b/docs/RECOVERY.md @@ -191,6 +191,11 @@ per line, then: python3 keym2.py decrypt --in backup.keym --shares-from shares.txt --out recovered.txt ``` +**If the strips need the password as well.** A backup can be made so that the +strips open it only together with the password, and never alone. `inspect` +then says `password and share set, both needed`, and the command above asks +for the password after reading the strips. Type it as usual. + **If each strip has two codes**, the second is the backup itself, a line starting `KMPART2:1/1:`, and the strips are all you need. Save that line from any one strip in a file called `parts.txt`, turn it back into the backup file, diff --git a/reference/bridge.mts b/reference/bridge.mts index 14e38a6..70596f7 100644 --- a/reference/bridge.mts +++ b/reference/bridge.mts @@ -56,6 +56,7 @@ import { import { encryptKeym2, encryptKeym2WithExplicitSecrets, + encryptKeym2WithSharesRequired, decryptKeym2, KEYM2_VERSION_V2, KEYM2_VERSION_V3, @@ -183,6 +184,46 @@ try { ); writeFileSync(outFile, Buffer.from(out)); + } else if (cmd === "encryptboth") { + // §4.8. A container whose only slot takes the password and k of n shares, + // every random input pinned (salt, master key, share secret, coefficients, + // container id), so crosstest2.py can compare the bytes and the share + // strings each implementation writes. + const kdf: KdfParams = + flag("kdf") === "argon2id" + ? { + kdf: KdfId.ARGON2ID, + params: { + timeCost: Number(flag("time") ?? 2), + memoryKiB: Number(flag("mem") ?? 16384), + parallelism: Number(flag("par") ?? 2), + }, + } + : { kdf: KdfId.PBKDF2, params: { iterations: Number(flag("iterations") ?? 600_000) } }; + const hex = (name: string) => { + const v = flag(name); + return v === undefined ? undefined : Uint8Array.from(Buffer.from(v, "hex")); + }; + const containerId = hex("container-id"); + const { container, shares } = await encryptKeym2WithSharesRequired( + new Uint8Array(inputBuf), + password, + keyFile ? new Uint8Array(keyFile) : null, + { kdf, cipher: CIPHERS[flag("cipher") ?? "aes"]! }, + Number(flag("threshold")), + Number(flag("shares")), + containerId === undefined ? KEYM2_VERSION_V2 : KEYM2_VERSION_V3, + { + salt: hex("salt"), + masterKey: hex("master-key"), + containerId, + shareSecret: hex("share-secret"), + coefficients: hex("share-coefficients"), + } + ); + writeFileSync(outFile, Buffer.from(container)); + writeFileSync(flag("shares-out")!, shares.join("\n") + "\n"); + } else if (cmd === "encryptapp") { const kdf: KdfParams = flag("kdf") === "argon2id" diff --git a/reference/crosstest2.py b/reference/crosstest2.py index 976570c..8783903 100644 --- a/reference/crosstest2.py +++ b/reference/crosstest2.py @@ -219,8 +219,12 @@ def main() -> int: # two inseparable: a stripped container that opens is only # correct if the reader also says the table changed, and one that # says so but refuses to open is worse than v2. + # §4.8's vectors open with the password and their shares both. + both_shares = (f["both"]["shares"][-f["both"]["threshold"]:] + if "both" in f else None) got = keym2.decrypt_report(blob, fx_pw, - keyfile_bytes=fx_kf if f["keyFile"] else None) + keyfile_bytes=fx_kf if f["keyFile"] else None, + shares=both_shares) check(f["name"], got.plaintext.decode() == f["plaintext"]) # `slotTableAuthentic` is recorded only on v3 entries, which are # the only containers carrying a slot_table_mac. Absent means the @@ -271,6 +275,19 @@ def main() -> int: except keym2.KeymError: check(f"{f['name']}: a wrong PRF output is still refused", True) + # §4.8. The TypeScript wrote these; the reference must refuse each + # half alone, as well as open the two together above. + if "both" in f: + both_all = f["both"]["shares"] + for half, attempt in ( + ("the password alone", lambda: keym2.decrypt(blob, fx_pw)), + ("the js-written shares alone", lambda: keym2.decrypt(blob, shares=both_all[:3]))): + try: + attempt() + check(f"{f['name']}: {half} is refused", False) + except keym2.KeymError: + check(f"{f['name']}: {half} is refused", True) + if "shamir" in f: k = f["shamir"]["threshold"] shares = f["shamir"]["shares"] @@ -291,18 +308,20 @@ def main() -> int: 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] + both_fixtures = [f for f in modern if "both" in f] # 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-six vectors: six share sets, six " - "passkeys, one page, one stripped table", - len(v2_fixtures) == 13 and len(v3_fixtures) == 13 + 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 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"{len(shamir_fixtures)} shamir, {len(passkey_fixtures)} passkey, " - f"{len(stripped_fixtures)} stripped") + f"{len(both_fixtures)} both, {len(stripped_fixtures)} stripped") # --------------------------------------------------------------- # 1. Byte equality — the check that catches a writer disagreement @@ -1692,6 +1711,66 @@ def py_verdict(kind: str, text: str) -> dict: all(t.startswith(keym2.SHARE2_PREFIX + keym2.share_text_set_code(t) + "-") for t in (*shares_t, *js_strips)) and len(js_strips) > 0) + # §4.8: a container whose only slot takes the password and the shares. + # Every random input pinned, so the two writers are compared on the + # bytes they emit and the share strings they print, then each opens + # the other's, and neither opens it with only one half. + print("\n§4.8 password-and-shares slot:") + for both_ver, both_cid in ((2, None), (3, os.urandom(16))): + for both_cipher, both_cid_name in ((keym2.CIPHER_AES, "aes"), (keym2.CIPHER_CHACHA, "chacha"), + (keym2.CIPHER_CHAINED, "chained")): + for both_kdf in ("pbkdf2", "argon2id"): + tag = f"v{both_ver} {both_cid_name} {both_kdf}" + pins = dict(salt=os.urandom(32), master_key=os.urandom(32), + share_secret=os.urandom(32), coefficients=os.urandom(32 * 2)) + pt = f"both halves, {tag}".encode() + kdf_kw = (dict(kdf_id=keym2.KDF_PBKDF2, iterations=600_000) if both_kdf == "pbkdf2" + else dict(kdf_id=keym2.KDF_ARGON2ID, time_cost=1, memory_kib=8192, parallelism=1)) + py_c, py_s = keym2.encrypt_both( + pt, PASSWORD, 3, 5, cipher_id=both_cipher, version=both_ver, + container_id=both_cid, **kdf_kw, **pins) + src, js_out, js_sh = tmp / "both-pt.bin", tmp / "both-js.keym", tmp / "both-js.txt" + src.write_bytes(pt) + args = ["encryptboth", "--password", PASSWORD, "--in", str(src), "--out", str(js_out), + "--shares-out", str(js_sh), "--threshold", "3", "--shares", "5", + "--cipher", both_cid_name, "--salt", pins["salt"].hex(), + "--master-key", pins["master_key"].hex(), + "--share-secret", pins["share_secret"].hex(), + "--share-coefficients", pins["coefficients"].hex()] + args += (["--kdf", "pbkdf2", "--iterations", "600000"] if both_kdf == "pbkdf2" + else ["--kdf", "argon2id", "--time", "1", "--mem", "8192", "--par", "1"]) + if both_cid is not None: + args += ["--container-id", both_cid.hex()] + try: + bridge(*args) + js_c = js_out.read_bytes() + js_s = [ln for ln in js_sh.read_text().splitlines() if ln.strip()] + except BridgeError as e: + js_c, js_s = b"", [f"bridge: {e}"] + check(f"{tag}: the container is byte-identical", py_c == js_c, + f"py={len(py_c)}B js={len(js_c)}B") + check(f"{tag}: the five strips are the same strings", py_s == js_s) + check_call(f"{tag}: the reference opens it with the password and three strips", + lambda: keym2.decrypt(js_c, PASSWORD, shares=[js_s[0], js_s[2], js_s[4]]), pt) + share_file = tmp / "both-three.txt" + share_file.write_text("\n".join(py_s[1:4]) + "\n") + try: + bridge("decrypt2", "--in", str(js_out), "--out", str(tmp / "both.out"), + "--password", PASSWORD, "--share-file", str(share_file)) + js_opened = (tmp / "both.out").read_bytes() + except BridgeError as e: + js_opened = str(e).encode() + check(f"{tag}: the TypeScript opens it with the password and three strips", + js_opened == pt, js_opened[:80]) + for half_args, half in ((["--password", PASSWORD], "the password alone"), + (["--share-file", str(share_file)], "the strips alone")): + try: + bridge("decrypt2", "--in", str(js_out), "--out", str(tmp / "half.out"), *half_args) + half_opened = True + except BridgeError: + half_opened = False + check(f"{tag}: the TypeScript refuses {half}", not half_opened) + # §6: a share set whose id matches the container's only in its first # four bytes. The values are this set's own, so a reader comparing four # bytes reconstructs the right secret and opens the container. diff --git a/reference/keym2.py b/reference/keym2.py index c9eba13..8189e10 100644 --- a/reference/keym2.py +++ b/reference/keym2.py @@ -280,8 +280,10 @@ SLOT_TYPE_PASSPHRASE = 0x00 SLOT_TYPE_PASSKEY_PRF = 0x01 # §4.7 SLOT_TYPE_SHAMIR = 0x02 # §4.6 +SLOT_TYPE_PASSPHRASE_AND_SHARES = 0x03 # §4.8 IMPLEMENTED_SLOT_TYPES = frozenset( - {SLOT_TYPE_PASSPHRASE, SLOT_TYPE_PASSKEY_PRF, SLOT_TYPE_SHAMIR}) + {SLOT_TYPE_PASSPHRASE, SLOT_TYPE_PASSKEY_PRF, SLOT_TYPE_SHAMIR, + SLOT_TYPE_PASSPHRASE_AND_SHARES}) # §6, and normative in both directions. A passphrase under HKDF is a password # with no stretching at all, and nothing in any output would reveal it; a @@ -292,6 +294,9 @@ SLOT_TYPE_PASSPHRASE: frozenset({KDF_PBKDF2, KDF_ARGON2ID}), SLOT_TYPE_PASSKEY_PRF: frozenset({KDF_HKDF}), SLOT_TYPE_SHAMIR: frozenset({KDF_HKDF}), + # §4.8: the guessable half is a password, so the passphrase KDFs and never + # HKDF alone, exactly as for 0x00. + SLOT_TYPE_PASSPHRASE_AND_SHARES: frozenset({KDF_PBKDF2, KDF_ARGON2ID}), } # §3.3. The container flags byte is entirely reserved now; the key-file hint @@ -333,6 +338,10 @@ # same slot key. CTX_PASSKEY_INPUT = b"keymaker.v2.passkey-input" +# §4.8, a fourth domain string, for the slot that takes a passphrase and a share +# set together. +CTX_BOTH_INPUT = b"keymaker.v2.passphrase-and-shares" + # §4.7. The PRF salt is derived from slot_salt rather than stored. # # It differs from INFO_SLOT_KEY as hygiene, not because anything rests on it: @@ -984,8 +993,52 @@ def build_passkey_input(prf_output: bytes) -> bytes: return lp(CTX_PASSKEY_INPUT) + lp(prf_output) -def derive_slot_key(slot: Slot, kdf_input: bytes) -> bytes: +@dataclass(frozen=True) +class BothSecret: + """ + §4.8's two inputs, held together until the slot key is derived: §4.1's + kdf_input, which the slot's own KDF stretches, and §4.6's reconstructed + share secret, which it does not. + """ + + kdf_input: bytes + share_secret: bytes + + +def build_both_input(passphrase_key: bytes, share_secret: bytes) -> bytes: + """ + §4.8. The HKDF input for ``slot_type = 0x03``: the stretched passphrase and + the share secret, length-prefixed under their own domain string. + """ + if len(passphrase_key) != SLOT_KEY_LEN: + raise UsageError("passphrase key must be 32 bytes") + if len(share_secret) != SHARE_VALUE_LEN: + raise UsageError("share secret must be 32 bytes") + return lp(CTX_BOTH_INPUT) + lp(passphrase_key) + lp(share_secret) + + +def derive_slot_key(slot: Slot, kdf_input: "bytes | BothSecret") -> bytes: """§4.3. Assumes `slot` came from parse_slot, i.e. is already bounded.""" + if slot.slot_type == SLOT_TYPE_PASSPHRASE_AND_SHARES: + # §4.8. The slot's KDF stretches the passphrase, the guessable half; + # HKDF then combines it with the share secret, which needs no + # stretching. Two stages, and only the first is memory-hard. + if not isinstance(kdf_input, BothSecret): + raise _reject() + passphrase_key = _stretch(slot, kdf_input.kdf_input) + return HKDF( + algorithm=hashes.SHA256(), + length=SLOT_KEY_LEN, + salt=slot.salt, + info=INFO_SLOT_KEY, + ).derive(build_both_input(passphrase_key, kdf_input.share_secret)) + if isinstance(kdf_input, BothSecret): + raise _reject() + return _stretch(slot, kdf_input) + + +def _stretch(slot: Slot, kdf_input: bytes) -> bytes: + """§4.3, one slot's own KDF over its input.""" if slot.kdf_id == KDF_HKDF: # §3.2. No cost parameters, because the secret this stretches is # already 32 CSPRNG bytes and 2^256 does not get larger when multiplied @@ -1900,6 +1953,75 @@ def build_passphrase_slot( return prefix + wrap_master_key(core, prefix, slot_key, master_key) +def build_both_slot( + core: CoreHeader, + master_key: bytes, + password: str, + k: int, + n: int, + *, + kdf_id: int = KDF_ARGON2ID, + keyfile_bytes: Optional[bytes] = None, + iterations: int = 1_000_000, + time_cost: int = 3, + memory_kib: int = 65_536, + parallelism: int = 4, + salt: Optional[bytes] = None, + share_secret: Optional[bytes] = None, + coefficients: Optional[bytes] = None, + enforce_write_policy: bool = True, +) -> tuple[bytes, list[str]]: + """ + §4.8. Build one ``slot_type = 0x03`` record and the ``n`` shares that, + together with ``password``, open it. Returns (slot record, share texts). + + The prefix is a passphrase slot's in every field but the type, and goes + through the same writer checks, because its parameters bound the same KDF. + The shares are §4.6's, named by this slot's salt. + + ``salt``, ``share_secret`` and ``coefficients`` are for byte comparison only + (§4.5). + """ + if salt is None: + salt = os.urandom(SALT_LEN) + if len(salt) != SALT_LEN: + raise UsageError("slot salt must be 32 bytes") + if len(master_key) != MASTER_KEY_LEN: + raise UsageError("master key must be 32 bytes") + if share_secret is None: + share_secret = os.urandom(SHARE_VALUE_LEN) + + draft = Slot( + slot_type=SLOT_TYPE_PASSPHRASE_AND_SHARES, + kdf_id=kdf_id, + slot_flags=SLOT_FLAG_KEYFILE if keyfile_bytes is not None else 0, + salt=salt, + wrapped_key=b"", + iterations=iterations, + time_cost=time_cost, + memory_kib=memory_kib, + parallelism=parallelism, + ) + check_writable_params(draft) + if enforce_write_policy: + check_write_policy(draft) + + prefix = draft.pack_prefix() + parse_slot(prefix + b"\x00" * (MASTER_KEY_LEN + core.tag_overhead)) + + # Split first: shamir_split validates k and n, and a refused split should + # cost nothing, not an Argon2id derivation. + parts = shamir_split(share_secret, k, n, coefficients=coefficients) + slot_key = derive_slot_key( + draft, BothSecret(build_kdf_input(password, keyfile_bytes), share_secret)) + record = prefix + wrap_master_key(core, prefix, slot_key, master_key) + + set_id = share_set_id_v2(salt) + texts = [encode_share_v2(Share(set_id=set_id, threshold=k, index=x, value=value)) + for x, value in parts] + return record, texts + + def assemble(core: CoreHeader, slots: list[bytes], payload: bytes, master_key: Optional[bytes] = None) -> bytes: """ @@ -2004,6 +2126,92 @@ def encrypt( master_key if core.is_v3 else None) +def encrypt_both( + plaintext: bytes, + password: str, + k: int, + n: int, + *, + kdf_id: int = KDF_ARGON2ID, + cipher_id: int = CIPHER_AES, + keyfile_bytes: Optional[bytes] = None, + iterations: int = 1_000_000, + time_cost: int = 3, + memory_kib: int = 65_536, + parallelism: int = 4, + salt: Optional[bytes] = None, + master_key: Optional[bytes] = None, + share_secret: Optional[bytes] = None, + coefficients: Optional[bytes] = None, + enforce_write_policy: bool = True, + version: int = VERSION, + container_id: Optional[bytes] = None, +) -> tuple[bytes, list[str]]: + """ + §4.8. A container whose only slot takes the passphrase **and** k of the n + shares returned. Returns (container, share texts). + + §4.8 permits the 0x03 slot to stand alone, and this is the writer for that + case, because it is the case the type exists for: with a plain passphrase + slot beside it the shares would be decoration. What it costs is §4.8's to + state and the caller's to show: a forgotten passphrase, or fewer than k + strips, loses the backup. + + ``salt``, ``master_key``, ``share_secret``, ``coefficients`` and + ``container_id`` are for byte comparison only, as in ``encrypt``. + """ + if cipher_id not in (CIPHER_AES, CIPHER_CHACHA, CIPHER_CHAINED): + 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 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") + core = CoreHeader(cipher_id=cipher_id, version=version, + container_id=container_id or b"") + if master_key is None: + master_key = os.urandom(MASTER_KEY_LEN) + elif len(master_key) != MASTER_KEY_LEN: + raise UsageError("master key must be 32 bytes") + + record, texts = build_both_slot( + core, master_key, password, k, n, + kdf_id=kdf_id, keyfile_bytes=keyfile_bytes, iterations=iterations, + time_cost=time_cost, memory_kib=memory_kib, parallelism=parallelism, + salt=salt, share_secret=share_secret, coefficients=coefficients, + enforce_write_policy=enforce_write_policy, + ) + container = assemble(core, [record], encrypt_payload(core, master_key, plaintext), + master_key if core.is_v3 else None) + return container, texts + + +def shares_need_password(container: bytes, shares: list[str]) -> bool: + """ + §4.8's MAY: do these shares belong to a slot that also takes the password? + + True when their set id matches a 0x03 slot's. Computed from the slot salt, + which is in the clear, so saying so tells the holder nothing anyone holding + the container could not work out, and it saves them from being told + "decryption failed" about strips that were never wrong. + """ + try: + _core, records, _payload = parse_container(container) + ids = {decode_share_any(t).set_id for t in shares} + except (KeymError, UsageError): + return False + for record in records: + slot = _attemptable(record) + if slot is None or slot.slot_type != SLOT_TYPE_PASSPHRASE_AND_SHARES: + continue + wide = share_set_id_v2(slot.salt) + if any(hmac.compare_digest(wide[:len(i)], i) for i in ids): + return True + return False + + def slot_secret_for( slot: Slot, *, @@ -2011,7 +2219,7 @@ def slot_secret_for( keyfile_bytes: Optional[bytes], shares: Optional[list[str]], prf_output: Optional[bytes], -) -> Optional[bytes]: +) -> "Optional[bytes | BothSecret]": """ §4.1 / §4.6. The slot secret this caller can offer *this* slot, or None if it holds nothing of the kind the slot wants. @@ -2037,6 +2245,18 @@ def slot_secret_for( return None return build_shamir_input(secret) + if slot.slot_type == SLOT_TYPE_PASSPHRASE_AND_SHARES: + # §4.8: both, or this slot is not attempted. The set id is compared, + # in full, before anything is stretched, so shares for some other slot + # decline here rather than after an Argon2id derivation on their behalf. + if password is None or not shares: + return None + try: + secret = combine_shares(shares, expected_set_id=share_set_id_v2(slot.salt)) + except KeymError: + return None + return BothSecret(build_kdf_input(password, keyfile_bytes), secret) + if slot.slot_type == SLOT_TYPE_PASSKEY_PRF: if prf_output is None: return None @@ -2358,6 +2578,49 @@ def add_shamir_slot( master if core.is_v3 else None), texts) +def add_both_slot( + container: bytes, + unlock_password: Optional[str], + new_password: str, + k: int, + n: int, + *, + unlock_keyfile: Optional[bytes] = None, + unlock_shares: Optional[list[str]] = None, + unlock_prf_output: Optional[bytes] = None, + new_keyfile: Optional[bytes] = None, + kdf_id: int = KDF_ARGON2ID, + iterations: int = 1_000_000, + time_cost: int = 3, + memory_kib: int = 65_536, + parallelism: int = 4, + salt: Optional[bytes] = None, + share_secret: Optional[bytes] = None, + coefficients: Optional[bytes] = None, +) -> tuple[bytes, list[str]]: + """ + §4.8. Enrol a passphrase-and-shares slot on an existing container, holding + one other secret. Returns (container, share texts). + + On its own this adds a way in that needs more than the existing ones, which + is only useful once those are removed (``remove_slot``): the point of 0x03 + is a container nothing weaker opens. + """ + core, records, payload, master = recover_master_key( + container, unlock_password, keyfile_bytes=unlock_keyfile, + shares=unlock_shares, prf_output=unlock_prf_output) + require_authentic_slot_table(container, core, records, master) + if len(records) >= SLOT_COUNT_MAX: + raise UsageError(f"container already has {SLOT_COUNT_MAX} slots") + record, texts = build_both_slot( + core, master, new_password, k, n, + kdf_id=kdf_id, keyfile_bytes=new_keyfile, iterations=iterations, + 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) + + def _passkey_only(records: list[bytes]) -> bool: """ True when every slot a reader could attempt is a passkey slot. @@ -3023,6 +3286,22 @@ def _describe_slot(index: int, record: bytes) -> list[str]: f" credential not stored — §4.7 keeps no identifier, so which " f"passkey opens this is not knowable from the file", ] + if slot.slot_type == SLOT_TYPE_PASSPHRASE_AND_SHARES: + # §4.8. Both halves described: the passphrase's KDF and key file, and + # the share set's id and set code, so an heir can see from the file + # alone that the strips and the password are needed together. + kdf = ( + f"PBKDF2-HMAC-SHA-256, iterations={slot.iterations}" + if slot.kdf_id == KDF_PBKDF2 + else f"Argon2id, t={slot.time_cost} m={slot.memory_kib}KiB p={slot.parallelism}" + ) + return [ + f" slot {index} type 0x{slot.slot_type:02x} (password and share set, both needed)", + f" kdf {kdf}, then HKDF-SHA-256 with the shares", + f" key file {'required' if slot.keyfile_used else 'not used'}", + f" set code {share_set_code(slot.salt)}", + f" salt {slot.salt.hex()}", + ] if slot.slot_type == SLOT_TYPE_SHAMIR: # No threshold and no share count: §4.6 keeps both out of the container, # so inspect reports what is actually there rather than inventing it. @@ -4116,6 +4395,77 @@ def _rejects_cleanly(name: str, i: object, o: int) -> None: check("a KMSHARE1 strip, or a truncated one, reports no set code", share_text_set_code(_v1_share) is None and share_text_set_code(SHARE2_PREFIX + _code[:4]) is None) + # --- §4.8 passphrase-and-shares slot (0x03) ------------------------------- + _both_draft = Slot(slot_type=SLOT_TYPE_PASSPHRASE_AND_SHARES, kdf_id=KDF_PBKDF2, + slot_flags=0, salt=bytes(range(32)), wrapped_key=b"", iterations=1) + check("§4.8's vector: the slot key from a password, a salt and a share secret", + derive_slot_key(_both_draft, BothSecret(build_kdf_input("correct horse", None), + bytes(range(0x40, 0x60)))).hex() + == "558d5bd0d8e944df524cfefacad4bf4f76602ba84ebe315d29044af809be9f8c") + _pp_draft = Slot(slot_type=SLOT_TYPE_PASSPHRASE, kdf_id=KDF_PBKDF2, slot_flags=0, + salt=bytes(range(32)), wrapped_key=b"", iterations=1) + check("a 0x03 slot key is not the 0x00 key for the same password and salt", + derive_slot_key(_pp_draft, build_kdf_input("correct horse", None)) + != derive_slot_key(_both_draft, BothSecret(build_kdf_input("correct horse", None), + bytes(range(0x40, 0x60))))) + rejects("a 0x03 slot declaring HKDF is refused (§6 pairing)", + lambda: parse_slot(bytes([SLOT_TYPE_PASSPHRASE_AND_SHARES, KDF_HKDF, 0]) + + bytes(5) + bytes(32) + bytes(8) + bytes(48))) + + _pt = b"needs the executor and the family" + for _ver in (VERSION_V2, VERSION_V3): + _both, _strips = encrypt_both(_pt, "executor password", 2, 3, kdf_id=KDF_PBKDF2, + iterations=600_000, version=_ver) + _tag = f"v{_ver}" + check(f"{_tag}: a 0x03 container opens with the password and two strips", + decrypt(_both, "executor password", shares=[_strips[0], _strips[2]]) == _pt) + rejects(f"{_tag}: the password alone does not open it", + lambda: decrypt(_both, "executor password")) + rejects(f"{_tag}: the strips alone do not open it", + lambda: decrypt(_both, shares=_strips)) + rejects(f"{_tag}: a wrong password with the right strips does not", + lambda: decrypt(_both, "not the password", shares=_strips[:2])) + rejects(f"{_tag}: one strip with the password does not", + lambda: decrypt(_both, "executor password", shares=_strips[:1])) + # A share set for some other slot is declined before the KDF runs, so a + # heap of the wrong strips costs nothing. + _other_container, _other_strips = add_shamir_slot( + encrypt(b"other", "other password", kdf_id=KDF_PBKDF2, iterations=600_000), + "other password", 2, 3) + _both_slot = parse_slot(parse_container(_both)[1][0]) + check("strips for a different slot are declined before any stretching", + slot_secret_for(_both_slot, password="executor password", keyfile_bytes=None, + shares=_other_strips[:2], prf_output=None) is None) + check("shares_need_password knows a 0x03 slot's strips", + shares_need_password(_both, _strips[:2]) + and not shares_need_password(_other_container, _other_strips[:2])) + check("inspect says both are needed, and gives the set code", + "password and share set, both needed" in _inspect(_both) + and f"set code {share_set_code(_both_slot.salt)}" in _inspect(_both)) + # Enrolled beside a passphrase, then the passphrase removed: the path an + # app takes to turn an existing backup into one that needs both. + _plain = encrypt(_pt, "old password", kdf_id=KDF_PBKDF2, iterations=600_000) + _added, _add_strips = add_both_slot(_plain, "old password", "executor password", 2, 3, + kdf_id=KDF_PBKDF2, iterations=600_000) + _only = remove_slot(_added, 0, unlock_password="old password") + check("add_both_slot then remove_slot leaves a container that needs both", + decrypt(_only, "executor password", shares=_add_strips[1:]) + == _pt and decrypt_report(_only, "executor password", + shares=_add_strips[1:]).slot_table_authentic) + rejects("and the old password no longer opens it", + lambda: decrypt(_only, "old password")) + # §4.4: a reader that predates §4.8 skips 0x03. Beside a passphrase slot + # it opens as before; alone, it is refused like any other unopenable file. + _saved = IMPLEMENTED_SLOT_TYPES + try: + globals()["IMPLEMENTED_SLOT_TYPES"] = _saved - {SLOT_TYPE_PASSPHRASE_AND_SHARES} + check("a pre-§4.8 reader still opens {passphrase, 0x03} by passphrase", + decrypt(_added, "old password") == _pt) + rejects("a pre-§4.8 reader refuses a container whose only slot is 0x03", + lambda: decrypt(_only, "executor password", shares=_add_strips[1:])) + finally: + globals()["IMPLEMENTED_SLOT_TYPES"] = _saved + _other_code = share_set_code(bytes(range(1, 33))) check("a different slot salt gives a different set code", _other_code != _code) @@ -5567,6 +5917,14 @@ def main(argv: Optional[list[str]] = None) -> int: password = (args.password if (shares or prf_output) else resolve_password(args.password, confirm=(args.cmd == "encrypt"))) + # §4.8. Strips for a slot that takes the password as well: they alone + # can never open it, so asking is the only useful next step, and the + # sentence says why rather than leaving a bare prompt to be puzzled over. + if (args.cmd == "decrypt" and shares and password is None + and shares_need_password(data, shares)): + print("These strips open this backup together with its password.", + file=sys.stderr) + password = resolve_password(None) except UsageError as e: print(f"error: {e}", file=sys.stderr) return 1 diff --git a/reference/recovery_test.py b/reference/recovery_test.py index ede653e..78ab03c 100644 --- a/reference/recovery_test.py +++ b/reference/recovery_test.py @@ -853,6 +853,50 @@ def recovery_commands() -> None: check(r.returncode == 0 and got == SECRET, f"and then `{c}` opens it with the strips alone", r.stderr.strip()[:160]) + # "If the strips need the password as well": a §4.8 container the + # shipping writer made, opened with the page's own shares command, + # which reads the strips and then asks for the password on stdin. + both_src = tmp / "both-pt.bin" + both_src.write_bytes(SECRET) + both_issued = tmp / "both-issued.txt" + r = subprocess.run( + ["node", str(BRIDGE), "encryptboth", "--password", PASSWORD, + "--in", str(both_src), "--out", str(backup), "--shares-out", str(both_issued), + "--threshold", "2", "--shares", "3", "--kdf", "pbkdf2", "--iterations", "600000", + "--salt", os.urandom(32).hex(), "--master-key", os.urandom(32).hex(), + "--container-id", os.urandom(16).hex(), + "--share-secret", os.urandom(32).hex(), "--share-coefficients", os.urandom(32).hex()], + capture_output=True, text=True, cwd=ROOT) + both_strips = [ln for ln in both_issued.read_text().split("\n") if ln.strip()] \ + if r.returncode == 0 else [] + check(len(both_strips) == 3, "wrote a backup whose strips need the password", + r.stderr.strip()[:160]) + if both_strips: + v3_line = [c for c in identify if 3 in claimed_versions(c)] + if v3_line: + r = run_doc_command(v3_line[0], tmp, stdin="") + check("password and share set, both needed" in r.stdout, + "inspect says the strips and the password are both needed", + r.stdout.strip()[-200:]) + shares_file.write_text(both_strips[1] + "\n" + both_strips[2] + "\n") + for c in shares: + recovered.unlink(missing_ok=True) + r = run_doc_command(c, tmp, stdin="") + check(r.returncode != 0 and not recovered.exists(), + f"`{c}` with no password typed does not open it", r.stderr.strip()[-160:]) + check("together with its password" in r.stderr, + "and says the strips need the password", r.stderr.strip()[-160:]) + recovered.unlink(missing_ok=True) + r = run_doc_command(c, tmp, stdin=PASSWORD + "\n") + got = recovered.read_bytes() if recovered.exists() else b"" + check(r.returncode == 0 and got == SECRET, + f"`{c}` asks for the password and opens it", r.stderr.strip()[-160:]) + check("password and share set, both needed" in doc, + "the page quotes inspect's words for such a backup") + # Back to the share-set backup the checks below are about. + backup.write_bytes(shared_container) + shares_file.write_text("# strips 1 and 3\n" + strips[0] + "\n" + strips[2] + "\n") + # "inspect prints it on the line `set code`", and it is what every # strip begins with. Run with the page's own inspect line for v3. v3_inspect = [c for c in identify if 3 in claimed_versions(c)] diff --git a/scripts/fixtures/keymaker/fixtures.json b/scripts/fixtures/keymaker/fixtures.json index 5520845..9ff8b96 100644 --- a/scripts/fixtures/keymaker/fixtures.json +++ b/scripts/fixtures/keymaker/fixtures.json @@ -3,7 +3,7 @@ "version": 1, "password": "correct horse battery staple — test only", "keyFileHex": "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", - "note": "APPEND-ONLY corpus. Test-only credentials — never use for real data. A fixture with no `version` field is v1; the field was added when v2 vectors joined the corpus and the v1 entries were left untouched. `slotTableAuthentic` appears only on v3 vectors, which are the only ones carrying a slot_table_mac: true where the table is intact, false for the one vector whose slot was stripped (v3 §5.2 — it must still open, and the reader must still report the table has changed).", + "note": "APPEND-ONLY corpus. Test-only credentials — never use for real data. A fixture with no `version` field is v1; the field was added when v2 vectors joined the corpus and the v1 entries were left untouched. `slotTableAuthentic` appears only on v3 vectors, which are the only ones carrying a slot_table_mac: true where the table is intact, false for the one vector whose slot was stripped (v3 §5.2 — it must still open, and the reader must still report the table has changed). A `both` vector (§4.8) opens only with the password and its shares together.", "fixtures": [ { "name": "pbkdf2-aes256gcm", @@ -381,6 +381,66 @@ "keyFile": false, "plaintext": "Keymaker fixture — v2 self-extracting page / aes-256-gcm", "selfextract": true + }, + { + "name": "v3-both-aes256gcm", + "file": "v3-both-aes256gcm.keym", + "version": 3, + "kdf": "pbkdf2", + "cipher": "aes-256-gcm", + "keyFile": false, + "plaintext": "Keymaker fixture — v3 password and shares 3-of-5 / aes-256-gcm", + "both": { + "threshold": 3, + "shares": [ + "KMSHARE2:TBHW-EHEQ-P84N-PNBE-RWB4-AGA5-JG1G-3MEC-M8WR-Z44Z-8HAT-7NQ8-GPDA-XNGS-8PPJ-QS71-SSX3-3P5V-ZNTD-9CSZ-M138-6XTP-3Z15-JSBR-DYSA-R5MQ-7C", + "KMSHARE2:TBHW-EHEQ-P84N-PNBE-RWB4-AGA5-JG1G-4S3J-0RQX-MX86-R0S8-K32R-25B4-T2WA-Z2S4-3XNW-PT38-PGT2-6T4T-KZ22-QN5G-FNMR-G89X-MMBN-RGX5-FX3T-84", + "KMSHARE2:TBHW-EHEQ-P84N-PNBE-RWB4-AGA5-JG1G-6XMT-HDG4-ABNE-QB70-QAE3-PQWP-GMSN-PD4Q-MYV7-HB9V-ARPX-2BY2-1WE6-9SG2-7J0E-W2E6-G7JD-7V13-64RP-H4", + "KMSHARE2:TBHW-EHEQ-P84N-PNBE-RWB4-AGA5-JG1G-9B6A-VQTG-X356-Y5ZR-1P9X-TSNH-MJY1-CAGJ-W0M1-H9ND-2PZZ-PTCQ-EGEQ-JB6P-XK3B-NXKE-GJRQ-Y6H7-HJTT-58", + "KMSHARE2:TBHW-EHEQ-P84N-PNBE-RWB4-AGA5-JG1G-BFH2-A2X9-3NRE-HE1G-5Z56-EB23-Y4VY-55D1-B3TT-PRZY-YYK0-JBPF-WK2K-61GD-22CY-Z4BY-2G1P-M0CE-2MVN-DG" + ] + }, + "slotTableAuthentic": true + }, + { + "name": "v3-both-chacha20poly1305", + "file": "v3-both-chacha20poly1305.keym", + "version": 3, + "kdf": "pbkdf2", + "cipher": "chacha20-poly1305", + "keyFile": false, + "plaintext": "Keymaker fixture — v3 password and shares 3-of-5 / chacha20-poly1305", + "both": { + "threshold": 3, + "shares": [ + "KMSHARE2:M1T4-RPBJ-PR97-81JA-HR5J-BVDM-2G1G-3BQT-NJWT-RWAR-RKV7-ZRZ1-ZJ76-HYKM-QYJW-AQ3X-Q3Z1-N7WC-SMZA-8JWY-SR73-1D8T-6QYX-FGDY-MZRC-J8YJ-PG", + "KMSHARE2:M1T4-RPBJ-PR97-81JA-HR5J-BVDM-2G1G-50JF-V59E-5YG1-K304-JTH7-4NBD-9M94-E70W-2EQH-VY00-W86Z-3GEX-14JB-HCJX-PHZ4-2EM4-1P64-EXPS-QHNZ-50", + "KMSHARE2:M1T4-RPBJ-PR97-81JA-HR5J-BVDM-2G1G-6EM7-Y2KE-WFNH-G38E-YF2E-BEA0-SM4Z-Z0GG-C369-GP7T-PB6M-MQHB-R3Z4-469P-PKR7-AEGP-6291-D64S-95RB-G0", + "KMSHARE2:M1T4-RPBJ-PR97-81JA-HR5J-BVDM-2G1G-9X1X-0QQ5-EXJZ-KYSD-7HS2-M1D6-J3SC-KCRX-REGB-HAT6-PX0M-M23D-BM9S-6WA4-6132-Y7V7-HEBY-YA2F-HW1Y-0W", + "KMSHARE2:M1T4-RPBJ-PR97-81JA-HR5J-BVDM-2G1G-AK7N-5GD5-QCQF-GYH7-B4AB-VTCB-23MQ-2B8H-P31K-T2XW-WY0Z-35WV-JK4P-SM7K-E233-7KNG-P5YW-0YY4-WJ18-MC" + ] + }, + "slotTableAuthentic": true + }, + { + "name": "v3-both-chained", + "file": "v3-both-chained.keym", + "version": 3, + "kdf": "pbkdf2", + "cipher": "chained", + "keyFile": false, + "plaintext": "Keymaker fixture — v3 password and shares 3-of-5 / chained", + "both": { + "threshold": 3, + "shares": [ + "KMSHARE2:TQ45-46JC-VH8D-6714-2MG2-TB4A-CW1G-2C56-MJ36-1PAE-CV4H-Y9HQ-C6RW-NHFS-XT4Y-ZD77-T4BN-CBBZ-KWN8-ZMHX-9HYN-K8A9-7MB5-DH12-RENZ-PF8S-XM", + "KMSHARE2:TQ45-46JC-VH8D-6714-2MG2-TB4A-CW1G-53RS-564G-RTVG-0CYD-CM79-DQVD-8SDS-3M8Z-DHGJ-9WSV-90XY-DM5S-BNX4-8SEW-7X31-VKF9-M267-4VR2-FVMD-2M", + "KMSHARE2:TQ45-46JC-VH8D-6714-2MG2-TB4A-CW1G-7PDS-SG7T-RA4X-TF2X-1CDM-CX58-NWSM-F301-QTBP-HXM3-5TKD-A1TK-PPPT-GRXB-H0MR-1B57-H1DK-NQ5N-X599-X8", + "KMSHARE2:TQ45-46JC-VH8D-6714-2MG2-TB4A-CW1G-981K-8D3J-K92P-ZY8V-VMCK-5YVC-JEZ8-TWKJ-ABE5-JJXW-Z182-RN8B-6HQT-FJKE-YPT8-55GE-97V9-H71X-SZ2C-8C", + "KMSHARE2:TQ45-46JC-VH8D-6714-2MG2-TB4A-CW1G-BXMK-MV0R-KSXV-5XMB-PC6E-4M59-FBB5-PBVC-G0N1-AKG4-KV6H-Z0Q1-VJW4-FV2Q-0YY8-4R72-10D2-GJK0-WWNM-7C" + ] + }, + "slotTableAuthentic": true } ] } diff --git a/scripts/fixtures/keymaker/v3-both-aes256gcm.keym b/scripts/fixtures/keymaker/v3-both-aes256gcm.keym new file mode 100644 index 0000000000000000000000000000000000000000..4407a054f27933693b508e124d5975f44dedc3c5 GIT binary patch literal 233 zcmVf~AAAP(MhiZ-`xvG-bT+#ymIuAA zN$tDj5n9*wB+T~10{{R300000_5oOnNnGo1&4uy0!0fU6=hL+d#5yOg!skfO7Fobo z00}3+00000m!$I|hnJ~Dok2IOsA>vPMX0-!)=c!04~x_Q3qRm+D>*6Vc@@*%NrJ^q z)O@b}DEAMLuk|@Ar!+7>dE<`iN99PW229g|G;A%qm|bxiS=l!MU_lZ6yEz literal 0 HcmV?d00001 diff --git a/scripts/fixtures/keymaker/v3-both-chained.keym b/scripts/fixtures/keymaker/v3-both-chained.keym new file mode 100644 index 0000000000000000000000000000000000000000..92e6f307763ea677d8c0c84c95bf402f9f6f2298 GIT binary patch literal 261 zcmV+g0s8(+MOjS)0ssIPzTdItC*PaCa(=D}G#C5rvOZ=d*Y(cmTaWcWOwD*d@J=0$TLr;Tcj(<8D LFNi-05nE#{v?_V# literal 0 HcmV?d00001 diff --git a/scripts/keymaker-generate-fixtures.mts b/scripts/keymaker-generate-fixtures.mts index f98651c..c89f904 100644 --- a/scripts/keymaker-generate-fixtures.mts +++ b/scripts/keymaker-generate-fixtures.mts @@ -40,6 +40,7 @@ import { addShamirSlotKeym2, addPasskeySlotKeym2, encryptKeym2, + encryptKeym2WithSharesRequired, keym2SlotLen, KEYM2_VERSION_V3, } from "../src/lib/keym-v2.ts"; @@ -489,6 +490,46 @@ async function main() { } } + // §4.8, a container whose only slot takes the password *and* the shares. + // Frozen for the same reason the share-set vectors are, twice over: the + // strings on the paper and the password in a head both have to keep opening + // this, and neither may ever open it alone. Added after the page so every + // entry before it keeps its place in fixtures.json. + for (const cipher of CIPHERS) { + const name = `v3-both-${cipher.slug}`; + const file = `${name}.keym`; + const prior = byName.get(name); + if (prior && existsSync(join(DIR, file))) { + fixtures.push(prior); + kept++; + continue; + } + const plaintext = `Keymaker fixture — v3 password and shares 3-of-5 / ${cipher.name}`; + const { container, shares } = await encryptKeym2WithSharesRequired( + new TextEncoder().encode(plaintext), + PASSWORD, + null, + { kdf: PBKDF2_V2_PARAMS, cipher: cipher.id }, + 3, + 5, + KEYM2_VERSION_V3 + ); + writeFileSync(join(DIR, file), Buffer.from(container)); + fixtures.push({ + name, + file, + version: 3, + kdf: "pbkdf2", + cipher: cipher.name, + keyFile: false, + plaintext, + both: { threshold: 3, shares }, + slotTableAuthentic: true, + }); + wrote++; + console.log(`wrote ${file} (${container.byteLength} bytes, password and 5 shares)`); + } + writeFileSync( metaPath, JSON.stringify( @@ -504,7 +545,8 @@ async function main() { "`slotTableAuthentic` appears only on v3 vectors, which are the only " + "ones carrying a slot_table_mac: true where the table is intact, " + "false for the one vector whose slot was stripped (v3 §5.2 — it must " + - "still open, and the reader must still report the table has changed).", + "still open, and the reader must still report the table has changed). " + + "A `both` vector (§4.8) opens only with the password and its shares together.", fixtures, }, null, diff --git a/scripts/keymaker-regression.mts b/scripts/keymaker-regression.mts index cfddea0..ade4cb6 100644 --- a/scripts/keymaker-regression.mts +++ b/scripts/keymaker-regression.mts @@ -341,7 +341,17 @@ async function main() { const version = fx.version ?? 1; const expected = version === 3 ? "keym-v3" : version === 2 ? "keym-v2" : "keym-v1"; try { - const res = await decryptData(ab, meta.password, keyFile); + // §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 + // password alone is asserted to fail below. + const res = fx.both + ? { + ...(await (await import("../src/lib/keym-v2.ts")).decryptKeym2( + new Uint8Array(blob), meta.password, null, fx.both.shares.slice(-fx.both.threshold) + )), + format: expected, + } + : await decryptData(ab, meta.password, keyFile); check( res.format === expected && dec.decode(res.data) === fx.plaintext, `v${version} ${fx.name} (${fx.kdf} / ${fx.cipher}${fx.keyFile ? " / +keyfile" : ""})` @@ -383,6 +393,26 @@ async function main() { check(refused, `v${version} ${fx.name} — ${k - 1} shares still do not`); } + // §4.8. Neither half alone, and not k-1 strips with the password. + if (fx.both) { + const { decryptKeym2 } = await import("../src/lib/keym-v2.ts"); + const all: string[] = fx.both.shares; + const k: number = fx.both.threshold; + for (const [label, attempt] of [ + ["the password alone", () => decryptKeym2(new Uint8Array(blob), meta.password, null)], + ["the shares alone", () => decryptKeym2(new Uint8Array(blob), "", null, all.slice(0, k))], + [`the password and ${k - 1} shares`, () => decryptKeym2(new Uint8Array(blob), meta.password, null, all.slice(0, k - 1))], + ] as const) { + let refused = false; + try { + await attempt(); + } catch { + refused = true; + } + check(refused, `v${version} ${fx.name} — ${label} still does not open it`); + } + } + // §4.7. Same promise in the other shape: the recorded PRF output is the // only way back into this container, and a fixture nobody can open is // not a fixture. What it pins is that the derivation from those 32 bytes @@ -440,13 +470,15 @@ async function main() { 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 === 32 && v1Count === 6 && v2Count === 13 && v3Count === 13 && - shamirCount === 6 && passkeyCount === 6 && pageCount === 1 && strippedCount === 1, + fixtureCount === 35 && v1Count === 6 && v2Count === 13 && v3Count === 16 && + 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 ` + - `sets, ${passkeyCount} passkey slots, ${pageCount} self-extracting page and ` + - `${strippedCount} stripped slot table = ${fixtureCount}/32)` + `sets, ${passkeyCount} passkey slots, ${bothCount} password-and-shares slots, ` + + `${pageCount} self-extracting page and ${strippedCount} stripped slot table = ${fixtureCount}/35)` ); } catch (err) { check(false, `fixture load — threw: ${(err as Error).message}`); diff --git a/src/components/container-inspector.tsx b/src/components/container-inspector.tsx index 783e58a..1cfd71d 100644 --- a/src/components/container-inspector.tsx +++ b/src/components/container-inspector.tsx @@ -25,6 +25,7 @@ import { KEYM2_KDF_HKDF, KEYM2_SLOT_TYPE_PASSKEY, KEYM2_SLOT_TYPE_SHAMIR, + KEYM2_SLOT_TYPE_BOTH, KEYM2_VERSION, KEYM2_VERSION_V2, KEYM2_VERSION_V3, @@ -244,6 +245,16 @@ function parsePeek(peek: Uint8Array): ParsedPeek | "legacy" | null { } else if (slot.slotType === KEYM2_SLOT_TYPE_SHAMIR) { // No k or n: §4.6 stores neither, and this pane never invents. slots.push({ index: i, label: "Share set", detail: "Shamir · HKDF-SHA-256" }); + } else if (slot.slotType === KEYM2_SLOT_TYPE_BOTH) { + // §4.8. Both named, because "Passphrase" alone would tell someone the + // password opens it, and it does not without the strips. + const kdf = + slot.kdf.kdf === KdfId.PBKDF2 + ? `PBKDF2 · ${slot.kdf.params.iterations.toLocaleString("en-US")} iterations` + : slot.kdf.kdf === KdfId.ARGON2ID + ? `Argon2id · ${Math.round(slot.kdf.params.memoryKiB / 1024)} MiB` + : ""; + slots.push({ index: i, label: "Password and shares", detail: `both needed · ${kdf}` }); } else if (slot.kdf.kdf === KEYM2_KDF_HKDF) { slots.push({ index: i, label: "HKDF slot", detail: "HKDF-SHA-256" }); } else if (slot.kdf.kdf === KdfId.PBKDF2) { diff --git a/src/components/encryptor-tool.tsx b/src/components/encryptor-tool.tsx index f32beb3..79bd62a 100644 --- a/src/components/encryptor-tool.tsx +++ b/src/components/encryptor-tool.tsx @@ -1458,7 +1458,18 @@ export function EncryptorTool() { const [passkeySupported, setPasskeySupported] = useState(false); const [shamirThreshold, setShamirThreshold] = useState(2); const [shamirCount, setShamirCount] = useState(3); - const [issuedShares, setIssuedShares] = useState<{ threshold: number; shares: string[] } | null>(null); + /** + * §4.8. The strips open the backup only together with the password: one slot + * that takes both, and nothing beside it. Off by default, because it makes a + * forgotten password, or too few strips, the end of the backup. + */ + const [sharesNeedPassword, setSharesNeedPassword] = useState(false); + const [issuedShares, setIssuedShares] = useState<{ + threshold: number; + shares: string[]; + /** §4.8: these strips open the backup only together with the password. */ + withPassword?: boolean; + } | null>(null); /** * "Put the whole backup on every strip", for a backup small enough to fit * one printed symbol. Off by default and reset with every new share set: it @@ -1493,6 +1504,8 @@ export function EncryptorTool() { | { kind: "failed"; message: string }; const [rehearsalOpen, setRehearsalOpen] = useState(false); const [rehearsalInput, setRehearsalInput] = useState(""); + /** §4.8: the password, for rehearsing strips that open the backup only with it. */ + const [rehearsalPassword, setRehearsalPassword] = useState(""); const [rehearsalInputRejected, setRehearsalInputRejected] = useState(null); const [rehearsal, setRehearsal] = useState({ kind: "idle" }); const rehearsalLines = useMemo(() => parseShareLines(rehearsalInput), [rehearsalInput]); @@ -1589,7 +1602,7 @@ export function EncryptorTool() { * and this decides which of two toasts the user is told, so it has to be * current rather than nearly current. */ - const issuedSharesRef = useRef<{ threshold: number; shares: string[] } | null>(null); + const issuedSharesRef = useRef<{ threshold: number; shares: string[]; withPassword?: boolean } | null>(null); useEffect(() => { issuedSharesRef.current = issuedShares; }, [issuedShares]); @@ -1609,6 +1622,8 @@ export function EncryptorTool() { tooLarge: boolean; shares?: string[]; threshold?: number; + /** §4.8: the strips open the backup only together with the password. */ + sharesNeedPassword?: boolean; /** §4.6 set codes of the container's share slots, for the owner's sheet. */ setCodes: string[]; /** The backup's one paper part, printed on every strip as well, when chosen. */ @@ -2141,6 +2156,7 @@ export function EncryptorTool() { // is the ink on the printed sheet, never state. setRehearsalOpen(false); setRehearsalInput(""); + setRehearsalPassword(""); setRehearsalInputRejected(null); setRehearsal({ kind: "idle" }); @@ -2997,7 +3013,8 @@ export function EncryptorTool() { // salt derives from it, so the question put to the key depends on a // value the container does not yet contain. let passkey: { prfOutput: Uint8Array; salt: Uint8Array } | undefined; - if (passkeyEnabled) { + // §4.8 rules a passkey out: it would open the backup on its own. + if (passkeyEnabled && !(shamirEnabled && sharesNeedPassword)) { const { derivePrfSalt } = await import("@/lib/keym-v2"); const { enrolPasskey } = await import("@/lib/webauthn-prf"); const slotSalt = crypto.getRandomValues(new Uint8Array(32)); @@ -3014,7 +3031,9 @@ export function EncryptorTool() { mutablePassword, keyFileBuffer, { kdf, cipher: cipherChoice }, - shamirEnabled ? { threshold: shamirThreshold, count: shamirCount } : undefined, + shamirEnabled + ? { threshold: shamirThreshold, count: shamirCount, withPassword: sharesNeedPassword } + : undefined, passkey ); resultBuffer = encrypted.data; @@ -3034,11 +3053,16 @@ export function EncryptorTool() { setRehearsal({ kind: "idle" }); setRehearsalOpen(false); setRehearsalInput(""); + setRehearsalPassword(""); } if (encrypted.shares && !isStale()) { // Straight to the modal. These exist exactly once — nothing can // reissue them — so they must not be left to be noticed. - setIssuedShares({ threshold: shamirThreshold, shares: encrypted.shares }); + setIssuedShares({ + threshold: shamirThreshold, + shares: encrypted.shares, + withPassword: sharesNeedPassword, + }); } // **Do not put an `await` between here and the delivery below.** // @@ -3066,11 +3090,17 @@ export function EncryptorTool() { to, kdf: kdfLabelOf(kdfChoice, argonMemoryMiB, argonTimeCost, argonParallelism), cipher: cipherLabelOf(cipherChoice), - waysIn: [ - useKeyFile && keyFile ? "Passphrase + key file" : "Passphrase", - ...(shamirEnabled ? [`${shamirThreshold}-of-${shamirCount} recovery shares`] : []), - ...(passkeyEnabled ? ["passkey"] : []), - ], + waysIn: + shamirEnabled && sharesNeedPassword + ? [ + `${useKeyFile && keyFile ? "Passphrase + key file" : "Passphrase"} and ` + + `${shamirThreshold}-of-${shamirCount} recovery shares, both needed`, + ] + : [ + useKeyFile && keyFile ? "Passphrase + key file" : "Passphrase", + ...(shamirEnabled ? [`${shamirThreshold}-of-${shamirCount} recovery shares`] : []), + ...(passkeyEnabled ? ["passkey"] : []), + ], bytes: resultBuffer.byteLength, onScreen, shares: shamirEnabled ? { threshold: shamirThreshold, count: shamirCount } : null, @@ -3229,6 +3259,21 @@ export function EncryptorTool() { prfOutput = await assertPasskeyPrf(await derivePrfSalt(salts[0]!)); } + // §4.8's MAY, taken: strips for a slot that also takes the password, + // with no password typed, can never open it. Said before any work, in + // the one sentence an heir needs, rather than after a derivation as + // "decryption failed" about strips that were never wrong. From the slot + // salts, which are in the clear, so it tells nobody anything new. + if (suppliedShares.length > 0 && !mutablePassword) { + const { sharesNeedPasswordKeym2 } = await import("@/lib/keym-v2"); + if (await sharesNeedPasswordKeym2(new Uint8Array(inputBuffer), suppliedShares)) { + throw new KeymakerError( + "credential-required", + "These strips open this backup together with its password. Type the password as well, then try again." + ); + } + } + if (isStale()) return; const decryptResult = await decryptViaWorker( inputBuffer, @@ -3466,7 +3511,7 @@ export function EncryptorTool() { // the render before the user touched any of them, so the share path took // the no-credential exit while every control leading to it looked live — // a click that did nothing at all, with no error to explain it. - }, [file, mode, keyFile, toast, inputType, textSecret, password, generated, kdfChoice, argonTimeCost, argonMemoryMiB, argonParallelism, cipherChoice, obscureFilename, isLoading, verifyOnly, useShares, shareInput, shamirEnabled, shamirThreshold, shamirCount, passkeyEnabled, usePasskey]); + }, [file, mode, keyFile, toast, inputType, textSecret, password, generated, kdfChoice, argonTimeCost, argonMemoryMiB, argonParallelism, cipherChoice, obscureFilename, isLoading, verifyOnly, useShares, shareInput, shamirEnabled, shamirThreshold, shamirCount, sharesNeedPassword, passkeyEnabled, usePasskey]); const handleUseKeyFileChange = useCallback((checked: boolean) => { setUseKeyFile(checked); @@ -4459,8 +4504,9 @@ export function EncryptorTool() {
    - {passkeyEnabled && ( + {passkeyEnabled && !(shamirEnabled && sharesNeedPassword) && (

    You will be asked to tap twice — once to create the passkey, once to use it. Some keys only produce what @@ -4607,11 +4653,48 @@ export function EncryptorTool() { share is as sensitive as the password itself. That belongs on the screen, not only in the docs. */} -

    - Each share is as sensitive as your password. Anyone holding{" "} - {shamirThreshold} of them opens this container without knowing it. - Store them apart, with people who would not combine them casually. -

    + {/* + §4.8. The other way to use strips: together with + the password rather than instead of it. The costs + §4.8 says a writer should state are stated here, + where the choice is made. + */} +
    +
    + { + setSharesNeedPassword(v); + if (v) setPasskeyEnabled(false); + }} + /> + +
    + {sharesNeedPassword ? ( +

    + Then neither opens it alone: it takes the password and{" "} + {shamirThreshold} strips together. A forgotten password, or fewer than{" "} + {shamirThreshold} strips, loses the backup for good, and nothing else can + stand in for either. Versions of Keymaker and keym2.py from before this + option cannot open it. No passkey can be added. +

    + ) : null} +
    + {sharesNeedPassword ? ( +

    + Keep the password and the strips in different hands. Anyone holding + the password and {shamirThreshold} strips opens this container. +

    + ) : ( +

    + Each share is as sensitive as your password. Anyone holding{" "} + {shamirThreshold} of them opens this container without knowing it. + Store them apart, with people who would not combine them casually. +

    + )} )}
    @@ -5313,7 +5396,14 @@ export function EncryptorTool() { const { dearmorKeym2 } = await import("@/lib/keym-v2"); // A copy: the worker takes ownership of the buffer it is handed. const container = dearmorKeym2(outputText).slice(); - const result = await decryptViaWorker(container.buffer as ArrayBuffer, "", null, strips); + // §4.8. Strips that need the password are rehearsed with it, the way an + // heir would have to open the backup. + const result = await decryptViaWorker( + container.buffer as ArrayBuffer, + issuedShares.withPassword ? rehearsalPassword : "", + null, + strips + ); // The plaintext exists on this thread for exactly this long. const bytes = result.data.byteLength; new Uint8Array(result.data).fill(0); @@ -5331,6 +5421,7 @@ export function EncryptorTool() { }); // The pasted strips have done their job; the ones above are still there. setRehearsalInput(""); + setRehearsalPassword(""); } catch { if (isStale()) return; setRehearsal({ @@ -5338,10 +5429,11 @@ export function EncryptorTool() { message: `These strips did not open the backup. Check each one against the sheet ` + `— a single wrong character is enough — and that at least ` + - `${issuedShares.threshold} of the ${issuedShares.shares.length} are here.`, + `${issuedShares.threshold} of the ${issuedShares.shares.length} are here` + + (issuedShares.withPassword ? `, and that the password is the one you set.` : `.`), }); } - }, [issuedShares, outputText, rehearsalInput]); + }, [issuedShares, outputText, rehearsalInput, rehearsalPassword]); /** * What the command bar offers, and when. @@ -5907,6 +5999,7 @@ export function EncryptorTool() { // and the encrypt-side Print button is still on the page. setRehearsalOpen(false); setRehearsalInput(""); + setRehearsalPassword(""); setRehearsalInputRejected(null); } }} @@ -5933,9 +6026,18 @@ export function EncryptorTool() { Save these {issuedShares?.shares.length} shares now - Any {issuedShares?.threshold} of them open this container without the - password. They are shown once and cannot be reissued — closing this - window loses them. + {issuedShares?.withPassword ? ( + <> + Any {issuedShares?.threshold} of them open this container together with the + password, and only with it. + + ) : ( + <> + Any {issuedShares?.threshold} of them open this container without the + password. + + )}{" "} + They are shown once and cannot be reissued. Closing this window loses them. @@ -5967,12 +6069,21 @@ export function EncryptorTool() { ))}
    -

    - Each share is as sensitive as your password. Store them in separate - places, with people who would not casually combine them. Anyone - holding {issuedShares?.threshold} of them and a copy of the backup - needs nothing else from you. -

    + {issuedShares?.withPassword ? ( +

    + Keep the strips apart from each other and from the password. Anyone + holding {issuedShares.threshold} of them, the password and a copy of the + backup opens it. With fewer strips, or without the password, nobody + does, you included. +

    + ) : ( +

    + Each share is as sensitive as your password. Store them in separate + places, with people who would not casually combine them. Anyone + holding {issuedShares?.threshold} of them and a copy of the backup + needs nothing else from you. +

    + )} {/* Self-contained strips. Offered only when the backup fits one @@ -5994,10 +6105,11 @@ export function EncryptorTool() {

    This backup is small enough to print on each strip. Then any{" "} - {issuedShares.threshold} strips open it on their own, with no sheet and no - file from you. That is also the cost: {issuedShares.threshold} holders who - get together need nothing else. Leave this off to keep the backup itself - with you. + {issuedShares.threshold} strips{issuedShares.withPassword ? " and the password" : ""} open it, + with no sheet and no file from you. That is also the cost:{" "} + {issuedShares.threshold} holders who get together + {issuedShares.withPassword ? " and have the password" : ""} need nothing else. Leave + this off to keep the backup itself with you.

    ) : null} @@ -6057,6 +6169,7 @@ export function EncryptorTool() { stripBackupPart: stripsCarryBackup && parts.length === 1 ? parts[0] : undefined, shares: issuedShares.shares, threshold: issuedShares.threshold, + sharesNeedPassword: issuedShares.withPassword ?? false, printedOn: new Date().toISOString().slice(0, 10), rehearsal: rehearsalStamp, }); @@ -6120,9 +6233,23 @@ export function EncryptorTool() {

    Do what an heir would do: pick any {issuedShares?.threshold} of the{" "} {issuedShares?.shares.length} strips above and paste them here, one per - line. The backup is opened with them alone — no password — and closed - again without showing anything. + line.{" "} + {issuedShares?.withPassword + ? "The backup is opened with them and the password, and closed again without showing anything." + : "The backup is opened with them alone — no password — and closed again without showing anything."}

    + {issuedShares?.withPassword ? ( + setRehearsalPassword(e.target.value)} + placeholder="The password, typed again" + aria-label="Password to rehearse with" + className="h-10 rounded-xl border-border bg-inset" + /> + ) : null}