fix(thumos): record the KDF parameters a device's secrets were derived under - #929
Conversation
…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
Both CI failures on this branch were its own, not inherited: the same `kernel (i686 tests + armv7a build)` job passed on main's run one day earlier. rustfmt: four call sites this branch touched were never formatted. The workspace `cargo fmt --all --check` passes, which is why this went unnoticed -- the thumos crate carries its own manifest and toolchain and is checked separately via `--manifest-path crates/thumos/Cargo.toml`. Both now exit 0. Target-test ledger: the branch adds exactly one test to each of key_manager (`derive_from_passphrase_differs_per_kdf_parameters`) and secrets (`v1_preamble_bytes_parse_under_v1_kdf`) and never updated docs/target-test-ledger.toml, so check-target-test-ledger.sh reported drift of 16->17 and 14->15. All 2564 kernel host tests passed; the job failed one step later on that audit. The ledger is doing its job here. It exists because tests that fail to compile for a target are silently not run (#124, #619, #631, #645) -- a declared count cross-checked against the derived one is what makes that visible, so the count is meant to be updated deliberately alongside the tests, not derived away.
|
Both failures were this branch's own, and both are fixed in
Worth knowing why it slipped:
The branch adds exactly one The ledger is working as designed and I did not touch its mechanism. It exists because a test that fails to compile for a target is silently not run (#124, #619, #631, #645); a declared count cross-checked against the derived one is what makes that visible. The count is meant to be updated deliberately alongside the tests, not derived away — deriving it would defeat the check. Nothing was built, tested, or clippy'd locally; CI note: GitHub Actions is in a major outage (incident opened 2026-08-26T15:11:58Z). Checks on this push may queue, |
|
Independent completion/architecture review agrees with the draft's self-identified blocker: the v1 route fixture proves To make the remaining author-owned step mechanical, I generated a KAT outside this crate using Python 3's OpenSSL 3.5.7-backed Reproduction: hashlib.pbkdf2_hmac(
"sha256",
b"production entry point test",
b"thumos-unit-test-salt",
100_000,
dklen=32,
).hex()Those inputs already exist in |
Refs #875. ## Acceptance map | #875 acceptance | Existing coverage from #920 | Coverage added here | | --- | --- | --- | | Scheduler and PID-0/service-loop progress owns pet authority | Pure `LivenessGate` policy and production IRQ/service-loop epoch wiring | Preserved; the watchdog model now makes owner-stall withholding observable in QEMU | | Startup, stale/duplicate epochs, overload, long sections | Pure liveness tests | Preserved and re-run in both host kernel profiles | | Intentional shutdown/reboot | Bounded grace existed only as an uncalled API | One production reboot coordinator accepts the immutable grace before reset; repeated/late entry cannot renew it | | Falsifying target witness | Ordinary QEMU boot plus a no-op pet backend | Injectable countdown backend proves reset, owner-stall expiry, and accepted-but-hung shutdown expiry with distinct exit codes | | Physical watchdog reset/deadline | Not verified | Still explicitly **unverified**; remains an operator-owned AGM M7 receipt after software acceptance | ## What changed - Route the console and probe reboot boundaries through `shutdown::reboot`, ordering the liveness grace transition before the platform reset request under one interrupt-masked boundary. - Make shutdown grace idempotent and immutable, with explicit `Started` / `AlreadyStarted` outcomes and host tests for ordering, late repeats, and failed reset return. - Replace QEMU's no-op watchdog with an observable 500-tick countdown model. It records accepted pets and expires with a dedicated UART marker and exit code. - Add three target witnesses through the real integration seam: - controlled reboot: coordinator -> reset backend -> QEMU exit `8`; - frozen PID-0 owner: pet withholding -> modeled expiry -> QEMU exit `7`; - accepted shutdown with injected reset failure: pets only through immutable grace (`elapsed=500`), rejects late renewal (`elapsed=501`), then expires -> QEMU exit `7`. - Wire the witness through hosted CI, local witness aggregation, extraction guards, capability inventory, runbook, and target-test ledger. No KDF or preamble surface from #929/#914 is changed. ## Nomos evidence Run locally on Nomos after rebasing onto `8671f998` (#943), using QEMU 10.2.2, Rust 1.94.1, the ARM and i686 targets, and nextest: - `scripts/kernel-host-tests.sh` - PASS: default `2565/2565`, debug-console `2569/2569` (one documented skip in each profile). - `scripts/kernel-build.sh && scripts/check-kernel-window.sh` - PASS: ARMv7 release build; `1,064,960 / 2,097,152` bytes (`50.8%`), `1,032,192` free. - `scripts/witness/trust-anchor.sh` - PASS. - `scripts/witness/watchdog.sh` - PASS with exact assertions: - `crates/thumos/target/witness/watchdog/reboot.log`: runner exit `8`; live boot, production coordinator, and reset-backend markers required. - `crates/thumos/target/witness/watchdog/stall.log`: runner exit `7`; owner freeze, withholding, and `since_pet=500` expiry markers required. - `crates/thumos/target/witness/watchdog/shutdown-hang.log`: runner exit `7`; production coordinator, injected reset failure, final grace pet at `elapsed=500`, immutable late entry and withholding at `elapsed=501`, and `since_pet=500` expiry required; probe-failure marker forbidden. - `scripts/witness-run-all.sh` - PASS: all kernel QEMU witnesses, including all three watchdog paths. - `scripts/kernel-clippy.sh` - PASS: default, debug-console, production, qemu, all three watchdog probes, kfault, crashloop, metaxu, metaxu-expired, and metaxu-no-capability. - `scripts/check-target-test-ledger.sh` - PASS: 130 rows, 31 both, 9 target-only, 2570 runnable host tests. - `scripts/check-doc-inventory.sh`, `scripts/check-wiring-inventory.sh --no-log`, `scripts/check-board-seam.sh`, `scripts/check-convergence.sh`, and `scripts/check-witness-extraction.sh` - PASS. - `git diff --check origin/main...HEAD` - PASS. The witness transcripts are intentionally ignored build artifacts under `crates/thumos/target/witness/watchdog/`; no root `watchdog-*.log` artifacts are committed. ## Kanon pre-push provenance The ordinary `git push` ran the canonical `kanon gate --pre-push`. It passed fmt, workspace check, fitness, dependency audit, workspace clippy, and all `997/997` nextest tests, and failed only the known full-tree Kanon lint debt tracked by #756. A diff-scoped lint found exactly three `WORKFLOW/cargo-without-target-dir-in-worktree` sites in `scripts/kernel-clippy.sh:185,188,191`; `git blame` attributes all three unchanged cargo invocations to pre-existing main commits (`a5016e86` / `f8773c4b`), with their line numbers shifted only by the new six-line feature dependency table. No changed watchdog/shutdown file had a Kanon finding. Per the documented main-baseline exception, the feature branch was then pushed with `git push --no-verify`. ## Deliberate remaining boundary This PR does **not** close #875. It completes the software residual identified by the reopen audit, but it does not claim the later physical AGM M7 reset-cause/deadline receipt. That hardware evidence remains unverified and operator-owned. --------- Co-authored-by: admin <admin@ardentleatherworks.com>
Closes #871 ## Summary - Centralize syscall user-memory access in direction-aware `copy_from_user` / `copy_to_user` primitives that validate the complete overflow-checked range against the live caller VAS and PL0 permissions. - Use exact ARM unprivileged-transfer fixup sites so a post-validation `LDRBT` or `STRBT` abort becomes `EFAULT`; unrelated PL1 faults still halt. - Migrate fd, pipe, socket, futex, time, IPC, UART, exec/argv, stat/getcwd, and signal-frame pointer families. Untrusted counts use bounded transport-sized chunks, and stateful reads use peek/copy/commit or rollback. - Make private futex wait queues VAS-scoped, harden exec argv and signal-stack arithmetic against wraparound, and return `EFAULT` for failed futex copyin. - Add a real QEMU syscall permission/fault matrix, raw fixup probes, and the existing kernel-fault negative control to CI and the target ledger. ## Acceptance evidence The QEMU uaccess witness exercises a live caller page table rather than a host predicate: - mapped anonymous RW copyin and copyout succeed; - a read-only page succeeds as a source and fails as a destination; - `PROT_NONE`, unmapped, stale-after-`munmap`, and cross-page partial ranges return `EFAULT`; - ARMv7 execute-only mappings are refused (`EINVAL`) because this MMU cannot express execute-without-data-read, while the pure descriptor tests reject execute-only data access; - raw `LDRBT` and `STRBT` faults bypass prevalidation and recover only at the registered transfer PCs; - the caller continues into the service loop after contained syscall failures; - the separate `kfault` witness still halts on an unrelated PL1 undefined instruction (runner rc 4). Cross-process and transition coverage includes distinct-L1 address-space isolation, same-VA/different-VAS futex ownership, caller-owned fd/socket fixtures, forked-child copyout, independent exec replacement, fork-then-exec copyin in both old and replacement VASes, and real target `mprotect` / `munmap` transitions. Pipe publication and read consumption occur only after successful copyout, satisfying #868's no-side-effect failure boundary. ## Verification - `env -u CARGO_TARGET_DIR scripts/kernel-clippy.sh` — all nine ARM feature configurations passed. - `env -u CARGO_TARGET_DIR scripts/kernel-host-tests.sh` — 2,571/2,571 default and 2,575/2,575 debug-console passed; one intentional skip in each. - `scripts/check-target-test-ledger.sh` — 129 rows, 29 both-mechanism, 9 target-only, 2,576 runnable host tests, no drift. - `scripts/check-witness-extraction.sh`, `cargo fmt --all --manifest-path crates/thumos/Cargo.toml -- --check`, `git diff --check`, and changed-shell syntax — passed. - `env -u CARGO_TARGET_DIR scripts/witness/uaccess.sh` — passed. - `env -u CARGO_TARGET_DIR scripts/witness/kfault.sh` — passed with expected runner rc 4. - `env -u CARGO_TARGET_DIR scripts/witness-run-all.sh` — `ALL KERNEL QEMU WITNESSES: PASS`, including fork, exec, and forkexec. - `kanon gate --full --git-diff origin/main --stamp .` — workspace check, dependency audit, clippy, and 997/997 nextest passed; only Kanon lint failed. ### Kanon #756 baseline receipt A fresh linked worktree at exact base `a6f855bcc54b2083d05b274f4aa2a3654dadb4a6` reproduced exactly the branch result: 163 violations, 28 suppressed, strict-equivalent 191, with identical rule counts. This is the known repository lint debt tracked by #756. Per the documented exception, the branch was pushed with `--no-verify`; no `Gate-Passed` trailer was fabricated. ## Truthful residuals - UDP `recvfrom` prevalidates both outputs and preserves the datagram on a payload transfer fault, but a late payload fault can leave the already-copied source-address bytes in user memory; the pair is not byte-atomic. - `/dev/urandom` preserves file data/offset behavior, but its volatile PRNG state may advance if a post-validation copyout fault occurs. - The current four-register dispatch ABI supplies zero address arguments to `sendto` / `recvfrom`; direct helpers support address buffers. The existing `addr_len` output and `execve` `envp` remain unimplemented/ignored rather than being newly claimed here. - #890 supplied the earlier caller-VAS validation and its host-fixture corrections; this PR closes the remaining fault-containment, race, migration, and target-witness scope. - No #929 KDF, secrets, encryption, or kinit files are touched. --------- Co-authored-by: admin <admin@ardentleatherworks.com>
… answer
The v1 fixture proves parse() routes a v1-tagged sector to whatever
V1_KDF currently is -- a self-consistency check between parse() and the
constant, not a check on the constant's value. A typo'd or silently
repriced iteration count would have passed every test and bricked every
provisioned device at first boot.
This adds v1_kdf_matches_independent_pbkdf2_known_answer: fixed
passphrase ("thumos v1 known-answer") and salt (bytes 0x00..=0x1f)
through KeyManager::derive_from_passphrase under V1_KDF, asserting the
full 32-byte output. The expected key was derived independently of this
crate -- OpenSSL via Python's hashlib.pbkdf2_hmac, cross-checked against
a from-scratch RFC 2898 composition in pure Python; both references
agree. derive_under applies no personalization or domain separation, so
the pinned construction is the raw RFC 8018 one: PBKDF2-HMAC-SHA256,
100,000 iterations, dkLen 32.
Closes the merge blocker named in #929's body for #914.
Gate-Passed: kanon 0.1.0
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_KDFis PBKDF2/100k — byte-identical to the retiredPBKDF2_ITERATIONS— andMASTER_KDFcurrently equals it, so no derivation output changes for any existing device.parse()accepts both versions and resolves a v1 header toV1_KDF.Every call site of
derive_from_passphrase,store_boot_verifier,render(, and everyDeviceSecrets{}construction acrosscrates/thumos/srcwas 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 wherestore_boot_verifieralways 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 andhas_verifierlogic (:221-224,:240-244), and the caller's behaviour. Rendering 2 would have madeparse()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 viarender().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_V1appeared only in productionparse()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_KDFvalue. It provesparse()routes a v1-tagged sector to whateverV1_KDFcurrently is — a self-consistency check betweenparse()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 thumoson a build box is the actual verdict.