Skip to content

Write a new backup's ways in with one operation (roadmap 9.3) - #228

Merged
404SecNotFound merged 1 commit into
claude/serene-carson-0739mv-secret-buffersfrom
claude/serene-carson-0739mv-one-derivation
Sep 28, 2026
Merged

404SecNotFound merged 1 commit into
claude/serene-carson-0739mv-secret-buffersfrom
claude/serene-carson-0739mv-one-derivation

Conversation

@404SecNotFound

@404SecNotFound 404SecNotFound commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Roadmap 9.3 and handover Section 05. Status: verified (implementation and required automated checks passed; no independent review implied).

Stacked on PR #227 (Section 04), which it builds on and which is not yet merged. This PR shows only its own commit. GitHub retargets it to main when #227 merges, since the repo uses merge commits.

Commits

Observed baseline

Measured through the shipping worker on #227's tree, with the old crypto-worker.ts swapped in and test:slot-transaction run against it.

Ways in Password derivations (PBKDF2 and Argon2id alike)
Password 1
Password or shares 2
Password or passkey 2
Password or shares or passkey 3

An invalid threshold (4 of 3, or 1 of 3) was refused only after one derivation. This matches roadmap 9.0.

Changes

  • encryptKeym2WithSlots (keym-v2.ts) writes the passphrase slot, share set and passkey slot in one operation, in the order the enrolment path adds them.
    • The master key it generates is still in hand, so the password is derived once, and the share and passkey slots take only their HKDF.
    • Refused before any derivation: missing password, bad KDF parameters, unknown version, bad share threshold or count (the split runs first), wrong PRF output or passkey salt size.
    • The master key, share secret, coefficients and share values are erased in finally, per the 9.4 ownership rules. explicit inputs and the PRF output are the caller's and are left untouched.
    • Shares are encoded only after the container exists. A failed write returns no strips.
  • writeKeym2 → writeKeym2Slots, generalised to N slots. Every prefix is validated before any key is derived. The single-slot writers call it with one slot, and their bytes are unchanged.
  • encryptContainerWithSlots (keymaker-crypto.ts) has encryptContainer's validation and key-file contract.
  • Worker and no-worker fallback.
    • A share set or passkey now goes through encryptContainerWithSlots.
    • §4.8 (password AND shares) keeps its own writer and is not treated as an OR.
    • Enrolling into an existing backup is unchanged.
  • Key file. The worker now reads it once, so the page transfers it on every path instead of cloning it and erasing its copy. Two assertions in secret-erase-test.mjs are tightened from "zeroed" to "detached", which means no page copy exists at all.
  • Fixtures. v3-slots-{aes256gcm,chacha20poly1305,chained} are appended, each with a password, a 3-of-5 share set and a passkey. The corpus had no container with all three. Earlier entries are untouched: fixtures.json is +69 lines, −0. The corpus count moves from 42 to 45 in both keymaker-regression.mts and crosstest2.py.
  • Parity. A new bridge subcommand encryptslots pins every random input, and crosstest2.py compares its bytes and share strings with the Python reference's three-step path (encrypt, add_shamir_slot, add_passkey_slot).
  • New test:slot-transaction, registered in package.json, README and ci.yml.
  • Docs. ROADMAP 9.3 section and status row, CHANGELOG ("Changed"), and AUDIT-BRIEF row.

Checks (all exit 0)

Check Result
typecheck, build (no base path), test:readme pass (42 scripts documented)
test:slot-transaction (new) 119 passed
test:conformance2 553 passed (was 445): +54 byte-identity, +36 share-string, +18 new-fixture checks
test:keymaker pass, including the three new fixtures via password, 3 of 5 shares, and PRF
test:secret-erase-core pass, including the new one-operation block (success and injected failure)
every other test:* script (41 in total) pass
Chromium full suite, --workers=2 320 passed, 5 skipped, 0 failed

The Python suites ran in a venv built from the hash-pinned reference/requirements.txt.

