diff --git a/CHANGELOG.md b/CHANGELOG.md index 9833616..b51cfb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -192,7 +192,9 @@ v2.2.0 wrote reads exactly as before, the existing fixture corpus is unchanged - **A module that failed to load could still read as a wrong password.** The KEYM v2 module itself, and the Shamir code used when adding shares, were imported without the typed "could not be loaded" error the other lazily - loaded modules use. + loaded modules use. Every other lazy import of either module (the page, the + no-worker fallback and the worker) now goes through the same two loaders, + and a test fails if a bare one comes back. - **Decoded share records were not erased.** The record a share decodes to, the checksum input built from it, a record refused for its padding bits, and the value dropped by two callers that needed only a share's set id or index diff --git a/scripts/secret-erase-core-test.mjs b/scripts/secret-erase-core-test.mjs index f3c3884..130e2f7 100644 --- a/scripts/secret-erase-core-test.mjs +++ b/scripts/secret-erase-core-test.mjs @@ -34,7 +34,7 @@ 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 { mkdtempSync, readdirSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -501,6 +501,47 @@ for (const [name, password, keyFile] of [ "once the keym-v2 chunk is reachable the same call opens the container (the failure was not cached)", why(again.error)); } + +// --------------------------------------------------------------------------- +// The two loaders above are the only dynamic imports of keym-v2.ts and +// keym-v2-shamir.ts in src. Twenty-one other sites (the page, crypto-client.ts's +// fallback, the worker) used a bare `import()`. None can fail today: the page +// imports keym-v2 statically and the worker is one bundle. But each is where the +// untyped failure would come back the day that stops being true, and no call +// can model it while the static import is there. So it is checked in the source. +// --------------------------------------------------------------------------- +{ + const LOADERS = new Map([ + ["keymaker-crypto.ts", 'keym2Promise = import("./keym-v2")'], + ["keym-v2.ts", 'shamirModulePromise = import("./keym-v2-shamir")'], + ]); + const bare = /(? { + for (const d of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, d.name); + if (d.isDirectory()) walk(path); + else if (/\.(ts|tsx|mts|js|mjs)$/.test(d.name)) { + readFileSync(path, "utf8").split("\n").forEach((line, i) => { + for (const m of line.matchAll(bare)) { + if (LOADERS.get(d.name) && line.includes(LOADERS.get(d.name))) continue; + offenders.push(`${path.slice(ROOT.length + 1)}:${i + 1}: ${m[0]}`); + } + }); + } + } + }; + walk(join(ROOT, "src")); + ok(offenders.length === 0, + "keym-v2 and keym-v2-shamir are imported lazily only through loadKeym2 and loadShamir", + offenders.join("; ")); + // The pattern itself has to see the forms it guards against, or a clean + // result means nothing. + for (const form of ['await import("@/lib/keym-v2")', "import('./keym-v2-shamir')", "import( \"../lib/keym-v2\" )"]) { + ok(form.match(bare) !== null, `the guard recognises ${form}`); + } + ok('Promise'.match(bare) === null, "the guard ignores a type-only import()"); +} { const CHUNK = "keym-v2-shamir.enrol-chunk.mjs"; const missingShamir = { diff --git a/src/components/encryptor-tool.tsx b/src/components/encryptor-tool.tsx index 79bd62a..8d87643 100644 --- a/src/components/encryptor-tool.tsx +++ b/src/components/encryptor-tool.tsx @@ -7,7 +7,7 @@ import { PaperVault } from "@/components/paper-vault"; import { ContainerInspector, type InspectorPlan } from "@/components/container-inspector"; import { SelfExtractExport } from "@/components/self-extract-export"; import { InheritancePlan } from "@/components/inheritance-plan"; -import { armorKeym2, KEYM2_HEADER_PEEK_BYTES, KEYM2_VERSION } from "@/lib/keym-v2"; +import { armorKeym2, KEYM2_HEADER_PEEK_BYTES, KEYM2_VERSION, loadShamir } from "@/lib/keym-v2"; import { looksLikeSelfExtract, extractSelfExtract } from "@/lib/keym-v2-selfextract"; import { looksLikePaperPart, describePaperPart, decodePaperPartsAny, splitPaperParts } from "@/lib/keym-v2-paper"; import { decodeAllQrImage, decodeQrImages, QrDecodeError } from "@/lib/qr-decode"; @@ -77,6 +77,7 @@ import { DEFAULT_ARGON2ID, type KdfParams, type DetectedFormat, + loadKeym2, } from "@/lib/keymaker-crypto"; import { encryptViaWorker, @@ -859,7 +860,7 @@ async function containerFromTextFile(bytes: Uint8Array): Promise { try { - const { shamirSlotSaltsKeym2 } = await import("@/lib/keym-v2"); - const { shareSetCode } = await import("@/lib/keym-v2-shamir"); + const { shamirSlotSaltsKeym2 } = await loadKeym2(); + const { shareSetCode } = await loadShamir(); return await Promise.all(shamirSlotSaltsKeym2(container).map((salt) => shareSetCode(salt))); } catch { return []; @@ -1569,7 +1570,7 @@ export function EncryptorTool() { let live = true; (async () => { try { - const { dearmorKeym2 } = await import("@/lib/keym-v2"); + const { dearmorKeym2 } = await loadKeym2(); const { encodePaperPartsForPrint } = await import("@/lib/keym-v2-paper"); const parts = await encodePaperPartsForPrint(dearmorKeym2(outputText)); if (live) setBackupFitsOnStrip(parts.length === 1); @@ -3015,7 +3016,7 @@ export function EncryptorTool() { let passkey: { prfOutput: Uint8Array; salt: Uint8Array } | undefined; // §4.8 rules a passkey out: it would open the backup on its own. if (passkeyEnabled && !(shamirEnabled && sharesNeedPassword)) { - const { derivePrfSalt } = await import("@/lib/keym-v2"); + const { derivePrfSalt } = await loadKeym2(); const { enrolPasskey } = await import("@/lib/webauthn-prf"); const slotSalt = crypto.getRandomValues(new Uint8Array(32)); const prfOutput = await enrolPasskey(await derivePrfSalt(slotSalt)); @@ -3122,7 +3123,7 @@ export function EncryptorTool() { // and so nobody has to strip `=` by hand from a backup they are // trying to recover. Dynamically imported for the same reason the // crypto core imports it that way. - const { armorKeym2 } = await import("@/lib/keym-v2"); + const { armorKeym2 } = await loadKeym2(); setOutputText(armorKeym2(new Uint8Array(resultBuffer))); setReceipt(receiptOf("text", "keym2: container, on screen", true)); setTextSecret(''); @@ -3160,7 +3161,7 @@ export function EncryptorTool() { // Case-sensitive and byte-exact — see the note on the constants. // Also base64url rather than base64, so this cannot go through // base64ToUint8Array. - const { dearmorKeym2 } = await import("@/lib/keym-v2"); + const { dearmorKeym2 } = await loadKeym2(); bytes = dearmorKeym2(blobText); } else { if (blobText.toUpperCase().startsWith(KEYM_V1_TEXT_PREFIX)) { @@ -3199,7 +3200,7 @@ export function EncryptorTool() { // reading a variable the worker has not produced yet — the whole point // is that this runs first. { - const { keym2UnlockCost, describeUnlockCost } = await import("@/lib/keym-v2"); + const { keym2UnlockCost, describeUnlockCost } = await loadKeym2(); const cost = keym2UnlockCost(headerPeek); const notice = describeUnlockCost(cost); // Same freeze, arriving from the other direction: here the KDF is the @@ -3247,7 +3248,7 @@ export function EncryptorTool() { if (isStale()) return; let prfOutput: Uint8Array | undefined; if (usePasskey) { - const { passkeySlotSaltsKeym2, derivePrfSalt } = await import("@/lib/keym-v2"); + const { passkeySlotSaltsKeym2, derivePrfSalt } = await loadKeym2(); const { assertPasskeyPrf } = await import("@/lib/webauthn-prf"); const salts = passkeySlotSaltsKeym2(new Uint8Array(inputBuffer)); if (salts.length === 0) { @@ -3265,7 +3266,7 @@ export function EncryptorTool() { // "decryption failed" about strips that were never wrong. From the slot // salts, which are in the clear, so it tells nobody anything new. if (suppliedShares.length > 0 && !mutablePassword) { - const { sharesNeedPasswordKeym2 } = await import("@/lib/keym-v2"); + const { sharesNeedPasswordKeym2 } = await loadKeym2(); if (await sharesNeedPasswordKeym2(new Uint8Array(inputBuffer), suppliedShares)) { throw new KeymakerError( "credential-required", @@ -3341,7 +3342,7 @@ export function EncryptorTool() { ) { // One inspector for both: every field it reads sits at a // version-dependent offset it already resolves from the header. - const { inspectKeym2 } = await import("@/lib/keym-v2"); + const { inspectKeym2 } = await loadKeym2(); const inspected = inspectKeym2(headerPeek); if (inspected) { info += ` · ${inspected.kdfLabel} · ${inspected.cipherLabel}`; @@ -5257,7 +5258,7 @@ export function EncryptorTool() { trimmed.startsWith(KEYM_V2_TEXT_PREFIX) && trimmed.length <= MAX_BASE64_INPUT_CHARS ) { - const { dearmorKeym2 } = await import("@/lib/keym-v2"); + const { dearmorKeym2 } = await loadKeym2(); // 1392 armor characters cover the peek even if every 64-column // line break survived the paste; a slice that cuts mid-quantum // throws, is caught, and reads as "nothing loaded yet". @@ -5325,7 +5326,7 @@ export function EncryptorTool() { */ const printPaperVault = useCallback(async () => { if (!outputText.startsWith("keym2:")) return; - const { dearmorKeym2 } = await import("@/lib/keym-v2"); + const { dearmorKeym2 } = await loadKeym2(); const container = dearmorKeym2(outputText); const { parts, tooLarge, setCodes } = await preparePaperParts(container); setPaperVault({ @@ -5340,7 +5341,7 @@ export function EncryptorTool() { const downloadContainer = useCallback(async () => { if (!outputText.startsWith("keym2:")) return; - const { dearmorKeym2 } = await import("@/lib/keym-v2"); + const { dearmorKeym2 } = await loadKeym2(); triggerDownload( new Blob([dearmorKeym2(outputText).slice()]), `keymaker-${randomFilenameSuffix()}.keym` @@ -5393,7 +5394,7 @@ export function EncryptorTool() { setRehearsal({ kind: "running" }); const started = performance.now(); try { - const { dearmorKeym2 } = await import("@/lib/keym-v2"); + const { dearmorKeym2 } = await loadKeym2(); // A copy: the worker takes ownership of the buffer it is handed. const container = dearmorKeym2(outputText).slice(); // §4.8. Strips that need the password are rehearsed with it, the way an @@ -5619,7 +5620,7 @@ export function EncryptorTool() { let container: Uint8Array | null = null; if (mode === "encrypt" && receipt?.onScreen && outputText.startsWith("keym2:")) { try { - const { dearmorKeym2 } = await import("@/lib/keym-v2"); + const { dearmorKeym2 } = await loadKeym2(); container = dearmorKeym2(outputText); } catch { container = null; @@ -6155,7 +6156,7 @@ export function EncryptorTool() { onClick={async () => { if (!issuedShares || !outputText.startsWith("keym2:")) return; try { - const { dearmorKeym2 } = await import("@/lib/keym-v2"); + const { dearmorKeym2 } = await loadKeym2(); const container = dearmorKeym2(outputText); const { parts, tooLarge, setCodes } = await preparePaperParts(container); setPaperVault({ diff --git a/src/lib/crypto-client.ts b/src/lib/crypto-client.ts index 8d0dee3..19654f4 100644 --- a/src/lib/crypto-client.ts +++ b/src/lib/crypto-client.ts @@ -29,6 +29,7 @@ import { type KeymakerErrorCode, type KeymakerOptions, type DetectedFormat, + loadKeym2, } from "./keymaker-crypto"; import type { CryptoRequest, CryptoResponse } from "./crypto-worker"; import { ProbePolicy, PROBE_TIMEOUT_MS } from "./worker-probe-policy"; @@ -407,7 +408,7 @@ async function encryptViaWorkerInner( // would silently produce a container with no share slot on a browser // where the Worker failed to start — a backup the heirs cannot open, // reported as success. - const { addShamirSlotKeym2 } = await import("./keym-v2"); + const { addShamirSlotKeym2 } = await loadKeym2(); const enrolled = await addShamirSlotKeym2( new Uint8Array(out), { password, keyFile: keyFileForSlots }, @@ -425,7 +426,7 @@ async function encryptViaWorkerInner( // must not quietly produce a container with no passkey slot, reported as // success. if (passkey) { - const { addPasskeySlotKeym2 } = await import("./keym-v2"); + const { addPasskeySlotKeym2 } = await loadKeym2(); const enrolled = await addPasskeySlotKeym2( new Uint8Array(out), { password, keyFile: keyFileForSlots }, diff --git a/src/lib/crypto-worker.ts b/src/lib/crypto-worker.ts index 031ee18..c461c6a 100644 --- a/src/lib/crypto-worker.ts +++ b/src/lib/crypto-worker.ts @@ -42,6 +42,7 @@ import { loadHashWasm, type KeymakerOptions, type DetectedFormat, + loadKeym2, } from "./keymaker-crypto"; import type { Argon2Sample } from "./kdf-calibration"; @@ -246,7 +247,7 @@ ctx.addEventListener("message", async (event: MessageEvent) => { // §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 { addShamirSlotKeym2 } = await loadKeym2(); const enrolled = await addShamirSlotKeym2( new Uint8Array(out), { password: req.password, keyFile: keyFileForSlots }, @@ -265,7 +266,7 @@ ctx.addEventListener("message", async (event: MessageEvent) => { // 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 { addPasskeySlotKeym2 } = await loadKeym2(); const enrolled = await addPasskeySlotKeym2( new Uint8Array(out), { password: req.password, keyFile: keyFileForSlots }, diff --git a/src/lib/keym-v2.ts b/src/lib/keym-v2.ts index d49f7b6..7cc74fb 100644 --- a/src/lib/keym-v2.ts +++ b/src/lib/keym-v2.ts @@ -1204,9 +1204,12 @@ export interface Keym2Secrets { * 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. + * + * Exported, like keymaker-crypto.ts's `loadKeym2`, so nothing else needs a bare + * `import()` of the Shamir module; scripts/secret-erase-core-test.mjs fails on one. */ let shamirModulePromise: Promise | null = null; -function loadShamir(): Promise { +export function loadShamir(): Promise { if (!shamirModulePromise) { shamirModulePromise = import("./keym-v2-shamir").catch((cause: unknown) => { shamirModulePromise = null; diff --git a/src/lib/keymaker-crypto.ts b/src/lib/keymaker-crypto.ts index 8a4717f..dce1a9f 100644 --- a/src/lib/keymaker-crypto.ts +++ b/src/lib/keymaker-crypto.ts @@ -549,9 +549,13 @@ export function isArgon2idAvailable(): Promise { * browser's own error, and a decrypt reported it as a wrong password. Same * answer as `loadHashWasm`, `loadNoble` and keym-v2.ts's `loadShamir`: a typed * `dependency-unavailable` error, and the rejection is not cached. + * + * Exported because this is the only way into keym-v2.ts that is not a static + * import. scripts/secret-erase-core-test.mjs fails on any bare `import()` of it + * elsewhere in src, since each one is a place the same failure could return. */ let keym2Promise: Promise | null = null; -function loadKeym2(): Promise { +export function loadKeym2(): Promise { if (!keym2Promise) { keym2Promise = import("./keym-v2").catch((cause: unknown) => { keym2Promise = null;