Skip to content

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

Merged
forkwright merged 5 commits into
mainfrom
fix/914-kdf-record
Sep 3, 2026
Merged

fix(thumos): record the KDF parameters a device's secrets were derived under#929
forkwright merged 5 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 3 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
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.
@forkwright

Copy link
Copy Markdown
Owner Author

Both failures were this branch's own, and both are fixed in 12b0646. Verified against main rather than assumed: the same kernel (i686 tests + armv7a build) job passed on main's run 32750096823 (head 5602f3f) a day before this PR's failing run, so neither defect was inherited.

rustfmt — four call sites this branch touched were never formatted, in key_manager.rs (two new test calls), secrets.rs, and security.rs.

Worth knowing why it slipped: cargo fmt --all -- --check over the workspace passes. The thumos crate carries its own manifest and toolchain and is checked separately as cargo fmt --manifest-path crates/thumos/Cargo.toml --check, which is the one that failed. A green workspace fmt is not evidence about that crate. Both exit 0 now.

kernel (i686 tests + armv7a build) — all 2564 kernel host tests passed; the job failed one step later in scripts/check-target-test-ledger.sh:

LEDGER DRIFT: module 'key_manager': ledger says 16 tests, i686 test binary has 17 runnable
LEDGER DRIFT: module 'secrets': ledger says 14 tests, i686 test binary has 15 runnable

The branch adds exactly one #[test] to each — derive_from_passphrase_differs_per_kdf_parameters and v1_preamble_bytes_parse_under_v1_kdf — and never updated docs/target-test-ledger.toml. Counts corrected to 17 and 15.

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; cargo fmt needs no build admission, and the kernel diagnosis is log-only plus a static diff and TOML parse.

CI note: GitHub Actions is in a major outage (incident opened 2026-08-26T15:11:58Z). Checks on this push may queue, startup_failure, or never be created — none of that will be evidence about the change. I will re-read once the incident closes rather than merging on an empty rollup.

@forkwright

Copy link
Copy Markdown
Owner Author

Independent completion/architecture review agrees with the draft's self-identified blocker: the v1 route fixture proves parse → V1_KDF, but it does not anchor what historical PBKDF2-HMAC-SHA256/100,000 actually produces.

To make the remaining author-owned step mechanical, I generated a KAT outside this crate using Python 3's OpenSSL 3.5.7-backed hashlib.pbkdf2_hmac:

passphrase = b"production entry point test"
salt       = b"thumos-unit-test-salt"
PRF        = HMAC-SHA256
iterations = 100000
dkLen      = 32
expected   = b881bcb2a54bfcfcf55619c2530a10b6ae82a1081ab4cbe86798f5d4e5378e23

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 derive_from_passphrase_production_entry_point, so asserting the exact 32 bytes there would anchor both the production entry point and V1_KDF without inventing a second fixture. The branch remains yours; t0-thumos has not changed or rebased it. Once the KAT is committed and the draft is made ready, I can shepherd its CI/merge without colliding with the storage lanes it gates.

forkwright added a commit that referenced this pull request Aug 26, 2026
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>
forkwright added a commit that referenced this pull request Aug 26, 2026
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
@forkwright
forkwright marked this pull request as ready for review September 3, 2026 17:53
@forkwright
forkwright merged commit c92905b into main Sep 3, 2026
14 checks passed
@forkwright
forkwright deleted the fix/914-kdf-record branch September 3, 2026 18:18
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