Performance report

One container (4-core Intel Xeon @ 2.80GHz, Node 22.22.2). The backup has a password, shares and a passkey, with AES-256-GCM, v3 and a 64-byte payload. There were 9 runs of each path, interleaved, after one discarded warm-up.

KDF Three steps (before) One operation (after)
PBKDF2, 1,000,000 iterations median 1670 ms, p95 2041 ms median 558 ms, p95 689 ms
Argon2id, t=3, 64 MiB, p=4 median 1330 ms, p95 1490 ms median 429 ms, p95 499 ms

The saving is in the password derivation only. [Inference] Other devices and settings will show a different ratio, so this is not a claim that creating a backup is three times faster everywhere. KDF parameters are unchanged.

Negative controls (each applied, typechecked, run, then restored)

Control Result
Baseline: the old worker from #227's tree test:slot-transaction fails every multi-way creation (2 or 3 derivations) and both invalid-threshold refusals (1 derivation)
NC1: an extra password derivation in the writer 6 failures ("derived once": 2 derivations)
NC2: share validation moved after the KDF 42 failures, including the refusal counts
NC3: passkey slot written before the share slot 18 byte-identity failures in test:slot-transaction and 18 in crosstest2.py
NC4: the writer keeps its master key census fails after success and after injected failure
NC5: the writer keeps its share values census fails after success and after injected failure (5 values survive)

NC3 did not bite at first. The first version inserted the share slot at index 1, which is where it already goes, so the patch changed nothing. It was rewritten to put the passkey slot first, and then it bit in both suites.

Not independently controlled

The "failed write returns no container and no shares" check has no targeted control. The worker's error path has no route that attaches data or shares, so there was nothing to remove. The check asserts the response shape after a failure injected once all three slots are wrapped, and a guard confirms the failure really landed there.

Fixtures and compatibility

  • No format change. For the same random inputs, the one-operation bytes are identical to the enrolment path's, so a reader cannot tell which path wrote a backup. TypeScript and Python both check this.
  • Three fixtures are appended. None are replaced.
  • §4.8 composition, standalone enrolment and legacy readers are unchanged.

Unrun checks

  • Firefox and WebKit are not installed here, so CI covers them.
  • No physical authenticator. PRF outputs are synthetic.

Remaining risks

  • Erasure is best effort, as in 9.4.
  • Timings come from one machine.

Next action

The owner merges #227, then this PR. This completes Sections 00 to 05 of the current run. Section 06 is outside this run's scope.

Roadmap 9.3. Creating a backup with a share set or a passkey wrote the
passphrase slot and then enrolled each extra way in, and each enrolment
reopened the container, deriving the password again: 1, 2, 2 and 3
derivations for password, +shares, +passkey, +both.

encryptKeym2WithSlots writes every initial slot while the generated
master key is still in hand, so the password is derived once and the
share and passkey slots take only their HKDF. writeKeym2 generalises to
writeKeym2Slots; the bytes are exactly the enrolment path's given the
same random inputs. The password, KDF parameters, version, share
threshold and count and passkey sizes are checked before any
derivation. The worker and its no-worker fallback call it through
encryptContainerWithSlots; §4.8 keeps its own writer, and enrolling
into an existing backup is unchanged. The worker now reads the key file
once, so the page transfers it on every path.

Adds test:slot-transaction (derivation counts through the shipping
worker for PBKDF2 and Argon2id, byte equality with the enrolment path
for every cipher, version, slot combination and key-file case, every
way in opens it, refusals cost no derivation, a failed write returns no
container or shares), a census block in secret-erase-core, a bridge
encryptslots subcommand compared byte for byte against the reference's
three-step path in crosstest2.py, and three appended v3-slots fixtures
(corpus 42 -> 45 in both languages).
@404SecNotFound
404SecNotFound merged commit de969a5 into claude/serene-carson-0739mv-secret-buffers Sep 28, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant