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 (
{parsed.slotCount === 1 ? "1 slot" : `${parsed.slotCount} slots`} · ways in
-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() {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' ? (
A self-extracting page is not available for this backup
-
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