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.
)}
+ {/*
+ The printed strips each carry a QR. Without this the only way
+ in from paper was retyping a ~140-character code per strip,
+ by the person least equipped to get one character right. Same
+ handler as the container scan, which routes by prefix, so a
+ container photo picked here still lands in its own box.
+ */}
+ {
+ const files = Array.from(e.target.files ?? []);
+ e.target.value = "";
+ void handleQrImageFiles(files);
+ }}
+ />
+
{(() => {
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() {
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() {
/>
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."}
python3 keym2.py decrypt --in vault.keym --out recovered{" "}
- — asks for the password, or use{" "}
- --share once per strip if you have {threshold ?? "k"} of
- them.
+ {sharesNeedPassword ? (
+ <>
+ — add --share once per strip, {threshold ?? "k"} of them;
+ it then asks for the password, which is needed as well.
+ >
+ ) : (
+ <>
+ — asks for the password, or use{" "}
+ --share once per strip if you have {threshold ?? "k"} of
+ them.
+ >
+ )}
diff --git a/src/lib/crypto-client.ts b/src/lib/crypto-client.ts
index 9c91a44..8d0dee3 100644
--- a/src/lib/crypto-client.ts
+++ b/src/lib/crypto-client.ts
@@ -22,6 +22,7 @@
import {
encryptContainer,
+ encryptContainerWithSharesRequired,
decryptData,
secureErase,
KeymakerError,
@@ -334,7 +335,7 @@ export async function encryptViaWorker(
password: string,
keyFile: ArrayBuffer | null,
options: KeymakerOptions,
- shamir?: { threshold: number; count: number },
+ shamir?: { threshold: number; count: number; withPassword?: boolean },
passkey?: { prfOutput: Uint8Array; salt: Uint8Array }
): Promise {
try {
@@ -359,7 +360,7 @@ async function encryptViaWorkerInner(
password: string,
keyFile: ArrayBuffer | null,
options: KeymakerOptions,
- shamir?: { threshold: number; count: number },
+ shamir?: { threshold: number; count: number; withPassword?: boolean },
passkey?: { prfOutput: Uint8Array; salt: Uint8Array }
): Promise {
const w = (await ready()) ? spawn() : null;
@@ -370,9 +371,24 @@ async function encryptViaWorkerInner(
// needs its own copy, taken before the call. Reading `keyFile` afterwards
// yields zeros, the slot key derives from the wrong material, and the
// enrolment fails with "Decryption failed." in the middle of an encryption.
+ // Not taken for §4.8's single-slot write, which returns before any
+ // enrolment could read it, and so before anything would erase it.
const keyFileForSlots =
- keyFile && (shamir || passkey) ? new Uint8Array(keyFile.slice(0)) : null;
+ keyFile && (shamir || passkey) && !shamir?.withPassword ? new Uint8Array(keyFile.slice(0)) : null;
try {
+ // §4.8, the same branch the worker takes: one slot that needs the
+ // password and the strips together, and nothing beside it.
+ if (shamir?.withPassword) {
+ if (passkey) {
+ throw new KeymakerError(
+ "invalid-input",
+ "A passkey would open this backup on its own, and it was set to need the password and the strips together."
+ );
+ }
+ return await encryptContainerWithSharesRequired(
+ data, password, keyFile, options, shamir.threshold, shamir.count
+ );
+ }
let out = await encryptContainer(data, password, keyFile, options);
let shares: string[] | undefined;
try {
diff --git a/src/lib/crypto-worker.ts b/src/lib/crypto-worker.ts
index bb22753..031ee18 100644
--- a/src/lib/crypto-worker.ts
+++ b/src/lib/crypto-worker.ts
@@ -34,6 +34,8 @@
import {
encryptContainer,
+ encryptContainerWithSharesRequired,
+ KeymakerError,
secureErase,
decryptData,
isUserFacingError,
@@ -66,7 +68,7 @@ export type CryptoRequest =
* alive in the page past the encrypt that should have cleared it, or
* asking the user to type it a second time.
*/
- shamir?: { threshold: number; count: number } | undefined;
+ shamir?: { threshold: number; count: number; withPassword?: boolean } | undefined;
/**
* §4.7. Obtained on the main thread, because `navigator.credentials` does
* not exist here — a Worker cannot tap a security key. The 32 bytes and
@@ -215,15 +217,32 @@ ctx.addEventListener("message", async (event: MessageEvent) => {
// and the enrolment fails with "Decryption failed." in the middle of an
// encryption.
const keyFileForSlots =
- req.keyFile && (req.shamir || req.passkey)
+ req.keyFile && (req.shamir || req.passkey) && !req.shamir?.withPassword
? new Uint8Array(req.keyFile.slice(0))
: null;
let out: ArrayBuffer;
let shares: string[] | undefined;
try {
- out = await encryptContainer(req.data, req.password, req.keyFile, req.options);
+ if (req.shamir?.withPassword) {
+ // §4.8. One slot that takes the password and the shares together, and
+ // nothing else: a passphrase or passkey slot beside it would open the
+ // backup with one half, which is what this choice rules out.
+ if (req.passkey) {
+ throw new KeymakerError(
+ "invalid-input",
+ "A passkey would open this backup on its own, and it was set to need the password and the strips together."
+ );
+ }
+ const written = await encryptContainerWithSharesRequired(
+ req.data, req.password, req.keyFile, req.options, req.shamir.threshold, req.shamir.count
+ );
+ out = written.data;
+ shares = written.shares;
+ } else {
+ out = await encryptContainer(req.data, req.password, req.keyFile, req.options);
+ }
- if (req.shamir) {
+ if (req.shamir && !req.shamir.withPassword) {
// §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.
diff --git a/src/lib/keym-v2.ts b/src/lib/keym-v2.ts
index 5b77ef0..3a2ee1b 100644
--- a/src/lib/keym-v2.ts
+++ b/src/lib/keym-v2.ts
@@ -209,6 +209,8 @@ const SLOT_TYPE_PASSPHRASE = 0x00;
export const KEYM2_SLOT_TYPE_PASSKEY = 0x01;
/** §4.6. Same 48-byte prefix; only the slot secret's origin differs. */
export const KEYM2_SLOT_TYPE_SHAMIR = 0x02;
+/** §4.8. A passphrase *and* a share set, both needed. Same 48-byte prefix. */
+export const KEYM2_SLOT_TYPE_BOTH = 0x03;
/**
* §3.2, HKDF-SHA-256 — added by §4.6, with no cost parameters on purpose.
@@ -232,6 +234,8 @@ function kdfIsLegalForSlotType(slotType: number, kdfId: number): boolean {
if (slotType === SLOT_TYPE_PASSPHRASE) return kdfId === KdfId.PBKDF2 || kdfId === KdfId.ARGON2ID;
if (slotType === KEYM2_SLOT_TYPE_SHAMIR) return kdfId === KEYM2_KDF_HKDF;
if (slotType === KEYM2_SLOT_TYPE_PASSKEY) return kdfId === KEYM2_KDF_HKDF;
+ // §4.8: the guessable half is a password, so exactly 0x00's KDFs.
+ if (slotType === KEYM2_SLOT_TYPE_BOTH) return kdfId === KdfId.PBKDF2 || kdfId === KdfId.ARGON2ID;
return false;
}
@@ -259,6 +263,8 @@ const INFO_SLOT_KEY = textEncoder.encode("keymaker.v2.slot-key");
// §4.7, the same argument a third time: a passphrase, a share secret and a PRF
// output are all 32 bytes and must not reach the same slot key.
const CTX_PASSKEY_INPUT = textEncoder.encode("keymaker.v2.passkey-input");
+// §4.8, a fourth domain string, for the slot that takes both.
+const CTX_BOTH_INPUT = textEncoder.encode("keymaker.v2.passphrase-and-shares");
// §4.7. The PRF salt is derived from slot_salt rather than stored. This differs
// from INFO_SLOT_KEY as hygiene rather than as a load-bearing separation: the
// two HKDF calls already take different IKMs, 32 bytes against 65.
@@ -534,7 +540,8 @@ export function parseKeym2Slot(record: Uint8Array): Keym2Slot | null {
if (
slotType !== SLOT_TYPE_PASSPHRASE &&
slotType !== KEYM2_SLOT_TYPE_SHAMIR &&
- slotType !== KEYM2_SLOT_TYPE_PASSKEY
+ slotType !== KEYM2_SLOT_TYPE_PASSKEY &&
+ slotType !== KEYM2_SLOT_TYPE_BOTH
) {
return null;
}
@@ -850,6 +857,55 @@ function buildPasskeyInput(prfOutput: Uint8Array): Uint8Array {
return lpConcat([CTX_PASSKEY_INPUT, prfOutput]);
}
+/**
+ * What a slot is opened with. One buffer for every slot type but §4.8's, which
+ * takes two: §4.1's kdf input, which the slot's KDF stretches, and the
+ * reconstructed share secret, which it does not.
+ */
+type SlotSecret = Uint8Array | { kdfInput: Uint8Array; shareSecret: Uint8Array };
+
+function eraseSlotSecret(secret: SlotSecret): void {
+ if (secret instanceof Uint8Array) {
+ secureErase(secret);
+ } else {
+ secureErase(secret.kdfInput);
+ secureErase(secret.shareSecret);
+ }
+}
+
+/**
+ * §4.8. The stretched passphrase and the share secret combined by HKDF, under
+ * their own domain string. The memory-hard cost is paid once, on the
+ * passphrase; the share secret is 32 CSPRNG bytes and needs none.
+ */
+async function deriveBothSlotKey(
+ kdfInput: Uint8Array,
+ shareSecret: Uint8Array,
+ salt: Uint8Array,
+ kdf: Keym2KdfParams
+): Promise {
+ if (kdf.kdf === KEYM2_KDF_HKDF) reject();
+ if (shareSecret.length !== MASTER_KEY_LEN) reject();
+ const passphraseKey = await deriveSlotKey(kdfInput, salt, kdf);
+ const bothInput = lpConcat([CTX_BOTH_INPUT, passphraseKey, shareSecret]);
+ try {
+ return await deriveSlotKey(bothInput, salt, { kdf: KEYM2_KDF_HKDF });
+ } finally {
+ secureErase(passphraseKey);
+ secureErase(bothInput);
+ }
+}
+
+/** §4.3 / §4.8. The slot key for a slot, from whichever secret it takes. */
+async function deriveSlotKeyFor(slot: Keym2Slot, secret: SlotSecret): Promise {
+ if (slot.slotType === KEYM2_SLOT_TYPE_BOTH) {
+ if (secret instanceof Uint8Array) reject();
+ return deriveBothSlotKey(secret.kdfInput, secret.shareSecret, slot.salt, slot.kdf);
+ }
+ if (!(secret instanceof Uint8Array)) reject();
+ return deriveSlotKey(secret, slot.salt, slot.kdf);
+}
+
/** §4.3. The slot key, from that slot's own KDF, salt and parameters. */
async function deriveSlotKey(kdfInput: Uint8Array, salt: Uint8Array, kdf: Keym2KdfParams): Promise {
if (kdf.kdf === KEYM2_KDF_HKDF) {
@@ -1168,7 +1224,7 @@ function loadShamir(): Promise {
* unchanged; only the question "what secret does this slot take" grew a second
* answer.
*/
-async function slotSecretFor(slot: Keym2Slot, secrets: Keym2Secrets): Promise {
+async function slotSecretFor(slot: Keym2Slot, secrets: Keym2Secrets): Promise {
if (slot.slotType === SLOT_TYPE_PASSPHRASE) {
if (secrets.password === undefined) return null;
return buildKdfInput(secrets.password, secrets.keyFile ?? null);
@@ -1196,6 +1252,26 @@ async function slotSecretFor(slot: Keym2Slot, secrets: Keym2Secrets): Promise {
+ // `finally`, not a trailing call: `kdfInput` holds the NFC password bytes and
+ // the key-file digest, and `deriveSlotKey` throws on reachable input — an
+ // Argon2id slot at §6's memory ceiling on a device that cannot allocate it,
+ // which the *decrypt* walk was specifically hardened against. On that path
+ // the buffer survived the throw. The unlock walk has always used `catch`
+ // here; the write paths were the inconsistency.
+ const kdfInput = await buildKdfInput(password, keyFile);
+ try {
+ return await deriveSlotKey(kdfInput, salt, options.kdf);
+ } finally {
+ secureErase(kdfInput);
+ }
+ });
+}
+
+/**
+ * A single-slot container: the core header, one slot built from `prefix` and
+ * the key `slotKey()` derives, and the payload under `masterKey`.
+ *
+ * Shared by the passphrase writer and §4.8's, which differ only in the slot's
+ * type byte and how its key is derived. Both prefixes go through the reader's
+ * own validators first.
+ */
+async function writeKeym2(
+ plaintext: Uint8Array,
+ cipher: CipherId,
+ masterKey: Uint8Array,
+ version: number,
+ containerId: Uint8Array,
+ prefix: Uint8Array,
+ slotKey: () => Promise
+): Promise {
+ if (version !== KEYM2_VERSION_V2 && version !== KEYM2_VERSION_V3) {
+ throw new KeymakerError("invalid-input", `KEYM: unknown container version ${version}.`);
+ }
+ const coreBytes = packCoreHeader(cipher, 0, version, containerId);
// Round-trip both through the reader's own validators. A writer that can emit
// a container its own parser rejects is a bug worth catching here rather than
@@ -1493,35 +1605,19 @@ export async function encryptKeym2WithExplicitSecrets(
// reason that has nothing to do with the header being wrong.
parseKeym2CoreHeader(concat([coreBytes, new Uint8Array(keym2SlotTableOffset(version) - coreBytes.length)]));
- if (parseKeym2Slot(concat([prefix, new Uint8Array(MASTER_KEY_LEN + tagOverheadFor(options.cipher))])) === null) {
+ if (parseKeym2Slot(concat([prefix, new Uint8Array(MASTER_KEY_LEN + tagOverheadFor(cipher))])) === null) {
throw new KeymakerError("invalid-input", "KEYM v2 refused to write a slot its own parser rejects.");
}
- // `finally`, not a trailing call: `kdfInput` holds the NFC password bytes and
- // the key-file digest, and `deriveSlotKey` throws on reachable input — an
- // Argon2id slot at §6's memory ceiling on a device that cannot allocate it,
- // which the *decrypt* walk was specifically hardened against. On that path
- // the buffer survived the throw. The unlock walk has always used `catch`
- // here; the write paths were the inconsistency.
- const kdfInput = await buildKdfInput(password, keyFile);
- let slotKey: Uint8Array;
- try {
- slotKey = await deriveSlotKey(kdfInput, salt, options.kdf);
- } finally {
- secureErase(kdfInput);
- }
-
+ const key = await slotKey();
let record: Uint8Array;
try {
- record = concat([
- prefix,
- await wrapMasterKey(options.cipher, slotKey, masterKey, concat([coreBytes, prefix])),
- ]);
+ record = concat([prefix, await wrapMasterKey(cipher, key, masterKey, concat([coreBytes, prefix]))]);
} finally {
- secureErase(slotKey);
+ secureErase(key);
}
- const keys = await payloadKeys(masterKey, options.cipher);
+ const keys = await payloadKeys(masterKey, cipher);
try {
const count = chunkCount(plaintext.length);
// v3 §3 puts the MAC between slot_count and the table. It is computed over
@@ -1533,7 +1629,7 @@ export async function encryptKeym2WithExplicitSecrets(
const parts: Uint8Array[] = [...head, record];
for (let i = 0; i < count; i++) {
const chunk = plaintext.subarray(i * KEYM2_CHUNK_SIZE, (i + 1) * KEYM2_CHUNK_SIZE);
- parts.push(await seal(options.cipher, keys, nonceFor(i, i === count - 1), chunk, coreBytes));
+ parts.push(await seal(cipher, keys, nonceFor(i, i === count - 1), chunk, coreBytes));
}
return concat(parts);
} finally {
@@ -1541,6 +1637,107 @@ export async function encryptKeym2WithExplicitSecrets(
}
}
+/**
+ * §4.8. A container whose only slot takes the passphrase **and** `k` of the
+ * `n` shares returned.
+ *
+ * §4.8 lets this slot stand alone, and this writes 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 the caller's to show before calling: a
+ * forgotten passphrase, or fewer than `k` strips, loses the backup.
+ *
+ * `explicit` pins the random inputs, for the conformance bridge only (§4.5).
+ */
+export async function encryptKeym2WithSharesRequired(
+ plaintext: Uint8Array,
+ password: string,
+ keyFile: Uint8Array | null,
+ options: Keym2Options,
+ threshold: number,
+ count: number,
+ version: number = KEYM2_VERSION,
+ explicit?: {
+ salt?: Uint8Array;
+ masterKey?: Uint8Array;
+ containerId?: Uint8Array;
+ shareSecret?: Uint8Array;
+ coefficients?: Uint8Array;
+ }
+): Promise {
+ if (!password) {
+ throw new KeymakerError("credential-required", "A password is required for encryption.");
+ }
+ validateKdfParams(options.kdf, "encrypt");
+ const { shamirSplit, shareSetIdV2, encodeShareV2, SHARE_VALUE_LEN } = await loadShamir();
+
+ const salt = explicit?.salt ?? crypto.getRandomValues(new Uint8Array(SALT_LEN));
+ const masterKey = explicit?.masterKey ?? crypto.getRandomValues(new Uint8Array(MASTER_KEY_LEN));
+ const containerId =
+ explicit?.containerId ??
+ (version === KEYM2_VERSION_V3 ? crypto.getRandomValues(new Uint8Array(CONTAINER_ID_LEN)) : EMPTY);
+ const shareSecret = explicit?.shareSecret ?? crypto.getRandomValues(new Uint8Array(SHARE_VALUE_LEN));
+ if (salt.length !== SALT_LEN || masterKey.length !== MASTER_KEY_LEN || shareSecret.length !== SHARE_VALUE_LEN) {
+ throw new KeymakerError("invalid-input", "KEYM v2 requires 32-byte salts and secrets.");
+ }
+
+ try {
+ // Split first: it validates k and n, and a refused split should cost
+ // nothing, not an Argon2id derivation.
+ const parts = shamirSplit(shareSecret, threshold, count, explicit?.coefficients);
+ const prefix = packSlotPrefix(options.kdf, keyFile ? SLOT_FLAG_KEYFILE : 0, salt, KEYM2_SLOT_TYPE_BOTH);
+ const container = await writeKeym2(plaintext, options.cipher, masterKey, version, containerId, prefix, async () => {
+ const kdfInput = await buildKdfInput(password, keyFile);
+ try {
+ return await deriveBothSlotKey(kdfInput, shareSecret, salt, options.kdf);
+ } finally {
+ secureErase(kdfInput);
+ }
+ });
+ const setId = await shareSetIdV2(salt);
+ const shares: string[] = [];
+ for (const part of parts) {
+ shares.push(await encodeShareV2({ setId, threshold, index: part.index, value: part.value }));
+ secureErase(part.value);
+ }
+ return { container, shares };
+ } finally {
+ if (!explicit?.shareSecret) secureErase(shareSecret);
+ if (!explicit?.masterKey) secureErase(masterKey);
+ }
+}
+
+/** Byte equality over public values (set ids), so no timing care is needed. */
+function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
+ return true;
+}
+
+/**
+ * §4.8's MAY: do these shares belong to a slot that also takes the password?
+ * From the slot salts, which are in the clear, so saying so tells the holder
+ * nothing anyone holding the container could not compute.
+ */
+export async function sharesNeedPasswordKeym2(data: Uint8Array, shares: string[]): Promise {
+ try {
+ const { decodeShareAny, shareSetIdV2 } = await loadShamir();
+ const core = parseKeym2CoreHeader(data);
+ const slotCount = data[keym2SlotCountOffset(core.version)] as number;
+ const width = keym2SlotLen(core.cipher);
+ const table = keym2SlotTableOffset(core.version);
+ const ids: Uint8Array[] = [];
+ for (const text of shares) ids.push((await decodeShareAny(text)).setId);
+ for (let i = 0; i < slotCount; i++) {
+ const slot = parseKeym2Slot(data.subarray(table + i * width, table + (i + 1) * width));
+ if (slot === null || slot.slotType !== KEYM2_SLOT_TYPE_BOTH) continue;
+ const wide = await shareSetIdV2(slot.salt);
+ if (ids.some((id) => sameBytes(wide.subarray(0, id.length), id))) return true;
+ }
+ } catch {
+ return false;
+ }
+ return false;
+}
export interface Keym2ShareSet {
container: Uint8Array;
@@ -2226,6 +2423,12 @@ export function inspectKeym2(
? "passkey / WebAuthn PRF (HKDF-SHA-256)"
: slot.slotType === KEYM2_SLOT_TYPE_SHAMIR
? "Shamir share set (HKDF-SHA-256)"
+ : slot.slotType === KEYM2_SLOT_TYPE_BOTH && slot.kdf.kdf !== KEYM2_KDF_HKDF
+ ? `password and share set, both needed (${
+ slot.kdf.kdf === KdfId.PBKDF2
+ ? `PBKDF2 ${slot.kdf.params.iterations.toLocaleString("en-US")} iters`
+ : `Argon2id ${Math.round(slot.kdf.params.memoryKiB / 1024)} MiB`
+ })`
: // Unreachable for the three types above, since §6 forbids a
// passphrase slot from declaring HKDF and the parser enforces it.
// Kept as the default for a *future* HKDF slot type, which should
diff --git a/src/lib/keymaker-crypto.ts b/src/lib/keymaker-crypto.ts
index 4d90142..9ebee0b 100644
--- a/src/lib/keymaker-crypto.ts
+++ b/src/lib/keymaker-crypto.ts
@@ -900,6 +900,57 @@ export async function encryptContainer(
}
}
+/**
+ * §4.8. A container whose only slot takes the password *and* `threshold` of
+ * the `count` shares returned, with `encryptContainer`'s validation and its
+ * key-file contract (the caller's buffer is zeroed once used).
+ *
+ * The worker and its no-worker fallback both call this, so a browser without a
+ * Worker writes the same kind of backup.
+ */
+export async function encryptContainerWithSharesRequired(
+ dataBuffer: ArrayBuffer,
+ password: string,
+ keyFileBuffer: ArrayBuffer | null,
+ options: KeymakerOptions,
+ threshold: number,
+ count: number
+): Promise<{ data: ArrayBuffer; shares: string[] }> {
+ validateCommon(dataBuffer, password, true);
+ if (!password) {
+ throw new KeymakerError("credential-required", "A password is required for encryption.");
+ }
+ if (!options || !options.kdf || options.cipher === undefined) {
+ throw new Error(
+ "encryptContainerWithSharesRequired requires explicit kdf and cipher options."
+ );
+ }
+ validateKdfParams(options.kdf, "encrypt");
+ try {
+ const { encryptKeym2WithSharesRequired } = await import("./keym-v2");
+ const { container, shares } = await encryptKeym2WithSharesRequired(
+ new Uint8Array(dataBuffer),
+ password,
+ keyFileBuffer ? new Uint8Array(keyFileBuffer) : null,
+ { kdf: options.kdf, cipher: options.cipher },
+ threshold,
+ count
+ );
+ return {
+ data: container.buffer.slice(container.byteOffset, container.byteOffset + container.byteLength) as ArrayBuffer,
+ shares,
+ };
+ } catch (error) {
+ if (isUserFacingError(error)) throw error;
+ if (error instanceof Error && /required|too (large|long)|invalid characters|not available|threshold|count/i.test(error.message)) {
+ throw error;
+ }
+ throw new Error("Encryption failed. Please try again.");
+ } finally {
+ if (keyFileBuffer) secureErase(keyFileBuffer);
+ }
+}
+
interface ParsedKeym {
kdf: KdfParams;
cipher: CipherId;
diff --git a/tests/browser/shamir-ui.spec.ts b/tests/browser/shamir-ui.spec.ts
index b5b6d4b..d3c99b6 100644
--- a/tests/browser/shamir-ui.spec.ts
+++ b/tests/browser/shamir-ui.spec.ts
@@ -522,3 +522,88 @@ test.describe("one unlock path at a time", () => {
await expect(visible(page.locator("#output-text"))).toHaveValue(SECRET, { timeout: 90_000 });
});
});
+
+/**
+ * §4.8, "the strips need the password too": one slot that takes both, and
+ * nothing beside it. What a person sees at each step has to match what the
+ * format now does: the choice states its cost, the passkey goes away, the
+ * strips alone are told they need the password rather than "decryption
+ * failed", the password alone fails, and the two together open it.
+ */
+test.describe("strips that need the password too", () => {
+ test("neither half opens it alone, and together they do", async ({ page }) => {
+ await page.goto("/");
+ await useTextMode(page);
+ await selectCrypto(page, "pbkdf2", "aes");
+ await enableShares(page, 2, 3);
+
+ const both = visible(page.getByRole("switch", { name: "The strips need the password too" }));
+ await expect(both).toHaveAttribute("aria-checked", "false");
+ await both.click();
+ await expect(both).toHaveAttribute("aria-checked", "true");
+ await expect(page.getByTestId("shares-need-password-cost")).toContainText(
+ "loses the backup for good"
+ );
+ const passkeySwitch = page.getByRole("switch", { name: "Passkey quick access" });
+ if ((await passkeySwitch.count()) > 0) await expect(passkeySwitch).toBeDisabled();
+
+ 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 });
+ await expect(page.getByText(/open this container together with the\s+password, and only with it/)).toBeVisible();
+ const shares = (await page.locator("p.font-mono").allTextContents()).filter((s) =>
+ s.startsWith("KMSHARE2:")
+ );
+ await page.getByRole("button", { name: "I have saved these shares" }).click();
+ const armored = await page.evaluate(
+ () => (document.querySelector("#output-text") as HTMLTextAreaElement).value
+ );
+ await expect(page.getByTestId("receipt-ways")).toContainText("both needed");
+
+ // The strips alone: named for what they are, before any work.
+ await visible(page.getByRole("tab", { name: "Decrypt" })).click();
+ await useTextMode(page);
+ await visible(page.getByPlaceholder("Enter text to decrypt")).fill(armored);
+ await visible(page.getByRole("button", { name: /^Use recovery shares$/ })).click();
+ await visible(page.locator("#share-input")).fill(`${shares[0]}\n${shares[2]}`);
+ await visible(page.getByRole("button", { name: /^Decrypt Text$/i })).click();
+ await expect(
+ page.getByText(/These strips open this backup together with its password/).first()
+ ).toBeVisible({ timeout: 30_000 });
+ // A refused unlock renders no output at all.
+ await expect(page.locator("#output-text")).toHaveCount(0);
+
+ // Both together.
+ await visible(page.getByPlaceholder("Enter decryption password")).fill(PASSWORD);
+ await visible(page.getByRole("button", { name: /^Decrypt Text$/i })).click();
+ await expect(visible(page.locator("#output-text"))).toHaveValue(SECRET, { timeout: 90_000 });
+ });
+
+ test("the password alone does not open it", async ({ page }) => {
+ await page.goto("/");
+ await useTextMode(page);
+ await selectCrypto(page, "pbkdf2", "aes");
+ await enableShares(page, 2, 3);
+ await visible(page.getByRole("switch", { name: "The strips need the password too" })).click();
+ 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 });
+ await page.getByRole("button", { name: "I have saved these shares" }).click();
+ const armored = await page.evaluate(
+ () => (document.querySelector("#output-text") as HTMLTextAreaElement).value
+ );
+
+ await visible(page.getByRole("tab", { name: "Decrypt" })).click();
+ await useTextMode(page);
+ await visible(page.getByPlaceholder("Enter text to decrypt")).fill(armored);
+ await visible(page.getByPlaceholder("Enter decryption password")).fill(PASSWORD);
+ await visible(page.getByRole("button", { name: /^Decrypt Text$/i })).click();
+ await expect(page.getByText(/decryption failed|could not be decrypted|password may be incorrect/i).first()).toBeVisible({
+ timeout: 90_000,
+ });
+ // A refused unlock renders no output at all.
+ await expect(page.locator("#output-text")).toHaveCount(0);
+ });
+});
From c6f0341ebbffd92cd8d7dfb460d31baa3458b4e7 Mon Sep 17 00:00:00 2001
From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com>
Date: Wed, 23 Sep 2026 11:44:12 +0000
Subject: [PATCH 25/34] fix(ci): pin the Python 3.10 closure instead of
installing with --no-deps
reference/conformance-requirements.txt was resolved for 3.12 only. On
3.10 cryptography also requires typing-extensions (its marker is
python_full_version < "3.11"), which the file did not pin, so the
reference-python-floor job could only install it with --no-deps.
scripts/pin-conformance-deps.py now resolves for both 3.12 and 3.10 and
merges the closures, refusing if a package resolves to different
versions on the two. Two defects in its resolution, found by running it:
- pip's --python-version does not set python_full_version for marker
evaluation, so typing-extensions never appeared. Each wheel's own
Requires-Dist is now evaluated against the target interpreter and
anything missing is downloaded in another round.
- It resolved for manylinux_2_17 only. argon2-cffi-bindings 26.1.0, the
version CI installs, publishes manylinux_2_26/2_28 wheels only, so a
regenerate silently fell back to 21.2.0. It now accepts every
manylinux tag down to 2014.
Regenerated: the same versions as before, plus typing-extensions 4.16.0.
The 3.10 job drops --no-deps. --check still passes, and a hash-checked
dry-run install succeeds on 3.11; 3.10 itself is only available in CI.
---
.github/workflows/conformance.yml | 16 ++--
CHANGELOG.md | 6 +-
reference/conformance-requirements.txt | 3 +
scripts/pin-conformance-deps.py | 121 +++++++++++++++++++++----
4 files changed, 116 insertions(+), 30 deletions(-)
diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml
index 29221c6..7520e75 100644
--- a/.github/workflows/conformance.yml
+++ b/.github/workflows/conformance.yml
@@ -123,16 +123,14 @@ jobs:
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.
+ # Hash-checked, with dependencies resolved as on any other install. On
+ # 3.10 cryptography also needs typing-extensions (it declares it for
+ # python_full_version < '3.11'); scripts/pin-conformance-deps.py now
+ # resolves the closure for 3.10 as well as 3.12, so it is pinned. This
+ # job used --no-deps until it was, which installed the pinned bytes but
+ # could not tell a missing dependency from a present one.
- name: Install the pinned reference dependencies
- run: pip install --require-hashes --no-deps -r reference/conformance-requirements.txt
+ run: pip install --require-hashes -r reference/conformance-requirements.txt
- name: Reference self-test (KEYM v1) on Python 3.10
run: python3 reference/keym.py selftest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c18bd4..682d7cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -168,7 +168,11 @@ v2.2.0 wrote reads exactly as before, the existing fixture corpus is unchanged
2 and Step 4 comments now say `v3 or v2`.
- **Build and release:** a checkout path with spaces builds; deploy and release
refuse to sign bytes the independent builds did not reproduce; the reference
- self-tests run on Python 3.10.
+ self-tests run on Python 3.10, now from a hash-pinned closure resolved for
+ 3.10 as well as 3.12 (it adds typing-extensions) rather than with `--no-deps`.
+ `pin-conformance-deps.py` also stopped resolving for `manylinux_2_17` alone,
+ which cannot see the argon2-cffi-bindings wheels CI installs and would have
+ downgraded them on the next regenerate.
## Keymaker v2.2.0
diff --git a/reference/conformance-requirements.txt b/reference/conformance-requirements.txt
index 9328b73..bb265b2 100644
--- a/reference/conformance-requirements.txt
+++ b/reference/conformance-requirements.txt
@@ -228,3 +228,6 @@ cryptography==50.0.1 \
pycparser==3.0 \
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
+typing-extensions==4.16.0 \
+ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
+ --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
diff --git a/scripts/pin-conformance-deps.py b/scripts/pin-conformance-deps.py
index a64f972..e3e0e99 100755
--- a/scripts/pin-conformance-deps.py
+++ b/scripts/pin-conformance-deps.py
@@ -13,7 +13,7 @@
python3 scripts/pin-conformance-deps.py # regenerate (needs a network)
python3 scripts/pin-conformance-deps.py --check # CI gate: have the two drifted?
-It resolves the full transitive closure against the interpreter CI uses, then
+It resolves the full transitive closure for each interpreter CI uses, then
records every sha256 PyPI publishes for each pinned version, so the hash matches
whichever wheel the runner selects. Needs a network; writes nothing on failure.
"""
@@ -25,17 +25,34 @@
import sys
import tempfile
import urllib.request
+import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DIRECT = ROOT / "reference" / "requirements.txt"
OUT = ROOT / "reference" / "conformance-requirements.txt"
-# The runner the conformance job uses. Kept beside the workflow's own values on
-# purpose: resolving against this container's interpreter instead would pin a
-# closure CI never installs.
-PY_VERSION = "3.12"
-PLATFORM = "manylinux_2_17_x86_64"
+# The interpreters the conformance workflow installs this file on: 3.12 for the
+# conformance job, and 3.10 for `reference-python-floor`, the RECOVERY.md floor.
+# Kept beside the workflow's own values on purpose: resolving against this
+# container's interpreter instead would pin a closure CI never installs.
+#
+# Both, and merged, because the closures differ. On 3.10 cryptography also
+# needs typing-extensions (it declares it for python_full_version < '3.11'), so
+# a file resolved for 3.12 alone could only be installed on 3.10 with
+# --no-deps, which is how the floor job had to run.
+PY_VERSIONS = ("3.12", "3.10")
+
+# Every manylinux tag an ubuntu-latest runner accepts that a pinned wheel is
+# published under, newest first. This was manylinux_2_17 alone, which cannot
+# resolve argon2-cffi-bindings 26.1.0 (published for manylinux_2_26/2_28 only):
+# regenerating quietly fell back to 21.2.0, a version CI had never run.
+PLATFORMS = (
+ "manylinux_2_28_x86_64",
+ "manylinux_2_26_x86_64",
+ "manylinux_2_17_x86_64",
+ "manylinux2014_x86_64",
+)
PIN_RE = re.compile(r"^([A-Za-z0-9._-]+)==([^\s;#]+)")
@@ -55,21 +72,85 @@ def direct_pins() -> list[tuple[str, str]]:
return pins
-def closure(pins: list[tuple[str, str]]) -> list[tuple[str, str]]:
- """Every package pip would install, resolved for the CI runner."""
+def _canon(name: str) -> str:
+ return re.sub(r"[-_.]+", "-", name).lower()
+
+
+def _requires(whl: Path) -> list[str]:
+ """A wheel's Requires-Dist lines, read from its own METADATA."""
+ with zipfile.ZipFile(whl) as z:
+ meta = next(n for n in z.namelist() if n.endswith(".dist-info/METADATA"))
+ text = z.read(meta).decode("utf-8", "replace")
+ return [line.split(":", 1)[1].strip() for line in text.splitlines()
+ if line.startswith("Requires-Dist:")]
+
+
+def closure_for(pins: list[tuple[str, str]], py_version: str) -> dict[str, str]:
+ """
+ Every package pip would install on one of the CI interpreters.
+
+ pip's --python-version sets `python_version` for marker evaluation but not
+ `python_full_version`, which is the one cryptography uses to require
+ typing-extensions below 3.11. Resolved from this machine's interpreter, that
+ dependency never appeared. So each wheel's own requirements are evaluated
+ here against the target interpreter, and anything missing is downloaded in
+ another round, until nothing is.
+ """
+ from pip._vendor.packaging.requirements import Requirement
+
+ env = {
+ "python_version": py_version,
+ "python_full_version": f"{py_version}.0",
+ "implementation_name": "cpython",
+ "platform_python_implementation": "CPython",
+ "sys_platform": "linux",
+ "platform_system": "Linux",
+ "platform_machine": "x86_64",
+ "os_name": "posix",
+ "extra": "",
+ }
+ wanted = [f"{n}=={v}" for n, v in pins]
with tempfile.TemporaryDirectory() as d:
- subprocess.run(
- [sys.executable, "-m", "pip", "download", "--dest", d,
- "--python-version", PY_VERSION, "--only-binary=:all:",
- "--platform", PLATFORM,
- *[f"{n}=={v}" for n, v in pins]],
- check=True, stdout=subprocess.DEVNULL,
- )
- found = {}
- for whl in sorted(Path(d).glob("*.whl")):
- name, version = whl.name.split("-")[:2]
- found[name.replace("_", "-").lower()] = version
- return sorted(found.items())
+ while True:
+ platform_args = [a for p in PLATFORMS for a in ("--platform", p)]
+ subprocess.run(
+ [sys.executable, "-m", "pip", "download", "--dest", d,
+ "--python-version", py_version, "--only-binary=:all:",
+ *platform_args, *wanted],
+ check=True, stdout=subprocess.DEVNULL,
+ )
+ wheels = sorted(Path(d).glob("*.whl"))
+ found = {_canon(w.name.split("-")[0]): w.name.split("-")[1] for w in wheels}
+ missing = []
+ for whl in wheels:
+ for line in _requires(whl):
+ req = Requirement(line)
+ if req.marker is not None and not req.marker.evaluate(env):
+ continue
+ if _canon(req.name) not in found:
+ missing.append(f"{req.name}{req.specifier}")
+ if not missing:
+ return found
+ wanted += sorted(set(missing))
+
+
+def closure(pins: list[tuple[str, str]]) -> list[tuple[str, str]]:
+ """
+ The union of every CI interpreter's closure.
+
+ A package both need must resolve to the same version on both, or one file
+ cannot serve both jobs; that is refused rather than papered over, since
+ picking either version would install something one job never resolved.
+ """
+ merged: dict[str, str] = {}
+ for py_version in PY_VERSIONS:
+ for name, version in closure_for(pins, py_version).items():
+ if merged.setdefault(name, version) != version:
+ raise SystemExit(
+ f"{name} resolves to {merged[name]} on one CI interpreter and "
+ f"{version} on Python {py_version}; one pinned file cannot serve both."
+ )
+ return sorted(merged.items())
def hashes(name: str, version: str) -> list[str]:
From 61f371c19db29644abe971f6aaddb24079a0d469 Mon Sep 17 00:00:00 2001
From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com>
Date: Thu, 24 Sep 2026 07:39:45 +0000
Subject: [PATCH 26/34] fix(self-extract): forget earlier results, and give the
right refusal reason
The page's decryptor (DECRYPTOR_JS):
- A failed attempt hid #result but left the plaintext in the hidden #out.
- A binary result left the previous text in #out.
- The save link's blob URL was never revoked.
Each submit now runs forget() first, which clears #out, revokes the URL and
resets the link. The password input uses autocomplete="off" instead of
current-password, so the heir's machine is not asked to keep the backup's
password. SELF_EXTRACT_SCRIPT_SHA256 is recomputed. keym2.py emits only the
sentinel block, so parity is unaffected, and the frozen fixture page keeps
its own copy of the old decryptor.
webcryptoProfileViolations:
- Every non-AES cipher was called ChaCha20-Poly1305, chained mode included.
Chained is now named as chained.
- A container with no passphrase slot at all (shares only, passkey only) was
told "the password slot uses Argon2id". It is now told it has no password
slot, and what it opens with.
Tests:
- self-extract.spec.ts "nothing an earlier attempt recovered outlives the
next one": builds a text and a binary page through the bridge, then checks
#out after a failed attempt and after a binary result, the revoked URL, and
the autocomplete value. Four controls, each with the hash recomputed so
the patched page's script still runs, each fails on its own assertion:
autocomplete back, forget() keeping #out, no revoke, and #out cleared only
on failure (the binary case).
- keymaker-regression.mts section 9: chained, ChaCha, Argon2id, passkey-only
and share-only reasons. With the old function, the chained, passkey-only
and share-only checks fail.
---
scripts/keymaker-regression.mts | 80 +++++++++++++++++++++++++
src/lib/keym-v2-selfextract.ts | 79 ++++++++++++++++++++++---
tests/browser/self-extract.spec.ts | 94 ++++++++++++++++++++++++++++++
3 files changed, 245 insertions(+), 8 deletions(-)
diff --git a/scripts/keymaker-regression.mts b/scripts/keymaker-regression.mts
index ade4cb6..0f645f4 100644
--- a/scripts/keymaker-regression.mts
+++ b/scripts/keymaker-regression.mts
@@ -1102,6 +1102,86 @@ async function main() {
);
}
+ // ---- 9. §7.2's refusal names what the container actually uses ----
+ //
+ // webcryptoProfileViolations is shown to the owner as the reason their backup
+ // cannot become a page. It used to call every non-AES cipher ChaCha20-Poly1305,
+ // chained mode included, and to tell a backup with no password slot at all
+ // that its "password slot uses Argon2id". Both sent the owner looking for a
+ // setting they had not chosen.
+ {
+ console.log("\n9. The self-extract refusal describes this container");
+ const { webcryptoProfileViolations } = await import("../src/lib/keym-v2-selfextract.ts");
+ const {
+ encryptKeym2,
+ addPasskeySlotKeym2,
+ addShamirSlotKeym2,
+ keym2SlotLen,
+ KEYM2_VERSION_V2,
+ } = await import("../src/lib/keym-v2.ts");
+ const pt = enc.encode("self-extract reasons");
+ const secrets = { password: PASSWORD, keyFile: null };
+
+ // v2 carries no slot_table_mac, so dropping slot 0 leaves a container whose
+ // only way in is the slot that was added after it. That is the shape a
+ // share-only or passkey-only backup has; this function reads structure only.
+ const withoutSlot0 = (c: Uint8Array, cipher: CipherId): Uint8Array => {
+ const TABLE = 9;
+ const w = keym2SlotLen(cipher);
+ const out = new Uint8Array(c.length - w);
+ out.set(c.subarray(0, TABLE));
+ out.set(c.subarray(TABLE + w), TABLE);
+ out[8] = (c[8] as number) - 1;
+ return out;
+ };
+ const aes = { kdf: PBKDF2_FAST, cipher: CipherId.AES_256_GCM };
+ const pbkdf2Aes = await encryptKeym2(pt, PASSWORD, null, aes, KEYM2_VERSION_V2);
+
+ const accepted = webcryptoProfileViolations(pbkdf2Aes);
+ check(accepted.length === 0, `a PBKDF2/AES backup is inside the subset (got ${JSON.stringify(accepted)})`);
+
+ const chained = webcryptoProfileViolations(
+ await encryptKeym2(pt, PASSWORD, null, { kdf: PBKDF2_FAST, cipher: CipherId.CHAINED }, KEYM2_VERSION_V2)
+ );
+ check(
+ chained.length === 1 && /chained/i.test(chained[0]!),
+ `a chained backup is called chained (got ${JSON.stringify(chained)})`
+ );
+
+ const chacha = webcryptoProfileViolations(
+ await encryptKeym2(pt, PASSWORD, null, { kdf: PBKDF2_FAST, cipher: CipherId.CHACHA20_POLY1305 }, KEYM2_VERSION_V2)
+ );
+ check(
+ chacha.length === 1 && /ChaCha20-Poly1305/.test(chacha[0]!) && !/chained/i.test(chacha[0]!),
+ `a ChaCha20-Poly1305 backup is called that, not chained (got ${JSON.stringify(chacha)})`
+ );
+
+ const argon = webcryptoProfileViolations(
+ await encryptKeym2(pt, PASSWORD, null, { kdf: ARGON_FAST, cipher: CipherId.AES_256_GCM }, KEYM2_VERSION_V2)
+ );
+ check(
+ argon.length === 1 && /Argon2id/.test(argon[0]!),
+ `an Argon2id password still names Argon2id (got ${JSON.stringify(argon)})`
+ );
+
+ const prf = new Uint8Array(32).fill(7);
+ const prfSalt = new Uint8Array(32).fill(9);
+ const passkeyOnly = webcryptoProfileViolations(
+ withoutSlot0(await addPasskeySlotKeym2(pbkdf2Aes, secrets, prf, prfSalt), CipherId.AES_256_GCM)
+ );
+ check(
+ passkeyOnly.length === 1 && !/Argon2id/.test(passkeyOnly[0]!) && /passkey/.test(passkeyOnly[0]!),
+ `a passkey-only backup is not told its password uses Argon2id (got ${JSON.stringify(passkeyOnly)})`
+ );
+
+ const { container: withShares } = await addShamirSlotKeym2(pbkdf2Aes, secrets, 2, 3);
+ const sharesOnly = webcryptoProfileViolations(withoutSlot0(withShares, CipherId.AES_256_GCM));
+ check(
+ sharesOnly.length === 1 && !/Argon2id/.test(sharesOnly[0]!) && /shares/.test(sharesOnly[0]!),
+ `a share-only backup is not told its password uses Argon2id (got ${JSON.stringify(sharesOnly)})`
+ );
+ }
+
// ---- Summary ----
console.log(`\n${passed} passed, ${failures} failed`);
if (failures > 0) {
diff --git a/src/lib/keym-v2-selfextract.ts b/src/lib/keym-v2-selfextract.ts
index 80d16ba..91d20f9 100644
--- a/src/lib/keym-v2-selfextract.ts
+++ b/src/lib/keym-v2-selfextract.ts
@@ -21,6 +21,9 @@
import {
KEYM2_ARMOR_PREFIX,
KEYM2_CORE_HEADER_LEN,
+ KEYM2_SLOT_TYPE_BOTH,
+ KEYM2_SLOT_TYPE_PASSKEY,
+ KEYM2_SLOT_TYPE_SHAMIR,
armorKeym2,
dearmorKeym2,
keym2SlotLen,
@@ -74,7 +77,16 @@ export function webcryptoProfileViolations(container: Uint8Array): string[] {
return ["this is not a KEYM v2 container"];
}
- if (core.cipher !== CipherId.AES_256_GCM) {
+ // Named for what the container actually uses. This once said ChaCha20-Poly1305
+ // for every cipher that was not AES, chained mode included, which sent the
+ // reader looking for a setting they had not chosen.
+ if (core.cipher === CipherId.CHAINED) {
+ reasons.push(
+ "the payload is encrypted in chained mode, AES-256-GCM and then " +
+ "ChaCha20-Poly1305. WebCrypto has never had ChaCha20-Poly1305 and no " +
+ "proposal adds it, so a self-extracting page can only carry AES-256-GCM alone."
+ );
+ } else if (core.cipher !== CipherId.AES_256_GCM) {
reasons.push(
"the payload is encrypted with ChaCha20-Poly1305, which WebCrypto has " +
"never had and no proposal adds. A self-extracting page can only carry AES-256-GCM."
@@ -86,6 +98,12 @@ export function webcryptoProfileViolations(container: Uint8Array): string[] {
const width = keym2SlotLen(core.cipher);
let usable = false;
let keyFileOnly = false;
+ let argon2Password = false;
+ // The ways in that are not a password alone, in the order they are found.
+ const otherWays: string[] = [];
+ const note = (way: string) => {
+ if (!otherWays.includes(way)) otherWays.push(way);
+ };
for (let j = 0; j < slotCount; j++) {
const start = table + j * width;
@@ -95,8 +113,23 @@ export function webcryptoProfileViolations(container: Uint8Array): string[] {
// the container. The question is only whether *some* slot is in the subset.
const slot = parseKeym2Slot(record);
if (!slot) continue;
+ if (slot.slotType === KEYM2_SLOT_TYPE_PASSKEY) {
+ note("a passkey");
+ continue;
+ }
+ if (slot.slotType === KEYM2_SLOT_TYPE_SHAMIR) {
+ note("a set of shares");
+ continue;
+ }
+ if (slot.slotType === KEYM2_SLOT_TYPE_BOTH) {
+ note("a password together with shares");
+ continue;
+ }
if (slot.slotType !== 0x00) continue;
- if (slot.kdf.kdf !== KdfId.PBKDF2) continue;
+ if (slot.kdf.kdf !== KdfId.PBKDF2) {
+ argon2Password = true;
+ continue;
+ }
if (slot.keyFileUsed) {
keyFileOnly = true;
continue;
@@ -111,11 +144,20 @@ export function webcryptoProfileViolations(container: Uint8Array): string[] {
"one file, which is exactly what a key file exists to prevent; leaving " +
"it out would write a weaker backup than you think you have."
);
- } else {
+ } else if (argon2Password) {
reasons.push(
"the password slot uses Argon2id, which needs WebAssembly. A page that " +
"still works in twenty years cannot depend on it."
);
+ } else {
+ // A share-only or passkey-only backup has no password slot at all, and
+ // telling its owner their password uses Argon2id describes a setting
+ // that does not exist.
+ const ways = otherWays.length ? otherWays.join(" or ") : "no method this page understands";
+ reasons.push(
+ `this backup has no password slot. It opens with ${ways}, and the page ` +
+ "can only ask for a password, so it needs a PBKDF2 password slot."
+ );
}
}
return reasons;
@@ -214,7 +256,7 @@ export function looksLikeSelfExtract(text: string): boolean {
* reason the app does: hashing a stylesheet buys nothing when no untrusted
* style can reach the document.
*/
-const SELF_EXTRACT_SCRIPT_SHA256 = "sha256-inEHJqi1e61OpR8s6dLFBcrboDqwxu81fUCR/fn76v4=";
+const SELF_EXTRACT_SCRIPT_SHA256 = "sha256-w5DyUTkawgwIa/55Xv/mJyxzZXVehfLAkhGPDPFhJVw=";
const SELF_EXTRACT_CSP = [
"default-src 'none'",
@@ -429,6 +471,23 @@ function say(text, bad) {
statusEl.className = bad ? 'bad' : 'busy';
}
+// The blob URL behind the save link. It keeps the recovered bytes reachable
+// for as long as it exists, so it is revoked rather than left to page unload.
+var savedUrl = null;
+
+// Nothing an earlier attempt recovered outlives the next one. Hiding the result
+// is not enough: a hidden textarea still holds its value, a binary result would
+// otherwise leave the previous text in it, and an unrevoked blob URL still
+// serves the plaintext to anything that has it.
+function forget() {
+ resultEl.hidden = true;
+ tableEl.hidden = true;
+ outEl.value = '';
+ if (savedUrl !== null) URL.revokeObjectURL(savedUrl);
+ savedUrl = null;
+ saveEl.setAttribute('href', '#');
+}
+
formEl.addEventListener('submit', async function (e) {
e.preventDefault();
if (!crypto || !crypto.subtle) {
@@ -436,8 +495,7 @@ formEl.addEventListener('submit', async function (e) {
'or use keym2.py -- the backup text is in this file either way.', true);
return;
}
- resultEl.hidden = true;
- tableEl.hidden = true;
+ forget();
goEl.disabled = true;
say('Working. This takes a few seconds by design.');
// Yield once so the browser paints the line above before PBKDF2 blocks it.
@@ -463,7 +521,8 @@ formEl.addEventListener('submit', async function (e) {
// MAC covers the table whole and cannot say.
tableEl.hidden = (opened.slotTableAuthentic !== false);
var blob = new Blob([plain], { type: 'application/octet-stream' });
- saveEl.href = URL.createObjectURL(blob);
+ savedUrl = URL.createObjectURL(blob);
+ saveEl.href = savedUrl;
saveEl.download = 'recovered.bin';
pwEl.value = '';
} catch (err) {
@@ -500,6 +559,10 @@ export interface SelfExtractOptions {
* A page whose job is to still work in 2040 does not get a framework, a font
* download or a build step — every one of those is a thing that can stop
* resolving while the file sits in a drawer.
+ *
+ * The password field says `autocomplete="off"`. The page is opened on whatever
+ * machine the heir has to hand, and `current-password` asks that machine's
+ * password manager to keep the backup's password afterwards.
*/
export function buildSelfExtractingPage(options: SelfExtractOptions): string {
const { container, createdOn, appVersion } = options;
@@ -551,7 +614,7 @@ disconnect from the network first if you like, and it will behave identically.
diff --git a/tests/browser/self-extract.spec.ts b/tests/browser/self-extract.spec.ts
index 94d64cd..dc04c5d 100644
--- a/tests/browser/self-extract.spec.ts
+++ b/tests/browser/self-extract.spec.ts
@@ -352,4 +352,98 @@ test.describe("§7.2 self-extracting page", () => {
expect(await offline.isHidden("#result")).toBe(true);
await offline.close();
});
+
+ test("nothing an earlier attempt recovered outlives the next one", async ({
+ context,
+ }, testInfo) => {
+ // Hiding #result is what the tests above check, and it is not the same as
+ // forgetting: a hidden textarea keeps its value, a binary result used to
+ // leave the previous text in it, and a blob URL nobody revokes keeps
+ // serving the plaintext. Each assertion below is the one that fails when
+ // its line in forget() is removed.
+ const textPt = testInfo.outputPath("forget-text.txt");
+ const textC = testInfo.outputPath("forget-text.keym2");
+ const textPage = testInfo.outputPath("forget-text.html");
+ writeFileSync(textPt, SECRET, "utf8");
+ bridge("encrypt2", "--password", STRONG_PASSWORD, "--in", textPt, "--out", textC,
+ "--cipher", "aes", "--salt", SE_SALT, "--master-key", SE_MASTER_KEY,
+ "--kdf", "pbkdf2", "--iterations", "600000");
+ bridge("selfextract", "--in", textC, "--out", textPage);
+
+ // Not valid UTF-8, so the page shows no text for it and offers the file only.
+ const binPt = testInfo.outputPath("forget-bin.bin");
+ const binC = testInfo.outputPath("forget-bin.keym2");
+ const binPage = testInfo.outputPath("forget-bin.html");
+ writeFileSync(binPt, Buffer.from([0xff, 0xfe, 0x00, 0x80, 0xc3, 0x28, 0x01]));
+ bridge("encrypt2", "--password", STRONG_PASSWORD, "--in", binPt, "--out", binC,
+ "--cipher", "aes", "--salt", SE_PK_SALT, "--master-key", SE_PK_PRF,
+ "--kdf", "pbkdf2", "--iterations", "600000");
+ bridge("selfextract", "--in", binC, "--out", binPage);
+ const binArmor = (readFileSync(binPage, "utf8")
+ .match(/
([\s\S]*?)<\/pre>/)?.[1] ?? "")
+ .replace(//g, "");
+ expect(binArmor.trim().startsWith("keym2:"), "could not read the binary page's armor").toBe(true);
+
+ const offline = await context.newPage();
+ const pageErrors: string[] = [];
+ offline.on("pageerror", (e) => pageErrors.push(e.message));
+ await offline.addInitScript(() => {
+ const w = window as unknown as { __revoked: string[] };
+ w.__revoked = [];
+ const revoke = URL.revokeObjectURL.bind(URL);
+ URL.revokeObjectURL = (u: string) => {
+ w.__revoked.push(u);
+ revoke(u);
+ };
+ });
+ await offline.goto(`file://${textPage}`);
+
+ // The backup's password is not something the heir's machine should keep.
+ expect(await offline.getAttribute("#pw", "autocomplete")).toBe("off");
+
+ const open = async () => {
+ await offline.fill("#pw", STRONG_PASSWORD);
+ await offline.click("#go");
+ await offline.waitForSelector("#result:not([hidden])", { timeout: 90_000 });
+ };
+ const refuse = async () => {
+ await offline.fill("#pw", "not the password");
+ await offline.click("#go");
+ await offline.waitForFunction(
+ () => document.querySelector("#status")?.className === "bad",
+ null,
+ { timeout: 90_000 }
+ );
+ };
+ const revoked = () =>
+ offline.evaluate(() => (window as unknown as { __revoked: string[] }).__revoked);
+
+ await open();
+ expect(await offline.inputValue("#out")).toBe(SECRET);
+ const firstUrl = (await offline.getAttribute("#save", "href")) ?? "";
+ expect(firstUrl.startsWith("blob:"), `the save link is ${firstUrl}`).toBe(true);
+
+ await refuse();
+ expect(await offline.isHidden("#result")).toBe(true);
+ expect(await offline.inputValue("#out"), "a failed attempt left the plaintext in #out").toBe("");
+ expect(await revoked(), "the earlier blob URL was never revoked").toContain(firstUrl);
+ expect(await offline.getAttribute("#save", "href")).toBe("#");
+
+ // Text again, then a binary result: the binary one must not show, or keep,
+ // the text before it.
+ await open();
+ expect(await offline.inputValue("#out")).toBe(SECRET);
+ const secondUrl = (await offline.getAttribute("#save", "href")) ?? "";
+ await offline.evaluate((armor) => {
+ document.getElementById("keym2-container")!.textContent = armor;
+ }, binArmor);
+ await open();
+ expect(await offline.isHidden("#out")).toBe(true);
+ expect(await offline.inputValue("#out"), "a binary result kept the earlier text").toBe("");
+ expect(await revoked()).toContain(secondUrl);
+ expect(((await offline.getAttribute("#save", "href")) ?? "").startsWith("blob:")).toBe(true);
+
+ expect(pageErrors).toEqual([]);
+ await offline.close();
+ });
});
From 0132dc981b7a5ab0450bf002e5ac3853654367f2 Mon Sep 17 00:00:00 2001
From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com>
Date: Thu, 24 Sep 2026 07:39:59 +0000
Subject: [PATCH 27/34] fix(audio): read WAV carriers of any common depth
without resampling
parseWavToPcm16 refused everything but 16-bit PCM ("Only 16-bit PCM WAV is
supported") while docs/FORMAT-AUDIO-STEGO.md promised Hide any WAV. Most
audio tools export 24-bit or float, so those files could not be carriers.
Web Audio is not the fallback: it resamples to the device rate, and the low
bit the payload lives in does not survive that. The exact parser now also
reads 8, 24 and 32-bit integer PCM and 32/64-bit float, plain or
WAVE_FORMAT_EXTENSIBLE (SubFormat GUID checked in full), at the file's own
sample rate, and scales each to 16 bits by a power of two. 16-bit PCM is
read sample for sample as before, so Reveal is unchanged for every file it
could open, and a stego WAV re-saved losslessly at 24-bit or float still
reveals. ADPCM, A-law, mu-law and other depths are refused with a message.
The format document now says exactly this.
Tests:
- New scripts/audio-wav-depths-test.mjs (npm run test:audio-wav-depths, CI
step added): every depth plain and extensible, rate kept, exact samples,
8-bit scaling, stereo order, float clamping, a payload surviving 24-bit and
float re-saves, and three EXTENSIBLE refusals. Controls: the old parser
fails 13 checks; dropping the GUID check reads a foreign header as PCM.
- audio-malformed-test.mjs: the float and 24-bit refusals became ADPCM,
12-bit PCM and 16-bit float refusals. Control: removing the encoding check
fails all three.
---
.github/workflows/ci.yml | 16 ++-
docs/FORMAT-AUDIO-STEGO.md | 15 ++-
package.json | 1 +
scripts/audio-malformed-test.mjs | 19 +--
scripts/audio-wav-depths-test.mjs | 186 ++++++++++++++++++++++++++++
src/components/audio-stego-tool.tsx | 5 +-
src/lib/audio-stego.ts | 78 +++++++++---
7 files changed, 289 insertions(+), 31 deletions(-)
create mode 100644 scripts/audio-wav-depths-test.mjs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 82dc1b8..f496c3b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -134,15 +134,25 @@ jobs:
run: npm run test:audio-wav-bounds
# Every other malformed carrier is a typed AudioStegoError too, on both the
- # WAV parse (not RIFF, no data chunk, non-PCM, non-16-bit, zero channels)
+ # WAV parse (not RIFF, no data chunk, an unread encoding, zero channels)
# and the KAUD extract path (no marker, unreadable version/depth, empty or
# over-long declared payload). A carrier fault must never reach the AEAD as
# "decryption failed". Controls bite: removing the payload-length bound
- # returns garbage instead of throwing; removing the format/bit-depth check
- # misreads a 24-bit file instead of refusing it.
+ # returns garbage instead of throwing; removing the encoding check misreads
+ # an ADPCM file instead of refusing it.
- name: Malformed audio carriers are all typed carrier errors
run: npm run test:audio-malformed
+ # A WAV carrier at 8, 24 or 32-bit PCM or 32/64-bit float, plain or
+ # WAVE_FORMAT_EXTENSIBLE, is read at its own rate and scaled to 16 bits by
+ # a power of two, so a stego WAV re-saved losslessly at a greater depth
+ # still reveals. It used to be refused as "Only 16-bit PCM WAV" while the
+ # format doc promised any WAV. Control bites: restoring the refusal fails
+ # every conversion case; dropping the SubFormat GUID check reads a foreign
+ # EXTENSIBLE header as PCM.
+ - name: WAV carriers of any common depth are read without resampling
+ run: npm run test:audio-wav-depths
+
# 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
diff --git a/docs/FORMAT-AUDIO-STEGO.md b/docs/FORMAT-AUDIO-STEGO.md
index 9989b13..dd70131 100644
--- a/docs/FORMAT-AUDIO-STEGO.md
+++ b/docs/FORMAT-AUDIO-STEGO.md
@@ -48,10 +48,17 @@ The payload lives in the least-significant bit of each PCM sample. Lossy codecs
quantisation, so a container embedded and then MP3-encoded is destroyed (measured
bit-error near 0.5, i.e. total loss). Therefore:
-- **Input** may be any format the browser can decode (MP3, WAV, FLAC, Ogg). A
- lossy input is decoded to PCM first; its compression artefacts are already
- baked into those samples, which is fine because they become the new lossless
- master.
+- **Input** may be MP3, FLAC, Ogg or anything else the browser can decode, or a
+ WAV. A lossy input is decoded to PCM first; its compression artefacts are
+ already baked into those samples, which is fine because they become the new
+ lossless master.
+- **A WAV is read directly, not by the browser's decoder**, because the browser
+ resamples to the device's rate and that destroys the low bit. Integer PCM at
+ 8, 16, 24 or 32 bits and float at 32 or 64 bits are accepted, plain or
+ `WAVE_FORMAT_EXTENSIBLE`, at their own sample rate. Anything other than 16-bit
+ is scaled to 16 bits by a power of two, so a 16-bit carrier re-saved
+ losslessly at a greater depth still gives back its payload. Other WAV
+ encodings (ADPCM, A-law, µ-law) are refused with a message.
- **Output** is always lossless. Phase 1 writes **16-bit PCM WAV**. FLAC output
is a later addition and uses the same stream defined here.
diff --git a/package.json b/package.json
index adab0de..2d6e074 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"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:audio-wav-depths": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/audio-wav-depths-test.mjs",
"test:sw-precache": "node scripts/sw-precache-test.mjs",
"test:camera-progress": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/camera-progress-test.mjs",
"test:printout-check": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/printout-check-test.mjs",
diff --git a/scripts/audio-malformed-test.mjs b/scripts/audio-malformed-test.mjs
index 02f48a3..c5037e8 100644
--- a/scripts/audio-malformed-test.mjs
+++ b/scripts/audio-malformed-test.mjs
@@ -8,15 +8,15 @@
* is never routed to the AEAD (where "decryption failed" would send someone to
* retype a password that was never wrong). scripts/audio-wav-bounds-test.mjs
* pins the fmt-bounds case; this pins the rest of the surface:
- * - parseWavToPcm16: not RIFF/WAVE, no data chunk, non-PCM format, non-16-bit,
- * zero channels;
+ * - parseWavToPcm16: not RIFF/WAVE, no data chunk, an encoding it does not
+ * read (ADPCM, 12-bit PCM, 16-bit float), zero channels;
* - extractContainer: no KAUD marker, an unreadable version or bit depth, an
* empty declared payload, and a payload length that runs past the carrier.
*
* esbuild bundles the TS the way the rest of the project reaches its `.ts`.
* Controls shown to bite (see the two reverts documented inline): removing the
* payload-length bound makes the over-long-payload case return garbage instead
- * of throwing, and removing the format/bit-depth check makes a 24-bit file be
+ * of throwing, and removing the format/bit-depth check makes an ADPCM file be
* misread rather than refused.
*/
import esbuild from "esbuild";
@@ -73,11 +73,16 @@ rejectsAudioStego(() => parseWavToPcm16(new Uint8Array(64).fill(0x41)), "a non-R
// RIFF/WAVE with a fmt chunk but no data chunk.
rejectsAudioStego(() => parseWavToPcm16(riff(fmtChunk({}))), "a WAV with no data chunk is refused");
-// Non-PCM format (3 = IEEE float) — must be refused, not misread.
-rejectsAudioStego(() => parseWavToPcm16(riff(fmtChunk({ format: 3 }), dataChunk())), "a non-PCM (float) WAV is refused");
+// An encoding the parser does not read (2 = ADPCM) — refused, not misread.
+// Float and 24-bit used to be refused here too; they are converted now, and
+// scripts/audio-wav-depths-test.mjs pins that.
+rejectsAudioStego(() => parseWavToPcm16(riff(fmtChunk({ format: 2 }), dataChunk())), "an ADPCM WAV is refused");
-// 24-bit PCM — the LSB scheme is defined on 16-bit ints.
-rejectsAudioStego(() => parseWavToPcm16(riff(fmtChunk({ bits: 24 }), dataChunk())), "a 24-bit WAV is refused");
+// A PCM depth with no whole-byte sample width to read.
+rejectsAudioStego(() => parseWavToPcm16(riff(fmtChunk({ bits: 12 }), dataChunk())), "a 12-bit WAV is refused");
+
+// Float at a width that is not 32 or 64.
+rejectsAudioStego(() => parseWavToPcm16(riff(fmtChunk({ format: 3, bits: 16 }), dataChunk())), "a 16-bit float WAV is refused");
// Zero channels.
rejectsAudioStego(() => parseWavToPcm16(riff(fmtChunk({ channels: 0 }), dataChunk())), "a WAV declaring no channels is refused");
diff --git a/scripts/audio-wav-depths-test.mjs b/scripts/audio-wav-depths-test.mjs
new file mode 100644
index 0000000..86dc380
--- /dev/null
+++ b/scripts/audio-wav-depths-test.mjs
@@ -0,0 +1,186 @@
+#!/usr/bin/env node
+/**
+ * A WAV carrier at any common depth is read, at its own sample rate, and a
+ * 16-bit carrier re-saved losslessly at a greater depth keeps its payload.
+ *
+ * parseWavToPcm16 used to refuse everything but 16-bit PCM ("Only 16-bit PCM
+ * WAV is supported"), while docs/FORMAT-AUDIO-STEGO.md promised Hide any WAV.
+ * A 24-bit or float recording, which is what most audio tools export, could not
+ * be used as a carrier at all. Handing those to Web Audio instead is not a fix:
+ * it resamples to the device rate, and the low bit the payload lives in does
+ * not survive that.
+ *
+ * What is pinned:
+ * - 8, 24 and 32-bit integer PCM and 32/64-bit float are read and scaled to
+ * 16 bits, plain and as WAVE_FORMAT_EXTENSIBLE, with the sample rate kept;
+ * - a 16-bit sample widened by a power of two comes back exactly, so a stego
+ * WAV re-saved at 24-bit or float still reveals its container;
+ * - an EXTENSIBLE header with a foreign SubFormat GUID, or one too short to
+ * hold it, is refused as a typed AudioStegoError.
+ *
+ * The control: restore the "Only 16-bit PCM" refusal and every conversion case
+ * here fails with that message.
+ */
+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-depth-")), "audio-stego.mjs");
+await esbuild.build({ entryPoints: [src], bundle: true, format: "esm", platform: "node", outfile: out });
+
+const { parseWavToPcm16, writePcm16Wav, embedContainer, extractContainer, AudioStegoError } =
+ await import(pathToFileURL(out).href);
+
+let failed = 0;
+const ok = (cond, msg) => {
+ if (!cond) { console.error("FAIL:", msg); failed++; }
+ else console.log("ok ", msg);
+};
+const parses = (bytes, msg) => {
+ try {
+ return parseWavToPcm16(bytes);
+ } catch (e) {
+ ok(false, `${msg} (threw ${e?.constructor?.name}: ${e?.message})`);
+ return null;
+ }
+};
+const rejectsAudioStego = (fn, msg) => {
+ try {
+ fn();
+ ok(false, msg + " (did not throw)");
+ } catch (e) {
+ ok(e instanceof AudioStegoError, msg + (e instanceof AudioStegoError ? "" : ` (threw ${e?.constructor?.name})`));
+ }
+};
+
+const ascii = (s) => Uint8Array.from(s, (c) => c.charCodeAt(0));
+const u16 = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff);
+const u32 = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff);
+const cat = (...parts) => {
+ const a = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
+ let o = 0;
+ for (const p of parts) { a.set(p, o); o += p.length; }
+ return a;
+};
+
+const RATE = 44100;
+/** KSDATAFORMAT_SUBTYPE_* for a given format code: the code, then a fixed suffix. */
+const subFormat = (code, suffix = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71]) =>
+ cat(u16(code), Uint8Array.from(suffix));
+
+function wav({ format, bits, channels = 1, data, extensible = false, guid = null, fmtSize = null }) {
+ const align = channels * (bits / 8);
+ const base = cat(u16(extensible ? 0xfffe : format), u16(channels), u32(RATE), u32(RATE * align), u16(align), u16(bits));
+ const ext = extensible ? cat(u16(22), u16(bits), u32(0), guid ?? subFormat(format)) : new Uint8Array(0);
+ const body = cat(base, ext);
+ const size = fmtSize ?? body.length;
+ const fmt = cat(ascii("fmt "), u32(size), body.subarray(0, size));
+ const dataChunk = cat(ascii("data"), u32(data.length), data, data.length & 1 ? new Uint8Array(1) : new Uint8Array(0));
+ const all = cat(fmt, dataChunk);
+ return cat(ascii("RIFF"), u32(4 + all.length), ascii("WAVE"), all);
+}
+
+/** Widen 16-bit samples to another encoding by a power of two, as a lossless re-save does. */
+function widen(samples, kind) {
+ const n = samples.length;
+ if (kind === "pcm24") {
+ const b = new Uint8Array(n * 3);
+ for (let i = 0; i < n; i++) {
+ const v = (samples[i] * 256) & 0xffffff;
+ b[i * 3] = v & 0xff; b[i * 3 + 1] = (v >> 8) & 0xff; b[i * 3 + 2] = (v >> 16) & 0xff;
+ }
+ return b;
+ }
+ const b = new Uint8Array(n * (kind === "float64" ? 8 : 4));
+ const v = new DataView(b.buffer);
+ for (let i = 0; i < n; i++) {
+ if (kind === "pcm32") v.setInt32(i * 4, samples[i] * 65536, true);
+ else if (kind === "float32") v.setFloat32(i * 4, samples[i] / 32768, true);
+ else v.setFloat64(i * 8, samples[i] / 32768, true);
+ }
+ return b;
+}
+
+const S = Int16Array.of(0, 1, -1, 2, -2, 32767, -32768, 12345, -12345, 257, -257, 16385);
+const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
+
+// --- Each depth, plain and extensible ---
+const CASES = [
+ ["24-bit PCM", 1, 24, "pcm24"],
+ ["32-bit PCM", 1, 32, "pcm32"],
+ ["32-bit float", 3, 32, "float32"],
+ ["64-bit float", 3, 64, "float64"],
+];
+for (const [name, format, bits, kind] of CASES) {
+ for (const extensible of [false, true]) {
+ const label = `${name}${extensible ? " (WAVE_FORMAT_EXTENSIBLE)" : ""}`;
+ const got = parses(wav({ format, bits, data: widen(S, kind), extensible }), `${label} is read`);
+ if (!got) continue;
+ ok(got.sampleRate === RATE, `${label} keeps its sample rate (got ${got.sampleRate})`);
+ ok(same(got.samples, S), `${label} widened from 16-bit comes back to the same samples`);
+ }
+}
+
+// 8-bit is unsigned and narrower, so only its scaling is checked.
+{
+ const got = parses(wav({ format: 1, bits: 8, data: Uint8Array.of(0, 128, 255, 129) }), "8-bit PCM is read");
+ if (got) ok(same(got.samples, Int16Array.of(-32768, 0, 32512, 256)), "8-bit PCM is centred on 128 and scaled by 256");
+}
+
+// Stereo keeps its interleave.
+{
+ const got = parses(wav({ format: 1, bits: 24, channels: 2, data: widen(S, "pcm24") }), "stereo 24-bit PCM is read");
+ if (got) ok(got.channels === 2 && same(got.samples, S), "stereo 24-bit PCM keeps its channels and order");
+}
+
+// Out-of-range float clamps rather than wrapping.
+{
+ const b = new Uint8Array(8);
+ const v = new DataView(b.buffer);
+ v.setFloat32(0, 1.0, true);
+ v.setFloat32(4, -1.5, true);
+ const got = parses(wav({ format: 3, bits: 32, data: b }), "full-scale float is read");
+ if (got) ok(same(got.samples, Int16Array.of(32767, -32768)), "full-scale float clamps to the 16-bit range");
+}
+
+// --- A payload survives a lossless re-save at a greater depth ---
+{
+ const carrier = new Int16Array(6000);
+ let seed = 7;
+ for (let i = 0; i < carrier.length; i++) { seed = (seed * 1103515245 + 12345) >>> 0; carrier[i] = (seed >>> 16) - 32768; }
+ const container = Uint8Array.from({ length: 64 }, (_, i) => (i * 37 + 11) & 0xff);
+ const stego = embedContainer({ sampleRate: RATE, channels: 1, samples: carrier }, container);
+ ok(same(extractContainer(parseWavToPcm16(writePcm16Wav(stego))), container), "the 16-bit stego WAV reveals its container");
+ for (const [kind, format, bits] of [["pcm24", 1, 24], ["float32", 3, 32]]) {
+ const got = parses(wav({ format, bits, data: widen(stego.samples, kind) }), `the stego WAV re-saved as ${kind} is read`);
+ if (!got) continue;
+ let revealed = null;
+ try { revealed = extractContainer(got); } catch { revealed = null; }
+ ok(revealed !== null && same(revealed, container), `the stego WAV re-saved as ${kind} still reveals its container`);
+ }
+}
+
+// --- Refusals ---
+rejectsAudioStego(
+ () => parseWavToPcm16(wav({ format: 1, bits: 24, data: widen(S, "pcm24"), extensible: true,
+ guid: subFormat(1, [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) })),
+ "an EXTENSIBLE WAV whose SubFormat is not a KSDATAFORMAT GUID is refused"
+);
+rejectsAudioStego(
+ () => parseWavToPcm16(wav({ format: 1, bits: 24, data: widen(S, "pcm24"), extensible: true, fmtSize: 16 })),
+ "an EXTENSIBLE fmt chunk too short to hold its SubFormat is refused"
+);
+rejectsAudioStego(
+ () => parseWavToPcm16(wav({ format: 7, bits: 24, data: widen(S, "pcm24"), extensible: true })),
+ "an EXTENSIBLE WAV whose SubFormat is mu-law is refused"
+);
+
+if (failed) {
+ console.error(`\n${failed} check(s) failed.`);
+ process.exit(1);
+}
+console.log("\nEvery WAV depth is read at its own rate, and a widened stego WAV keeps its payload.");
diff --git a/src/components/audio-stego-tool.tsx b/src/components/audio-stego-tool.tsx
index 28d48d3..0b34572 100644
--- a/src/components/audio-stego-tool.tsx
+++ b/src/components/audio-stego-tool.tsx
@@ -82,8 +82,9 @@ function looksLikeWav(file: File): boolean {
return /audio\/(wav|x-wav|wave|vnd\.wave)/i.test(file.type) || /\.wav$/i.test(file.name);
}
-/** Decode any carrier to 16-bit PCM. WAV is parsed exactly so its samples (and
- * therefore any embedded LSBs) survive; everything else goes through Web Audio. */
+/** Decode any carrier to 16-bit PCM. WAV of any PCM or float depth is parsed
+ * directly, never resampled, so 16-bit samples (and 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) || isWavBytes(new Uint8Array(buffer))) {
diff --git a/src/lib/audio-stego.ts b/src/lib/audio-stego.ts
index 75d28ac..bd6cac4 100644
--- a/src/lib/audio-stego.ts
+++ b/src/lib/audio-stego.ts
@@ -128,7 +128,7 @@ export function extractContainer(pcm: Pcm16): Uint8Array {
return payload;
}
-// ---- WAV (16-bit PCM) read and write ----
+// ---- WAV read (any integer PCM or float depth) and write (16-bit PCM) ----
function readAscii(view: DataView, offset: number, length: number): string {
let s = "";
@@ -136,14 +136,6 @@ function readAscii(view: DataView, offset: number, length: number): string {
return s;
}
-/**
- * Parse a 16-bit PCM WAV into interleaved samples.
- *
- * Only the one encoding the writer below produces is accepted: RIFF/WAVE, PCM
- * (format 1), 16 bits per sample. Anything else (float, 24-bit, ADPCM) is
- * 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?
*
@@ -158,6 +150,28 @@ export function isWavBytes(bytes: Uint8Array): boolean {
return tag(0) === "RIFF" && tag(8) === "WAVE";
}
+const WAVE_FORMAT_PCM = 1;
+const WAVE_FORMAT_IEEE_FLOAT = 3;
+const WAVE_FORMAT_EXTENSIBLE = 0xfffe;
+/** The 14 bytes after the format code in a KSDATAFORMAT_SUBTYPE_* GUID. */
+const KSDATAFORMAT_SUFFIX = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71];
+
+const clamp16 = (v: number) => Math.max(-32768, Math.min(32767, v));
+
+/**
+ * Parse a WAV into interleaved 16-bit samples, without resampling.
+ *
+ * 16-bit PCM, the one encoding the writer below produces and the only one a
+ * payload is embedded in, is read sample for sample. Other integer depths (8,
+ * 24, 32) and 32- or 64-bit float, plain or WAVE_FORMAT_EXTENSIBLE, are scaled
+ * to 16 bits here rather than refused. Each is scaled by a power of two, so a
+ * 16-bit file re-saved losslessly at a greater depth comes back to the same
+ * samples, low bit included, and a payload it carried is still there.
+ *
+ * This is not left to Web Audio because Web Audio resamples to the device rate,
+ * which destroys the low bit. Encodings this does not read (ADPCM, A-law,
+ * mu-law, unusual depths) are refused with a message rather than misread.
+ */
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") {
@@ -189,6 +203,16 @@ export function parseWavToPcm16(bytes: Uint8Array): Pcm16 {
channels = view.getUint16(body + 2, true);
sampleRate = view.getUint32(body + 4, true);
bitsPerSample = view.getUint16(body + 14, true);
+ if (format === WAVE_FORMAT_EXTENSIBLE) {
+ // The real encoding is the first two bytes of the SubFormat GUID at
+ // body+24, and the rest of the GUID has to be the KSDATAFORMAT one for
+ // those two bytes to mean anything.
+ if (size < 40 || body + 40 > bytes.length) {
+ throw new AudioStegoError("This WAV's format chunk is malformed or truncated.");
+ }
+ const suffixOk = KSDATAFORMAT_SUFFIX.every((b, i) => bytes[body + 26 + i] === b);
+ format = suffixOk ? view.getUint16(body + 24, true) : 0;
+ }
} else if (id === "data") {
dataOffset = body;
dataLength = Math.min(size, bytes.length - body);
@@ -198,14 +222,37 @@ export function parseWavToPcm16(bytes: Uint8Array): Pcm16 {
}
if (dataOffset < 0) throw new AudioStegoError("This WAV has no audio data.");
- if (format !== 1 || bitsPerSample !== 16) {
- throw new AudioStegoError("Only 16-bit PCM WAV is supported. Re-export the audio as 16-bit PCM WAV.");
+ const pcm = format === WAVE_FORMAT_PCM && [8, 16, 24, 32].includes(bitsPerSample);
+ const float = format === WAVE_FORMAT_IEEE_FLOAT && (bitsPerSample === 32 || bitsPerSample === 64);
+ if (!pcm && !float) {
+ throw new AudioStegoError(
+ "This WAV's encoding cannot be read. Re-export it as PCM (8, 16, 24 or 32-bit) or float WAV."
+ );
}
if (channels < 1) throw new AudioStegoError("This WAV declares no channels.");
- const sampleCount = Math.floor(dataLength / 2);
+ const width = bitsPerSample / 8;
+ const sampleCount = Math.floor(dataLength / width);
const samples = new Int16Array(sampleCount);
- for (let i = 0; i < sampleCount; i++) samples[i] = view.getInt16(dataOffset + i * 2, true);
+ for (let i = 0; i < sampleCount; i++) {
+ const at = dataOffset + i * width;
+ let v: number;
+ if (float) {
+ // The same single scale decodeToPcm16 uses, and its inverse of s / 32768.
+ const f = width === 4 ? view.getFloat32(at, true) : view.getFloat64(at, true);
+ v = Number.isFinite(f) ? Math.round(f * 32768) : 0;
+ } else if (width === 2) {
+ v = view.getInt16(at, true);
+ } else if (width === 1) {
+ v = (view.getUint8(at) - 128) * 256; // 8-bit WAV is unsigned
+ } else if (width === 3) {
+ const u = view.getUint8(at) | (view.getUint8(at + 1) << 8) | (view.getUint8(at + 2) << 16);
+ v = Math.round((u & 0x800000 ? u - 0x1000000 : u) / 256);
+ } else {
+ v = Math.round(view.getInt32(at, true) / 65536);
+ }
+ samples[i] = clamp16(v);
+ }
return { sampleRate, channels, samples };
}
@@ -243,8 +290,9 @@ export function writePcm16Wav(pcm: Pcm16): Uint8Array {
*
* A lossy input (MP3, Ogg, AAC) is decoded to float PCM and re-quantised to
* 16-bit here; those samples become the lossless master the payload is embedded
- * into. WAV that is already 16-bit PCM is parsed directly by `parseWavToPcm16`
- * so its exact samples survive; this path is for everything else.
+ * into. A WAV is parsed directly by `parseWavToPcm16` whatever its depth, so
+ * its sample rate is kept and 16-bit samples survive exactly; this path is for
+ * everything else.
*
* `ctxFactory` is injected so a test can supply an AudioContext; in the app it
* defaults to the platform one.
From d353bd67e185dc100d5f0771b52fd102ac999da0 Mon Sep 17 00:00:00 2001
From: 404SecNotFound <46477113+404SecNotFound@users.noreply.github.com>
Date: Thu, 24 Sep 2026 07:39:59 +0000
Subject: [PATCH 28/34] fix(sealed): claim only what the page's policy enforces
The sealed panel said "Forbidden to talk to any server" and "for every
request to anywhere". default-src, connect-src and form-action set to 'none'
block the scripted connection APIs, form posts and loads from other servers.
They do not govern moving the tab to another address, WebRTC, requests for
the site's own files, or the page's workers (docs/HOW-IT-WORKS.md, "What the
CSP does not do").
The wording now lives in seal-verdict.ts as SEALED_CLAIM, names those
limits, and sealed-status.tsx renders it. The unsealed text no longer says
the export "forbids every request".
Tests:
- seal-verdict-test.mjs: the claim must not say "any server", "every
request" or "anywhere", must name all four limits, and sealed-status.tsx
must render SEALED_CLAIM and not carry the old phrases. Controls: the old
component fails 4 checks, both old files fail 9, the old wording in the
constant fails 7.
- sealed-status.spec.ts: the panel's title and text equal SEALED_CLAIM.
Controls, each built: the old component, and the new component with the
old wording, both fail.
---
scripts/seal-verdict-test.mjs | 38 ++++++++++++++++++++++++++---
src/components/sealed-status.tsx | 29 ++++++++++------------
src/lib/seal-verdict.ts | 31 +++++++++++++++++++++--
tests/browser/sealed-status.spec.ts | 10 +++++++-
4 files changed, 85 insertions(+), 23 deletions(-)
diff --git a/scripts/seal-verdict-test.mjs b/scripts/seal-verdict-test.mjs
index fb07b8c..a729d70 100644
--- a/scripts/seal-verdict-test.mjs
+++ b/scripts/seal-verdict-test.mjs
@@ -3,8 +3,9 @@
* The "sealed" verdict requires the whole egress-relevant directive set, not
* connect-src alone.
*
- * The sealed panel claims total egress prevention ("forbidden to talk to any
- * server, for every request to anywhere"). `connect-src 'none'` earns only part
+ * The sealed panel used to claim total egress prevention ("forbidden to talk to
+ * any server, for every request to anywhere"); the last section pins the
+ * narrower claim it makes now. `connect-src 'none'` earns only part
* of that — fetch/XHR/WebSocket/EventSource/sendBeacon — while a `