Skip to content

fix(thumos): record the KDF parameters a device's secrets were derived under - #929

Draft
forkwright wants to merge 2 commits into
mainfrom
fix/914-kdf-record
Draft

fix(thumos): record the KDF parameters a device's secrets were derived under#929
forkwright wants to merge 2 commits into
mainfrom
fix/914-kdf-record

Conversation

@forkwright

Copy link
Copy Markdown
Owner

Draft — one gap is named below and should be closed before this merges

Finding

Stored secrets carry no record of how their keys were derived. Changing the KDF later would silently make every provisioned device unreadable, with no way to tell which algorithm a given sector predates.

What changes

Format version 1 → 2, adding a bounds-checked 16-byte KDF record. V1_KDF is PBKDF2/100k — byte-identical to the retired PBKDF2_ITERATIONS — and MASTER_KDF currently equals it, so no derivation output changes for any existing device. parse() accepts both versions and resolves a v1 header to V1_KDF.

Every call site of derive_from_passphrase, store_boot_verifier, render(, and every DeviceSecrets{} construction across crates/thumos/src was checked — none left un-threaded.

Two defects found while finishing the branch's own earlier work

  • secrets.rs:329 — a doc comment describing a slot count (2 when present, 1 otherwise) the function had stopped writing.
  • secrets.rs:732 — a test asserting an on-disk slot count of 2 where store_boot_verifier always renders three slots. It had been patched to compile against the new signature without being reconciled to the new semantics.

The code is right and the test was stale, established on three independent sources rather than by re-deriving the same arithmetic twice: the module-level format doc (secrets.rs:46-48, untouched and predating both), parse()'s own accepted-count and has_verifier logic (:221-224, :240-244), and the caller's behaviour. Rendering 2 would have made parse() stop recognising its own verifier.

Two tests added, because their absence was the real gap

v1_preamble_bytes_parse_under_v1_kdf — builds a v1 sector from literal bytes, never via render(). render() only ever writes v2 today, and a fixture generated by the writer under test proves only that the writer and reader agree with each other. Covers both real v1 shapes (salt-only, and salt+verifier). Before this, VERSION_V1 appeared only in production parse() branches and zero times in tests.

derive_from_passphrase_differs_per_kdf_parameters — same passphrase, same salt, two KDF parameter sets, different derived keys. This is #914's own "Done when", and without it the record is decorative.

The gap that remains — read this before merging

The v1 fixture would NOT catch a wrong V1_KDF value. It proves parse() routes a v1-tagged sector to whatever V1_KDF currently is — a self-consistency check between parse() and the constant, not an external check on the constant. If the iteration count were typo'd or silently repriced relative to what real provisioned devices used, this test still passes tautologically.

What would close it: a known-answer test — a fixed passphrase and salt with an expected key derived independently of this crate, under PBKDF2-HMAC-SHA256 at 100,000 iterations. Nothing in this suite asserts V1_KDF's value against any external reference, before or after this change.

Until that exists, "cannot break existing secrets" is argued from reading, not proven. For a master-key derivation on a device whose stored secrets become unreadable if it is wrong, that distinction is the whole thing.

Verification status

Nothing here has been compiled or run — this box has no build role for this repo. Confidence comes from manual tracing: type and visibility checks, every call site cross-referenced, and the three-source argument above. cargo test -p thumos on a build box is the actual verdict.

forkwright added 2 commits August 21, 2026 06:52
…d under

Stored secrets carried no record of how their keys were derived, so changing the
KDF later would silently make every provisioned device unreadable with no way to
tell which algorithm a given sector predates.

The format version moves 1 -> 2 and gains a bounds-checked 16-byte KDF record.
V1_KDF is defined as PBKDF2/100k -- byte-identical to the retired constant -- and
MASTER_KDF currently equals it, so no derivation output changes for any existing
device. parse() accepts both versions and resolves a v1 header to V1_KDF.

Two defects found while finishing this, both in the branch's own earlier work.
A doc comment described a slot count the function had stopped writing. And a test
asserted an on-disk slot count of 2 where store_boot_verifier always renders
three slots -- it had been patched to compile against the new signature without
being reconciled to the new semantics. The code is right and the test was stale:
the module format doc and parse()'s own accepted-count logic both independently
say v2 carries 2 or 3, and rendering 2 would have made parse() stop recognising
its own verifier.

Two tests are added because their absence was the real gap. A v1 fixture built
from literal bytes rather than from render(), since a fixture generated by the
writer under test only proves the writer and reader agree with each other. And
the assertion the issue actually asks for: the same passphrase and salt under
different KDF parameters must produce different keys, without which the record
is decorative.

Refs #914
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