diff --git a/CHANGELOG.md b/CHANGELOG.md index bc9a6d1..7fa0ba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## Unreleased ### Added +- **A "Format detail" switch.** Header bytes, offsets and KDF parameters are + now off by default, in the container pane, the receipt, the Recovery tab + and the Decrypt tab's format line, and the self-extract notice keeps its + trade-off without the per-format reasons. The switch in the container pane + shows them. KDF and cipher names, the format version, the byte map and + every warning stay on screen either way. The switch is not stored. - **The steps of making a backup are shown on the Encrypt tab.** Content, access rule, review, create, check saved copy and prepare recovery, each marked with what the page actually knows. It is status, not a wizard, and diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index de642dc..b3a29bb 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1031,7 +1031,7 @@ Both are corrected with the assurance-language work. | 9.2 | merged | PR #226, in `main` at `60567ac` (`scripts/verify-transport-test.mts` present) | | 9.3 | merged | PR #229, in `main` at `6aff83d` (PR #228 merged into its stacked base, not `main`, so #229 carried the same commit `487f8be`); `encryptKeym2WithSlots` present | | 9.4 | merged | PR #227, in `main` at `2adefa3` (`shamirSplit` erases the coefficients it draws) | -| 9.6 | in_progress | Section 06, the creation workflow. 06a below; the visible re-sequencing, the expert view and the recovery-test lifetimes follow | +| 9.6 | in_progress | Section 06, the creation workflow. Parts a (evidence), b (visible step order) and c (format detail switch) below; the recovery-test lifetimes, screenshots and usability notes follow | `verified` means the implementation and its automated checks passed. It says nothing about an independent review. @@ -1268,6 +1268,50 @@ following the form, the job and a download; a saved copy done only after every printed symbol matched, with one of several shown as partial; and recovery done only after a rehearsal. +### 9.6 The creation workflow (handover Section 06), part c + +The expert view. Format and KDF detail used to be on screen by default: +header bytes and offsets in the inspector, full KDF parameters on the +receipt, the Recovery tab and the Decrypt tab's "Format:" line, and a list +of per-format reasons under the self-extract notice after every text +encrypt with the default Argon2id. + +One switch, **Format detail**, in the inspector header, now controls them. +It is off by default and it is not stored: the app keeps nothing between +visits, so every load starts with it off. + +Off, the page leaves out the header hex row, the magic, version-byte and +offset line, the "salts and nonces" line, KDF parameters (memory, time cost, +parallelism, iterations), and the self-extract notice's list of reasons. + +Off, the page still shows the byte map (BAR.md keeps the preview beside the +form), every KDF and cipher name, the ways in, the version pill, the "Format: +KEYM vN" line, every check, and every warning: the unlock cost, the v2 slot +table, the weak-KDF heads-up and the slot-table change. + +`src/lib/detail-level.ts` does the trimming. The labels come in two shapes. +The receipt and the inspector join segments with " · ", and a parameter is +always a segment of its own there. The readers behind the "Format:" line put +parameters in brackets, as in "PBKDF2 (1,000,000 iters)". Only a whole +segment, or a parameter token inside brackets, is ever dropped. A bracket with +no parameter in it, such as "(HKDF-SHA-256)", is kept, and running text is +never touched, so a warning that quotes a number survives whole. The browser +spec found the bracket shape: the first version trimmed only segments. + +**Not yet done in Section 06.** The verify result that outlives its input, +testing from the Recovery tab wiping the receipt, the auto-lock clearing +evidence while the shares stay open, and the production screenshots with +usability notes. + +**Checks.** `npm run test:backup-workflow` holds the trimming: names kept, +parameters dropped, the unlock line's version, cipher, slot note and key file +kept, and a weak-KDF warning's numbers kept, in both shapes. Dropping any +segment with a digit, skipping the bracket trimming, or ignoring the switch +each fail their own checks. `tests/browser/workflow-expert-view.spec.ts` reads each screen both +ways: the inspector, the receipt, the parsed slot rows, the Recovery tab, the +unlock line and the self-extract notice. `container-inspector.spec.ts` now +turns the switch on, since it is about the bytes. + --- ## Ongoing — not a phase, a standing obligation diff --git a/scripts/backup-workflow-test.mts b/scripts/backup-workflow-test.mts index 9565cfa..4f034fa 100644 --- a/scripts/backup-workflow-test.mts +++ b/scripts/backup-workflow-test.mts @@ -15,6 +15,10 @@ * done on evidence the page does not have: a started download or print is * never a checked copy, a partial printout check is never a whole one, and a * rehearsal that has not run is never a prepared recovery. + * + * Part c adds the "Format detail" switch. Off, KDF parameters go and + * everything else stays: the KDF and cipher names, the version, and every + * warning, including one that quotes a number. */ import { describeAccessRule, @@ -30,6 +34,7 @@ import { type Workflow, type WorkflowEvent, } from "../src/lib/backup-workflow.ts"; +import { atDetail, withoutKdfParameters } from "../src/lib/detail-level.ts"; let passed = 0; let failed = 0; @@ -251,5 +256,53 @@ check("an AND way in keeps its own wording", eq(mergePrintoutCoverage(null, [{ kind: "backup", belongs: "yes" }]) ?? {}, { checked: [1], total: 1 })); } +// --------------------------------------------------------------------------- +// Part c: the format detail switch. +// --------------------------------------------------------------------------- +{ + const trim = withoutKdfParameters; + check("Argon2id keeps its name and loses its parameters", + trim("Argon2id · 64 MiB · t=3 · p=4") === "Argon2id", trim("Argon2id · 64 MiB · t=3 · p=4")); + check("PBKDF2 keeps its name and loses its iterations", + trim("PBKDF2 · 1,000,000 iterations") === "PBKDF2"); + check("a passkey slot keeps both algorithm names", + trim("WebAuthn PRF · HKDF-SHA-256") === "WebAuthn PRF · HKDF-SHA-256"); + check("a password-and-shares slot keeps \"both needed\"", + trim("both needed · Argon2id · 64 MiB") === "both needed · Argon2id"); + const unlocked = + "Format: KEYM v3 · PBKDF2 · 1,000,000 iterations · AES-256-GCM · 2 slots (read from the file, not authenticated)" + + " · key file"; + check("the unlock line keeps the version, the cipher, the slot note and the key file", + trim(unlocked) === "Format: KEYM v3 · PBKDF2 · AES-256-GCM · 2 slots (read from the file, not authenticated) · key file", + trim(unlocked)); + const warned = + "Format: KEYM v3 · PBKDF2 · 100,000 iterations · AES-256-GCM — Heads up: this backup was made with " + + "100,000 PBKDF2 iterations, below the 1,000,000 this version writes. It opened fine."; + check("a weak-KDF warning keeps the numbers it quotes", + trim(warned).includes("made with 100,000 PBKDF2 iterations, below the 1,000,000 this version writes"), trim(warned)); + check("a label with no parameters is unchanged", trim("AES-256-GCM") === "AES-256-GCM"); + // The readers' shape, which the unlock line uses: parameters in brackets. + check("the reader's PBKDF2 label loses its bracketed iterations", + trim("Format: KEYM v3 · PBKDF2 (1,000,000 iters) · AES-256-GCM") === "Format: KEYM v3 · PBKDF2 · AES-256-GCM", + trim("Format: KEYM v3 · PBKDF2 (1,000,000 iters) · AES-256-GCM")); + check("the reader's Argon2id label loses its bracketed parameters", + trim("Argon2id (64 MiB, t=3, p=4)") === "Argon2id", trim("Argon2id (64 MiB, t=3, p=4)")); + check("a both-needed slot keeps the KDF name inside its bracket", + trim("password and share set, both needed (PBKDF2 1,000,000 iters)") === + "password and share set, both needed (PBKDF2)", + trim("password and share set, both needed (PBKDF2 1,000,000 iters)")); + check("a bracket naming an algorithm is kept", + trim("passkey / WebAuthn PRF (HKDF-SHA-256)") === "passkey / WebAuthn PRF (HKDF-SHA-256)"); + const readerWarned = + "Format: KEYM v3 · PBKDF2 (100,000 iters) · AES-256-GCM — Heads up: this backup was made with " + + "100,000 PBKDF2 iterations, below the 1,000,000 this version writes. It opened fine."; + check("the reader's shape keeps a weak-KDF warning's numbers too", + trim(readerWarned).includes("made with 100,000 PBKDF2 iterations, below the 1,000,000 this version writes") && + !trim(readerWarned).includes("(100,000 iters)"), + trim(readerWarned)); + check("with format detail on, nothing is trimmed", + atDetail("Argon2id · 64 MiB · t=3 · p=4", true) === "Argon2id · 64 MiB · t=3 · p=4"); +} + console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); diff --git a/src/components/container-inspector.tsx b/src/components/container-inspector.tsx index 9f6e59c..997964d 100644 --- a/src/components/container-inspector.tsx +++ b/src/components/container-inspector.tsx @@ -42,6 +42,8 @@ import { import { cn } from "@/lib/utils"; import type { WayIn } from "@/lib/access-policy"; import { SealedStatus } from "@/components/sealed-status"; +import { Switch } from "@/components/ui/switch"; +import { atDetail } from "@/lib/detail-level"; /** What the encrypt form has declared, restated — not predicted. */ export interface InspectorPlan { @@ -298,7 +300,7 @@ function parsePeek(peek: Uint8Array): ParsedPeek | "legacy" | null { const rowClasses = "flex items-baseline gap-2.5 border-t border-border px-4 py-2 text-[12.5px]"; -function SlotList({ slots }: { slots: SlotRow[] }) { +function SlotList({ slots, formatDetail }: { slots: SlotRow[]; formatDetail: boolean }) { return (
{slots.map((slot) => ( @@ -308,7 +310,7 @@ function SlotList({ slots }: { slots: SlotRow[] }) { {slot.label} - {slot.detail} + {atDetail(slot.detail, formatDetail)}
))} @@ -331,6 +333,8 @@ export function ContainerInspector({ peek, className, sealing = false, + formatDetail = true, + onFormatDetailChange, }: { mode: "encrypt" | "decrypt"; plan: InspectorPlan | null; @@ -338,6 +342,14 @@ export function ContainerInspector({ className?: string; /** True while the worker is writing the container the plan describes. */ sealing?: boolean; + /** + * Section 06c. Off, the pane leaves out header bytes, offsets and KDF + * parameters. The byte map, the names, the version and every check and + * warning stay. See `lib/detail-level.ts`. + */ + formatDetail?: boolean; + /** Present when the page offers the switch. */ + onFormatDetailChange?: (on: boolean) => void; }) { const parsed = useMemo(() => (peek ? parsePeek(peek) : null), [peek]); @@ -394,11 +406,26 @@ export function ContainerInspector({ )} + {onFormatDetailChange && ( +
+ + +
+ )} {/* ── The bytes ─────────────────────────────────────────────── */} {parsed && parsed !== "legacy" ? ( <> -
+ {formatDetail && ( + <> +
0000 {parsed.headHex.map((hex, i) => (
+ + )} {/* Widths from the parsed offsets, so the map cannot disagree with the rows below it. `slots.length` rather than the count byte: a @@ -433,7 +462,7 @@ export function ContainerInspector({

{parsed.slotCount === 1 ? "1 slot" : `${parsed.slotCount} slots`} · ways in

- +
Header declares {parsed.cipherLabel} @@ -488,7 +517,9 @@ export function ContainerInspector({ ) : mode === "encrypt" && plan ? ( <> -
+ {formatDetail && ( + <> +
0000 {["4B", "45", "59", "4D"].map((hex) => ( @@ -503,6 +534,8 @@ export function ContainerInspector({

salts and nonces are drawn fresh at seal time

+ + )} {/* The plan side of the same map: version is what this app writes, slot count is the ways-in the form has declared. Restated, not @@ -522,7 +555,7 @@ export function ContainerInspector({ {way.keyFile ? "Passphrase + key file" : "Passphrase"}
- {plan.kdfLabel} + {atDetail(plan.kdfLabel, formatDetail)} ) : way.kind === "shares" ? ( @@ -538,7 +571,7 @@ export function ContainerInspector({ {way.keyFile ? "Passphrase + key file" : "Passphrase"} and share set - any {way.threshold} of {way.count}, both needed · {plan.kdfLabel} + any {way.threshold} of {way.count}, both needed · {atDetail(plan.kdfLabel, formatDetail)} ) : ( diff --git a/src/components/encryptor-tool.tsx b/src/components/encryptor-tool.tsx index 85f004f..776f33f 100644 --- a/src/components/encryptor-tool.tsx +++ b/src/components/encryptor-tool.tsx @@ -46,7 +46,7 @@ export function EncryptorTool() { mode, workspacePage, compactNavigation, isLoading, qrScanBusy, isApplePlatform, setIsCommandBarOpen, isCommandBarOpen, navItems, pageCopy, activePage, navigateWorkspace, currentDoor, openDoor, - openInheritance, inspectorPlan, sealedPeek, decryptPeek, + openInheritance, inspectorPlan, sealedPeek, decryptPeek, formatDetail, setFormatDetail, commandBarCommands, paperVault, cameraOpen, setCameraOpen, handleQrImageFiles, setIsRecoveryOpen, steps, } = state; @@ -135,7 +135,7 @@ export function EncryptorTool() { {workspacePage === "workbench" && (mode === "encrypt" || mode === "decrypt") && ( - + )}
{(mode === "encrypt" || mode === "decrypt") && ( diff --git a/src/components/encryptor/recovery-tab.tsx b/src/components/encryptor/recovery-tab.tsx index b1a6f28..16833b2 100644 --- a/src/components/encryptor/recovery-tab.tsx +++ b/src/components/encryptor/recovery-tab.tsx @@ -10,13 +10,14 @@ import { Button } from "@/components/ui/button"; import { TabsContent } from "@/components/ui/tabs"; import { BASE_PATH, formatBytes } from "./shared"; import { useEncryptorContext } from "./context"; +import { atDetail } from "@/lib/detail-level"; export function RecoveryTab() { const { mode, receipt, rehearsal, downloadContainer, printPaperVault, handleModeChange, returnToBackupTest, printoutInputRef, checkPrintout, printoutBusy, printoutFindings, setIsRecoveryOpen, openInheritance, - exportsStarted, backupDiffers, + exportsStarted, backupDiffers, formatDetail, } = useEncryptorContext(); // Local time, to the minute: when the page asked, not when anything was kept. const when = (iso: string) => @@ -30,7 +31,7 @@ export function RecoveryTab() { {mode === "encrypt" && receipt ? ( <>

Container created · {formatBytes(receipt.bytes)}

-

{receipt.cipher} · {receipt.kdf}

+

{receipt.cipher} · {atDetail(receipt.kdf, formatDetail)}

{backupDiffers && (

The form on the Encrypt tab has changed since this backup was made ({backupDiffers.join(", ")}). diff --git a/src/components/encryptor/secret-form.tsx b/src/components/encryptor/secret-form.tsx index 8b2d830..faff979 100644 --- a/src/components/encryptor/secret-form.tsx +++ b/src/components/encryptor/secret-form.tsx @@ -71,6 +71,7 @@ import { PASSPHRASE_ENTROPY_BITS, } from "./shared"; import { useEncryptorContext } from "./context"; +import { atDetail } from "@/lib/detail-level"; export function SecretForm({ mode }: { mode: Mode }) { const { @@ -105,7 +106,7 @@ export function SecretForm({ mode }: { mode: Mode }) { blockedByPasswordPolicy, isProcessButtonDisabled, printPaperVault, rehearseFromPaper, downloadContainer, clipboardSecondsLeft, clipboardClearPending, clearClipboardNow, lockSecondsLeft, wipeAck, - wipeNow, backupDiffers, accessRule, + wipeNow, backupDiffers, accessRule, formatDetail, } = useEncryptorContext(); // Key-file toggle + picker/generator. Rendered in place on the Decrypt @@ -1338,7 +1339,7 @@ export function SecretForm({ mode }: { mode: Mode }) { : "this password"}

- {verifyResult.detail} · {formatBytes(verifyResult.bytes)} of contents, + {atDetail(verifyResult.detail, formatDetail)} · {formatBytes(verifyResult.bytes)} of contents, authenticated and discarded without being shown.

{!verifyResult.inWorker && ( @@ -1356,7 +1357,7 @@ export function SecretForm({ mode }: { mode: Mode }) { ) : decryptInfo && mode === 'decrypt' ? (

- {decryptInfo} + {atDetail(decryptInfo, formatDetail)}

) : null} @@ -1437,7 +1438,7 @@ export function SecretForm({ mode }: { mode: Mode }) {
Protected by
- {receipt.kdf} + {atDetail(receipt.kdf, formatDetail)} {" · "} {receipt.cipher}
@@ -1706,7 +1707,7 @@ export function SecretForm({ mode }: { mode: Mode }) { place the choice can be taught at the moment it means anything. */} {mode === 'encrypt' && outputText.startsWith('keym2:') && ( - + )} {/* diff --git a/src/components/encryptor/use-encryptor-state.ts b/src/components/encryptor/use-encryptor-state.ts index 8a2537d..3abbb04 100644 --- a/src/components/encryptor/use-encryptor-state.ts +++ b/src/components/encryptor/use-encryptor-state.ts @@ -189,6 +189,13 @@ export function useEncryptorState() { // Advanced encryption options (Encrypt tab only — the KEYM container is // self-describing, so decryption needs no knobs). const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); + /** + * Section 06, part c. "Format detail": KDF parameters, header bytes and + * offsets. Off by default and never stored, since the app keeps nothing + * between visits. Off still names every KDF and cipher, every way in, the + * version and every warning; see `lib/detail-level.ts`. + */ + const [formatDetail, setFormatDetail] = useState(false); // Argon2id is the default: the user who never opens Advanced should get the // memory-hard KDF, not the weaker one. It needs WebAssembly, though, so // availability is probed on mount and we fall back visibly rather than @@ -3236,6 +3243,7 @@ export function useEncryptorState() { showPassword, setShowPassword, showTextSecret, setShowTextSecret, isAdvancedOpen, setIsAdvancedOpen, + formatDetail, setFormatDetail, kdfChoice, setKdfChoice, argon2Available, setArgon2Available, argonTimeCost, setArgonTimeCost, diff --git a/src/components/self-extract-export.tsx b/src/components/self-extract-export.tsx index 04d4ffa..3a49c77 100644 --- a/src/components/self-extract-export.tsx +++ b/src/components/self-extract-export.tsx @@ -34,7 +34,18 @@ function triggerDownload(text: string, filename: string) { URL.revokeObjectURL(url); } -export function SelfExtractExport({ armored }: { armored: string }) { +export function SelfExtractExport({ + armored, + formatDetail = true, +}: { + armored: string; + /** + * Section 06c. Off, the notice keeps its heading and the trade it names + * (PBKDF2 and AES-256-GCM, easier to open later, weaker today) and leaves + * out the per-format reasons, which the "Format detail" switch shows. + */ + formatDetail?: boolean; +}) { const [done, setDone] = useState(false); // The container is parsed once per output, not per render: dearmoring a @@ -58,11 +69,13 @@ export function SelfExtractExport({ armored }: { armored: string }) {

A self-extracting page is not available for this backup

-
    - {reasons.map((r) => ( -
  • {r}
  • - ))} -
+ {formatDetail && reasons.length > 0 && ( +
    + {reasons.map((r) => ( +
  • {r}
  • + ))} +
+ )}

The page can only use what a browser has built in for ever, which means AES-256-GCM and PBKDF2. Choose those before encrypting if you want one — diff --git a/src/lib/detail-level.ts b/src/lib/detail-level.ts new file mode 100644 index 0000000..c0d92a1 --- /dev/null +++ b/src/lib/detail-level.ts @@ -0,0 +1,55 @@ +/** + * How much format detail the page shows (roadmap Section 06, part c). + * + * The page has one switch, "Format detail", off by default and not stored: + * the app keeps nothing between visits. Off, the page still names every KDF + * and cipher, every way in, the format version and every warning. What it + * leaves out are the numbers only someone checking the format needs: KDF + * parameters, header bytes and offsets. + * + * The labels this trims are built elsewhere in two shapes. The receipt and + * the inspector join segments with " · ", and there a parameter is always a + * segment of its own. The unlock's "Format:" line comes from the readers, + * which put parameters in brackets: "PBKDF2 (1,000,000 iters)", "Argon2id + * (64 MiB, t=3, p=4)", "both needed (PBKDF2 1,000,000 iters)". Only a whole + * segment, or a token inside brackets, is ever dropped. A bracket with no + * parameter in it ("(HKDF-SHA-256)", "(read from the file, not + * authenticated)") is left alone, and so is running text, which is what + * keeps a warning that quotes a number intact. + */ + +const SEPARATOR = " · "; + +/** Exactly a KDF parameter: "64 MiB", "t=3", "p=4", "1,000,000 iterations". */ +const PARAMETER = /^(?:\d+ MiB|t=\d+|p=\d+|[\d,]+ iterations)$/; + +/** A parameter inside brackets, with the space before it. */ +const BRACKETED_PARAMETER = /\s*(?:[\d,]+ (?:iters|iterations)|\d+ MiB|t=\d+|p=\d+)(?![\w=])/g; + +/** "(64 MiB, t=3, p=4)" goes; "(PBKDF2 1,000,000 iters)" becomes "(PBKDF2)". */ +function trimBrackets(text: string): string { + return text.replace(/ \(([^()]*)\)/g, (whole, inner: string) => { + BRACKETED_PARAMETER.lastIndex = 0; + if (!BRACKETED_PARAMETER.test(inner)) return whole; + const rest = inner + .replace(BRACKETED_PARAMETER, "") + .replace(/(?:,\s*)+/g, ", ") + .replace(/^[,\s]+|[,\s]+$/g, ""); + return rest === "" ? "" : ` (${rest})`; + }); +} + +/** The label without its KDF parameters. Unchanged when it has none. */ +export function withoutKdfParameters(label: string): string { + return trimBrackets( + label + .split(SEPARATOR) + .filter((segment) => !PARAMETER.test(segment.trim())) + .join(SEPARATOR) + ); +} + +/** The label as the current detail level shows it. */ +export function atDetail(label: string, formatDetail: boolean): string { + return formatDetail ? label : withoutKdfParameters(label); +} diff --git a/tests/browser/container-inspector.spec.ts b/tests/browser/container-inspector.spec.ts index 1e19d3f..bedf602 100644 --- a/tests/browser/container-inspector.spec.ts +++ b/tests/browser/container-inspector.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from "@playwright/test"; -import { visible, useTextMode, encryptText, decryptText, STRONG_PASSWORD } from "./helpers"; +import { visible, useTextMode, encryptText, decryptText, showFormatDetail, STRONG_PASSWORD } from "./helpers"; import { armorKeym2, dearmorKeym2, keym2SlotCountOffset, KEYM2_VERSION_V3 } from "../../src/lib/keym-v2"; /** @@ -58,6 +58,9 @@ async function enableShares(page: Page, k: number, n: number) { test.beforeEach(async ({ page }) => { await page.goto("/"); + // This file is about the bytes, so it reads them with format detail on. + // workflow-expert-view.spec.ts covers what the pane shows with it off. + await showFormatDetail(page); }); test("encrypt: with no input the pane summarises instead of itemising", async ({ page }) => { diff --git a/tests/browser/helpers.ts b/tests/browser/helpers.ts index 382a0d4..f3c6309 100644 --- a/tests/browser/helpers.ts +++ b/tests/browser/helpers.ts @@ -1,3 +1,4 @@ +import { expect } from "@playwright/test"; import type { Page, Locator } from "@playwright/test"; /** @@ -229,3 +230,13 @@ export async function composePhoto(page: Page, pngs: Buffer[]): Promise }, pngs.map((b) => `data:image/png;base64,${b.toString("base64")}`)); return Buffer.from(url.split(",")[1] as string, "base64"); } + +/** + * Turn on "Format detail" (Section 06c): header bytes, offsets and KDF + * parameters. Off by default and not stored, so every page load starts off. + */ +export async function showFormatDetail(page: Page) { + const sw = visible(page.getByTestId("format-detail-switch")); + if ((await sw.getAttribute("aria-checked")) !== "true") await sw.click(); + await expect(sw).toHaveAttribute("aria-checked", "true"); +} diff --git a/tests/browser/self-extract.spec.ts b/tests/browser/self-extract.spec.ts index dc04c5d..a7a3f27 100644 --- a/tests/browser/self-extract.spec.ts +++ b/tests/browser/self-extract.spec.ts @@ -4,7 +4,7 @@ import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; -import { visible, useTextMode, selectCrypto, STRONG_PASSWORD } from "./helpers"; +import { visible, useTextMode, selectCrypto, showFormatDetail, STRONG_PASSWORD } from "./helpers"; const REPO_ROOT = resolve(__dirname, "../.."); const BRIDGE = resolve(REPO_ROOT, "reference/bridge.mjs"); @@ -201,6 +201,8 @@ test.describe("§7.2 self-extracting page", () => { test("an Argon2id backup is refused, and says what would have to change", async ({ page }) => { await encryptWith(page, "argon2id"); + // The per-format reasons are format detail (Section 06c). + await showFormatDetail(page); await expect(visible(page.getByTestId("selfextract-download"))).toHaveCount(0); const notice = visible(page.getByTestId("selfextract-unavailable")); diff --git a/tests/browser/workflow-expert-view.spec.ts b/tests/browser/workflow-expert-view.spec.ts new file mode 100644 index 0000000..ba8a72c --- /dev/null +++ b/tests/browser/workflow-expert-view.spec.ts @@ -0,0 +1,100 @@ +import { test, expect, type Page } from "@playwright/test"; +import { visible, useTextMode, selectCrypto, encryptText, decryptText, showFormatDetail, STRONG_PASSWORD } from "./helpers"; + +/** + * The "Format detail" switch (roadmap Section 06, part c). + * + * Off by default and not stored. Off, the page leaves out KDF parameters, + * header bytes and offsets. It still names the KDF and cipher, the ways in + * and the version, keeps the byte map, and keeps every warning. On, it shows + * what it always showed. Each test reads the same screen both ways, so a + * switch that hid nothing, or hid too much, fails here. + */ + +const SECRET = "expert-view synthetic secret, not a real one"; +const pane = (page: Page) => visible(page.getByTestId("container-inspector")); +const detailSwitch = (page: Page) => visible(page.getByTestId("format-detail-switch")); + +test("the inspector leaves out bytes and KDF parameters until format detail is on", async ({ page }) => { + await page.goto("/"); + await useTextMode(page); + await expect(detailSwitch(page)).toHaveAttribute("aria-checked", "false"); + + // Real input opens the itemisation; the default KDF is Argon2id. + await visible(page.getByPlaceholder("Enter text to encrypt")).fill(SECRET); + await expect(pane(page)).toContainText("ways in · as configured"); + await expect(pane(page)).toContainText("Argon2id"); + await expect(pane(page)).toContainText("KEYM v3"); + await expect(pane(page)).toContainText("Header layout"); + await expect(page.getByTestId("inspector-hex")).toHaveCount(0); + await expect(pane(page)).not.toContainText("salts and nonces"); + await expect(pane(page)).not.toContainText("MiB"); + await expect(pane(page)).not.toContainText(/\bt=\d/); + + await showFormatDetail(page); + // The gaps between bytes are margins, so the text runs together. + await expect(visible(page.getByTestId("inspector-hex"))).toContainText("4B45594D"); + await expect(pane(page)).toContainText("salts and nonces are drawn fresh at seal time"); + await expect(pane(page)).toContainText(/Argon2id · \d+ MiB · t=\d+ · p=\d+/); + + // Not stored: a reload starts with it off again. + await page.reload(); + await expect(detailSwitch(page)).toHaveAttribute("aria-checked", "false"); +}); + +test("the receipt, the parsed slot rows and the Recovery tab name the KDF without its parameters", async ({ page }) => { + await page.goto("/"); + await useTextMode(page); + await selectCrypto(page, "pbkdf2", "aes"); + await encryptText(page, SECRET, STRONG_PASSWORD); + + const kdf = visible(page.getByTestId("receipt-kdf")); + const firstSlot = page.getByTestId("inspector-slot-row").first(); + await expect(kdf).toHaveText("PBKDF2"); + await expect(firstSlot).toContainText("PBKDF2"); + await expect(firstSlot).not.toContainText("iterations"); + await expect(page.getByTestId("inspector-hex")).toHaveCount(0); + + await showFormatDetail(page); + await expect(kdf).toHaveText("PBKDF2 · 1,000,000 iterations"); + await expect(firstSlot).toContainText("PBKDF2 · 1,000,000 iterations"); + await expect(visible(page.getByTestId("inspector-hex"))).toBeVisible(); + + // The Recovery tab reads the same switch. + await visible(page.getByRole("tab", { name: "Recovery", exact: true })).click(); + await expect(page.getByText("AES-256-GCM · PBKDF2 · 1,000,000 iterations")).toBeVisible(); + await visible(page.getByRole("tab", { name: "Encrypt", exact: true })).click(); + await detailSwitch(page).click(); + await expect(detailSwitch(page)).toHaveAttribute("aria-checked", "false"); + await visible(page.getByRole("tab", { name: "Recovery", exact: true })).click(); + await expect(page.getByText("AES-256-GCM · PBKDF2", { exact: true })).toBeVisible(); +}); + +test("the unlock line keeps the version and cipher, and drops only the parameters", async ({ page }) => { + await page.goto("/"); + await useTextMode(page); + await selectCrypto(page, "pbkdf2", "aes"); + const container = await encryptText(page, SECRET, STRONG_PASSWORD); + await decryptText(page, container, STRONG_PASSWORD); + + const line = visible(page.getByText(/^Format: KEYM v3/)); + await expect(line).toHaveText("Format: KEYM v3 · PBKDF2 · AES-256-GCM"); + await showFormatDetail(page); + await expect(line).toHaveText("Format: KEYM v3 · PBKDF2 (1,000,000 iters) · AES-256-GCM"); +}); + +test("the self-extract notice keeps its trade-off and leaves the per-format reasons to format detail", async ({ page }) => { + await page.goto("/"); + await useTextMode(page); + await selectCrypto(page, "argon2id", "aes"); + await encryptText(page, SECRET, STRONG_PASSWORD); + + const notice = visible(page.getByTestId("selfextract-unavailable")); + await expect(notice).toContainText("A self-extracting page is not available for this backup"); + await expect(notice).toContainText("AES-256-GCM and PBKDF2"); + await expect(notice).toContainText(/weaker/i); + await expect(page.getByTestId("selfextract-reasons")).toHaveCount(0); + + await showFormatDetail(page); + await expect(visible(page.getByTestId("selfextract-reasons"))).toContainText(/WebAssembly/i); +});