Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 42 additions & 1 deletion scripts/secret-erase-core-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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 = /(?<!typeof\s)\bimport\s*\(\s*["'`][^"'`]*\bkeym-v2(?:-shamir)?["'`]\s*\)/g;
const offenders = [];
const walk = (dir) => {
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<typeof import("./keym-v2")>'.match(bare) === null, "the guard ignores a type-only import()");
}
{
const CHUNK = "keym-v2-shamir.enrol-chunk.mjs";
const missingShamir = {
Expand Down
37 changes: 19 additions & 18 deletions src/components/encryptor-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -77,6 +77,7 @@ import {
DEFAULT_ARGON2ID,
type KdfParams,
type DetectedFormat,
loadKeym2,
} from "@/lib/keymaker-crypto";
import {
encryptViaWorker,
Expand Down Expand Up @@ -859,7 +860,7 @@ async function containerFromTextFile(bytes: Uint8Array): Promise<Uint8Array | nu
"Nothing was tried against your password. Recover from another copy."
);
if (text.startsWith(KEYM_V2_TEXT_PREFIX)) {
const { dearmorKeym2 } = await import("@/lib/keym-v2");
const { dearmorKeym2 } = await loadKeym2();
try {
return dearmorKeym2(text);
} catch {
Expand Down Expand Up @@ -1135,8 +1136,8 @@ async function preparePaperParts(
*/
async function containerSetCodes(container: Uint8Array): Promise<string[]> {
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 [];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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('');
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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",
Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -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".
Expand Down Expand Up @@ -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({
Expand All @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down
5 changes: 3 additions & 2 deletions src/lib/crypto-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 },
Expand All @@ -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 },
Expand Down
5 changes: 3 additions & 2 deletions src/lib/crypto-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
loadHashWasm,
type KeymakerOptions,
type DetectedFormat,
loadKeym2,
} from "./keymaker-crypto";
import type { Argon2Sample } from "./kdf-calibration";

Expand Down Expand Up @@ -246,7 +247,7 @@ ctx.addEventListener("message", async (event: MessageEvent<CryptoRequest>) => {
// §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 },
Expand All @@ -265,7 +266,7 @@ ctx.addEventListener("message", async (event: MessageEvent<CryptoRequest>) => {
// 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 },
Expand Down
5 changes: 4 additions & 1 deletion src/lib/keym-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("./keym-v2-shamir")> | null = null;
function loadShamir(): Promise<typeof import("./keym-v2-shamir")> {
export function loadShamir(): Promise<typeof import("./keym-v2-shamir")> {
if (!shamirModulePromise) {
shamirModulePromise = import("./keym-v2-shamir").catch((cause: unknown) => {
shamirModulePromise = null;
Expand Down
6 changes: 5 additions & 1 deletion src/lib/keymaker-crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,9 +549,13 @@ export function isArgon2idAvailable(): Promise<boolean> {
* 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<typeof import("./keym-v2")> | null = null;
function loadKeym2(): Promise<typeof import("./keym-v2")> {
export function loadKeym2(): Promise<typeof import("./keym-v2")> {
if (!keym2Promise) {
keym2Promise = import("./keym-v2").catch((cause: unknown) => {
keym2Promise = null;
Expand Down
Loading