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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 45 additions & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions scripts/backup-workflow-test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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);
47 changes: 40 additions & 7 deletions src/components/container-inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (
<div>
{slots.map((slot) => (
Expand All @@ -308,7 +310,7 @@ function SlotList({ slots }: { slots: SlotRow[] }) {
</span>
<span className="font-medium text-foreground">{slot.label}</span>
<span className="ml-auto text-right font-mono text-[12px] text-muted-foreground">
{slot.detail}
{atDetail(slot.detail, formatDetail)}
</span>
</div>
))}
Expand All @@ -331,13 +333,23 @@ export function ContainerInspector({
peek,
className,
sealing = false,
formatDetail = true,
onFormatDetailChange,
}: {
mode: "encrypt" | "decrypt";
plan: InspectorPlan | null;
peek: Uint8Array | null;
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]);

Expand Down Expand Up @@ -394,11 +406,26 @@ export function ContainerInspector({
</span>
)}
</header>
{onFormatDetailChange && (
<div className="flex items-center gap-2 px-4 pb-2">
<Switch
id="format-detail"
checked={formatDetail}
onCheckedChange={onFormatDetailChange}
data-testid="format-detail-switch"
/>
<label htmlFor="format-detail" className="cursor-pointer text-[12px] text-muted-foreground">
Format detail (header bytes, offsets, KDF parameters)
</label>
</div>
)}

{/* ── The bytes ─────────────────────────────────────────────── */}
{parsed && parsed !== "legacy" ? (
<>
<div className="mx-4 overflow-x-auto rounded-md border border-border bg-background px-3 py-2 font-mono text-[12px] leading-relaxed">
{formatDetail && (
<>
<div data-testid="inspector-hex" className="mx-4 overflow-x-auto rounded-md border border-border bg-background px-3 py-2 font-mono text-[12px] leading-relaxed">
<span className="mr-2 text-subtle-foreground">0000</span>
{parsed.headHex.map((hex, i) => (
<span
Expand All @@ -422,6 +449,8 @@ export function ContainerInspector({
slot count @ 0x{parsed.slotCountOffset.toString(16).toUpperCase().padStart(2, "0")}
</span>
</div>
</>
)}

{/* Widths from the parsed offsets, so the map cannot disagree with
the rows below it. `slots.length` rather than the count byte: a
Expand All @@ -433,7 +462,7 @@ export function ContainerInspector({
<p className="px-4 pb-1 pt-3 font-mono text-[12px] uppercase tracking-[0.1em] text-subtle-foreground">
{parsed.slotCount === 1 ? "1 slot" : `${parsed.slotCount} slots`} · ways in
</p>
<SlotList slots={parsed.slots} />
<SlotList slots={parsed.slots} formatDetail={formatDetail} />

<div className="mt-auto space-y-1.5 border-t border-border px-4 py-3">
<Check>Header declares {parsed.cipherLabel}</Check>
Expand Down Expand Up @@ -488,7 +517,9 @@ export function ContainerInspector({
</>
) : mode === "encrypt" && plan ? (
<>
<div className="mx-4 overflow-x-auto rounded-md border border-border bg-background px-3 py-2 font-mono text-[12px] leading-relaxed">
{formatDetail && (
<>
<div data-testid="inspector-hex" className="mx-4 overflow-x-auto rounded-md border border-border bg-background px-3 py-2 font-mono text-[12px] leading-relaxed">
<span className="mr-2 text-subtle-foreground">0000</span>
{["4B", "45", "59", "4D"].map((hex) => (
<span key={hex} className="mr-1.5 font-medium text-foreground">
Expand All @@ -503,6 +534,8 @@ export function ContainerInspector({
<p className="px-4 pb-1 pt-2 font-mono text-[12px] text-subtle-foreground">
salts and nonces are drawn fresh at seal time
</p>
</>
)}

{/* 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
Expand All @@ -522,7 +555,7 @@ export function ContainerInspector({
{way.keyFile ? "Passphrase + key file" : "Passphrase"}
</span>
<span className="ml-auto text-right font-mono text-[12px] text-muted-foreground">
{plan.kdfLabel}
{atDetail(plan.kdfLabel, formatDetail)}
</span>
</>
) : way.kind === "shares" ? (
Expand All @@ -538,7 +571,7 @@ export function ContainerInspector({
{way.keyFile ? "Passphrase + key file" : "Passphrase"} and share set
</span>
<span className="ml-auto text-right font-mono text-[12px] text-muted-foreground">
any {way.threshold} of {way.count}, both needed · {plan.kdfLabel}
any {way.threshold} of {way.count}, both needed · {atDetail(plan.kdfLabel, formatDetail)}
</span>
</>
) : (
Expand Down
4 changes: 2 additions & 2 deletions src/components/encryptor-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -135,7 +135,7 @@ export function EncryptorTool() {
<TabsContent value="tools" className="mt-0" tabIndex={-1} forceMount hidden={mode !== "tools" || workspacePage !== "workbench"}><DiceEntropyTool /></TabsContent>
</section>
{workspacePage === "workbench" && (mode === "encrypt" || mode === "decrypt") && (
<ContainerInspector mode={mode} plan={inspectorPlan} peek={mode === "encrypt" ? sealedPeek : decryptPeek} sealing={isLoading && mode === "encrypt"} className="km-inspector" />
<ContainerInspector mode={mode} plan={inspectorPlan} peek={mode === "encrypt" ? sealedPeek : decryptPeek} sealing={isLoading && mode === "encrypt"} formatDetail={formatDetail} onFormatDetailChange={setFormatDetail} className="km-inspector" />
)}
</div>
{(mode === "encrypt" || mode === "decrypt") && (
Expand Down
5 changes: 3 additions & 2 deletions src/components/encryptor/recovery-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -30,7 +31,7 @@ export function RecoveryTab() {
{mode === "encrypt" && receipt ? (
<>
<p className="text-sm text-foreground">Container created · {formatBytes(receipt.bytes)}</p>
<p className="mt-2 text-[13px] leading-relaxed text-muted-foreground">{receipt.cipher} · {receipt.kdf}</p>
<p className="mt-2 text-[13px] leading-relaxed text-muted-foreground">{receipt.cipher} · {atDetail(receipt.kdf, formatDetail)}</p>
{backupDiffers && (
<p data-testid="recovery-stale" className="mt-2 text-[12.5px] leading-snug text-warning">
The form on the Encrypt tab has changed since this backup was made ({backupDiffers.join(", ")}).
Expand Down
Loading
Loading