Skip to content

security: the master-key KDF has no versioned parameter record, so its cost can never be raised #914

Description

@forkwright

Finding

KeyManager::derive_from_passphrase derives the master key — the value every partition key descends from — with PBKDF2-HMAC-SHA256 at an iteration count read from an ambient constant. Nothing persists which parameters produced a given device's key.

The consequence is not that the cost is currently too low. It is that the cost can never be changed at all. The master key is the derivation output, so raising the iteration count yields a different key, and the partition it decrypts becomes unreadable. There is no migration path, and there is no record to migrate.

This is the same defect #272 just fixed one layer over, in the path that matters more.

Evidence

  • crates/thumos/src/key_manager.rs:226-239derive_from_passphrase(passphrase, salt) calls security::pbkdf2_sha256(passphrase, salt, PBKDF2_ITERATIONS, &mut key_bytes). The iteration count is a module constant, not an argument and not a stored field.
  • crates/thumos/src/security.rs:36PBKDF2_ITERATIONS: u32 = 100_000, one value for every device and every derivation that reads it.
  • crates/thumos/src/secrets.rs:52-55 — the on-disk preamble carries MAGIC and a format VERSION, and secrets.rs:15 documents the field layout. It stores the per-device salt (Kernel: per-device encryption salt — replace the fixed global PBKDF2 salt (from #337) #449) but no KDF identifier and no cost parameters. A reader has no way to learn what produced the key it is about to derive.
  • crates/thumos/src/security.rs (as of Sentinel-exit PIN is stored and compared as a single-round unsalted SHA-256 #272) — PinVerifier carries PinKdf with its parameters precisely so this problem does not arise for the secret verifiers. The master key did not get the same treatment.

Why this matters

Two consequences, and the second is the one that bites later.

The cost is frozen at whatever shipped. Every other cost parameter in this kernel can now be raised on a provisioned device; this one cannot. When 100,000 PBKDF2 iterations is visibly inadequate — and it is a linear work factor against an attacker whose advantage is parallel hardware, so that day arrives on a schedule — the only remedy will be re-provisioning every device, which means destroying and restoring its data.

A future format change has no safe reading. The preamble carries a VERSION, so a VERSION = 2 could add KDF fields. But existing VERSION = 1 devices carry no parameters, so a v2 reader must assume PBKDF2/100k for them. That assumption is only correct because the constant has never moved — it is an undocumented invariant that the code does not state and nothing enforces. If the constant is ever edited without touching the preamble, every previously-provisioned device silently fails to unlock, and the symptom is "wrong passphrase" rather than "wrong parameters".

Severity is medium rather than high, and the reason is worth recording: #872's ~77-bit boot secret makes brute force infeasible against this KDF regardless of its work factor. That decision is what keeps a frozen cost from being an active hole. It does not make it correct, and it means this issue's urgency depends on a decision made elsewhere — which is exactly the coupling that should be written down rather than remembered.

Desired correction

Give the master-key derivation the same versioned record PinVerifier now has, in the preamble rather than in a constant.

  • Extend the secrets preamble to carry a KDF identifier and its parameters alongside the existing salt, under a new format VERSION.
  • Read the parameters from the preamble in derive_from_passphrase rather than from PBKDF2_ITERATIONS; the constant then has no consumer and goes.
  • Define the v1 reading explicitly — a VERSION = 1 preamble means PBKDF2-HMAC-SHA256 at 100,000 iterations — so the assumption becomes a stated fact with a test rather than an invariant that holds by nobody having edited a constant.
  • Argon2id is the target KDF here for the same reason it was chosen on Sentinel-exit PIN is stored and compared as a single-round unsalted SHA-256 #272, and the page-allocator memory design landed there applies unchanged. Whether to migrate existing devices on unlock (derive under the old parameters, re-wrap under the new) or to leave v1 devices alone is a separate decision that the record makes possible; it is not possible today.

Done when: the preamble carries the KDF and its parameters, derive_from_passphrase takes them from the record it read rather than a constant, a v1 preamble round-trips through the documented default, and a test proves two preambles declaring different parameters derive different keys from the same passphrase and salt.

Provenance

Found while implementing #272's Argon2id half: PBKDF2_ITERATIONS had one consumer left after the verifier moved off it, and that consumer turned out to be the master key. Every citation above was read first-hand at main.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions