Conversation
…e review stops covering it when upstream releases again check-source-currency.sh reports that a newer version exists and, rightly, judges nothing: newer is not a security verdict. Nothing else judged either, so 51 of 113 pins are behind today and no file says which of those gaps holds a security fix. This is the place that gets written down. tools/check-pin-reviews.sh reads the survey (--tsv) and tools/pin-reviews.tsv, and never the network, so its answer can be tested and reproduced. A behind pin passes only with a row that names the version reviewed, the newest upstream release that was read, a verdict, a date and what was read. fine: nothing in the gap is a security fix that reaches Kryptik. held: something is, and the row says why the pin stays; it is printed on every run, and --no-held, which is what a release asks, refuses it. What makes it a gate and not a list: a row fails when the pin has moved off the version it reviewed, when upstream has released past what was read, when it is older than 180 days (a vulnerability is often published long after its fix), when it has no note, and when the pin has caught up and the row was left behind, so the file shrinks when the work is done. The tests (22, offline) found a bug before anything else did. The survey is tab separated and a pin the survey could not determine has an empty "newest" column; read merges a run of tabs, every later field moved left, and all 16 undetermined pins were counted as none. The survey is now read with a separator that is not whitespace. make check-pins runs the survey and then the gate; make test-pin-reviews and tools/run-tests.sh run the suite. The reviews file ships with its contract and no rows: the rows are the review itself, and come with it.
… pins turn out to be behind Sixteen pins were UNKNOWN in every survey: the hosts serve an API, a feed or a front page, not a directory listing. They were also a third of what the image exposes to untrusted input: less, lynx, openssh, libinput, wlroots, wayland. Not checked is not the same as fine, and four were not fine: less 661 against 710, procps-ng 4.0.4 against 4.0.7, lvm2 2.03.39 against 2.03.42, libinput 1.30.4 against 1.32.0. Each rule was run against the real host before it was written, and each encodes a way to be confidently wrong, with a test that serves the trap: wayland and libinput number a release candidate 1.31.901, with no "rc" in it; a GitLab release list is ordered by date, so the first entry is not the newest; psmisc's release list is missing a release its tags have, and kernel-hardening-checker publishes tags and no releases; less's directory offers a beta, and the front page says which version is for general use; lynx's directory is full of dev snapshots; openssh's p1 is part of the version; curl.se/ca/ answers 200 with a meta refresh curl does not follow. wlroots is read within its pinned series, as python is: dwl is written against one series and the next is an API change. The glibc FHS patch is deferred to the glibc row, which is what decides it. The header said --only NAME; the parser has always taken --only=NAME.
…held with what is wrong with each, and a release refused until the six are dealt with tools/pin-reviews.tsv now has a row for every pin that stays behind its upstream once the open bumps are on main. Each row names the version reviewed, the newest upstream release that was read, the date, and what was read and what it said; they come from four reports made on 2026-09-19 out of upstream NEWS files and changelogs, project security pages, the Debian tracker and NVD records, not from a search for "CVE". Held, because a fix exists and the pin cannot simply move: zlib (1.3.2 fixes one CVE and introduces a worse one that 1.3.1 does not have; the fix is unreleased), gawk (four overflows fixed only in 5.4.1, which changes the regex engine under every build script), gzip (the fix is in no release, so 1.14 fixes nothing), acl (2.4.0 does not build with the pinned tar), readline (8.2 without its official patches; 8.3 moves with bash 5.3), shadow (a use-after-free fixed in 4.17, and 4.19 and 4.20 each break the recipe). Run against the survey as it reads with the open bumps applied: 26 fine, 6 held, nothing unreviewed, and --no-held, which is what a release asks, fails on exactly those six. CI's weekly job and every push to main now run the survey and then the gate. On a pull request the verdict is printed and does not fail the check: an upstream that released this morning is not the fault of whoever opened a pull request this afternoon, and the scheduled run is what makes sure somebody reads it. The roadmap entry says what is written and what ticking it still needs. The rows describe main as it will be once the glibc, gcc and pin-batch changes are on it, so this merges after them.
… believes, and which bytes it will stage The first piece of the update channel (docs/design/update-channel.md), and the part that is pure: no socket, no file, no signature. A pointer's text is parsed strictly, with an unknown key refused rather than skipped. A verified pointer is accepted for this image's role and never backwards: an `issued` earlier than the newest already accepted is a replay, however valid its signature. Its age says whether it is stale. A relative base is resolved against the channel address from the verified root, and only a development image may be pointed at plain http. For staging: nothing but the manifest and its signature, whole and small, until they have verified; after that only a name the signed manifest lists, at exactly the offset already held, never past the signed size. `still_needed` is the answer to a poll: what is missing, and from which byte. Versions compare as `sort -V` orders them for kryptik-update. Nothing is wired to the broker yet; nine unit tests cover the rules.
…me things The owner asked whether what is being added is real or padded, and measured honestly this tool was padded: a join of two files with five ways to fail had grown an expiry policy, a date override for its own tests, a strict mode CI never used, six report sections and a test file longer than itself. It now does what makes it a gate and nothing else: a behind pin needs a row; the row stops covering it when the pin moves, when upstream releases past what was read, or when the pin catches up; a row needs a verdict and a note; held is printed and --no-held refuses it; an undetermined pin is reported. Review expiry, --strict, --max-age and --today are gone. The suite keeps the sixteen cases that each guard one of those behaviours, including the tab-merging bug it originally found. Run against the 32 real rows and the projected survey it gives the verdicts it gave before: six held, the rest covered, a release refused for the six. The reviews file's header, the CI step's comment and the Makefile's are cut to what a reader needs.
…, with the code that checks a payload
Two read-only subcommands, which zone 0 runs before it believes anything
the net zone hands it:
check-manifest DIR the signature, role and version steps of `apply`
on a manifest that has no payload beside it yet;
prints the version, the hash of the copy it
verified, and each listed file with its size
check-pointer FILE SIG the signature over a statement of what is
current, in a namespace of its own
The manifest steps were the first half of verify_payload; they are now
verify_manifest, which verify_payload calls, so `apply` and the channel
cannot come to disagree about what a valid manifest is. A downgrade is
refused as in `apply`: nothing that arrives over the network is a
recovery. The signature check is one function with the namespace as an
argument: kryptik-release for a manifest, kryptik-latest for a pointer,
so neither signature can be presented as the other.
Neither check needs root or this installation's disks, so the tool asks
for those only for the subcommands that write.
The suite that lifts verify_payload out of the tool now covers both
checks with the same real ssh-keygen: the other namespace, a stranger's
key, another role, an older release, a listed path that climbs, an
edited pointer, a manifest presented as a pointer, and the tool itself
run without root. It had been registered nowhere; it is now
`make test-update-verify` and runs with the offline suites. 18 rows.
…verbs does with it The stateful half of update.rs, still not wired to the broker. Under /var/lib/kryptik/update, root's alone: the newest statement accepted, when one was last looked at, the version the person asked for, the verified manifest's file list, and the staging directory `apply` will be given. latest: one statement is looked at per hour, whoever sent it; its signature is checked before a word of it is parsed, and it replaces the stored one only if it is for this image's role and not older. want: the person asks; nothing is fetched that was not asked for. poll: what is wanted, the base address from the verified statement, and what is still missing and from which byte, or idle. put: the manifest and its signature first; when both are there they are verified, held to the hash the statement announced and measured against the room there is, and a refusal throws both away. Only then is a listed file taken, in pieces of at most 1 MiB, each appended at exactly the offset held. The two signature checks are passed in as functions, so the tests stand in for kryptik-update and run against a directory of their own: a statement that does not verify, the interval, a replay, the whole staging order with a dropped connection in the middle, a manifest with another hash, another version, a size nothing could hold, and no signature. The staged directory ends up holding exactly what `apply` accepts. Thirteen tests.
…e person
Three verbs on the zone-facing socket, taken from the zone that holds the
network and from no other, like time-offset:
update-latest <plen> <slen> a statement of what is current and its
signature, at most 8 KiB each
update-poll idle, or what is wanted, from where, and
what is still missing and from which byte
update-put <name> <off> <len> one piece of the release, at most 1 MiB
Who is asking is settled before a byte of payload is read. A release
crosses in pieces, each one request answered between two looks at the
zone, so supervision is never further away than one piece; the log gets a
line for what ends something, not for each of some thousands of pieces.
What is believed and what is stored is update.rs's decision, and every
signature is kryptik-update's.
For the person, through the launch service like `kryptik wifi`:
`kryptik update status` (the release running, the newest known and how
old that news is, what is staged), `fetch` (the asking without which the
net zone is told idle) and `apply`, which hands the staged directory to
kryptik-update apply: it verifies all of it again before it writes a
slot, and the trial boot and the fallback are the ones that exist.
Tests: the verbs' grammar and bounds and the refusal by identity as unit
tests (202 pass); five rows in the boundary suite, against a real zone,
for the three verbs from a zone that does not hold the network and two
requests outside the grammar. The serve and cli suites pass unchanged.
…er, and nothing more tools/net/update-fetch.py is the net zone's half. `latest` brings the signed statement of what is current and its signature to zone 0; `poll` asks whether a release is wanted and streams what zone 0 says is still missing, from the byte zone 0 names, in pieces of at most 1 MiB. It decides nothing and holds nothing: the address comes from zone 0's configuration and then from the statement zone 0 verified, a piece zone 0 refuses ends the run and is not sent again, and a server that ignores Range has the bytes before the offset dropped rather than sent. netzone-init.sh runs it in the background, one at a time: the statement every half hour until zone 0 has taken one and daily after that, the question "is a release wanted?" every minute, which is one line on a local socket and is how `kryptik update fetch` is noticed. With no /etc/kryptik/update.conf there is no channel and nobody is asked; the nic zone now sees that file read-only, like the time sources. Stage 04 installs the fetcher and compiles it under the target python. `make test-update-fetch`: a real HTTP server on loopback and a stand-in for zone 0's broker. A whole release byte for byte, the manifest first, no piece over 1 MiB, a download cut at a byte resumed from that byte with and without Range, a refusal, no channel, a dead host. Eleven rows.
… what is left The design's status, the paragraph on how a release crosses the broker (pieces of at most 1 MiB, one request each, rather than a copy handed to a child of the launcher: no second process and no state between pieces but the staged file's length), where the channel address lives and what its absence means, and the list of files and suites. The roadmap item stays unticked: what is left is the row that needs the installed system (a release fetched over the test network, staged, applied and committed) and release tooling that publishes a signed pointer.
…ot the caller Code scanning reads a peer's uid in a log line as sensitive data, which it is not, and raised one alert on the new line. The other verbs' lines carry the same finding, dismissed as a false positive. This one does not need the argument: the launch service answers the session's user and root and nobody else, so the uid on an update line says nothing the service's own rule does not, and the line goes without it.
…rom the environment
Found in review. The directory the manifest is copied into was
`${SNAP:-$(mktemp -d ...)}`, with nothing above it clearing SNAP, and the
exit trap is `rm -rf -- "$SNAP"`. So `SNAP=/some/dir kryptik-update ...`
as root changed that directory's mode, copied a manifest into it and
removed it recursively on the way out, whatever it was. It needs someone
who already controls root's environment, so it is a trap for the unwary
rather than a boundary; but the update channel adds callers, and a
script with rm -rf in its exit trap should not take the path from
outside. kryptikd clears the environment when it runs the tool; the next
caller might not.
One line: SNAP is set empty beside the other settings. The suite is not
affected, since it lifts the functions out and sets SNAP in a shell of
its own; it gains a row that runs the real script with SNAP naming a
directory that holds a marker, and requires the marker to survive. The
row fails against the script without the line. 19 rows.
…second key, for statements only Found while reading the build for the installed-system test. The trust anchor stage 04 installs has always restricted the release key to `namespaces="kryptik-release"`. check-pointer verifies in kryptik-latest against the same file, so on a real image no pointer could ever verify: the one key enrolled is not honoured in that namespace. It failed closed, and the offline suite did not see it because its anchor was one line with no namespaces, which accepts what the installed system refuses. The anchor now has two lines. The release key, honoured for manifests and nothing else, as before; and a second development key made beside it, enrolled as kryptik-latest and honoured for statements of what is current and nothing else. That is the design's "separate freshness key", enforced by the anchor rather than by convention: the key that has to be at hand on a schedule cannot sign a release, and the key that signs releases never has to be at hand. An owner who wants one key lists the release key on the second line; the design says so. Stage 04 proves both keys both ways round on every build, and the probe fails against an anchor without the namespaces. The workflow keeps the new private half out of the acceptance artifact, like the other two. verify_signed tries every principal a key is enrolled under, so one key on two lines works as well as two keys. The suite's anchor is now shaped like the real one and gains two rows: a pointer signed by the release key and a manifest signed by the statement key are both refused, whatever namespace they sign in. 21 rows.
…nd stage 06 publishes one beside each payload `release-manifest.sh pointer --key K --manifest M --base B --out FILE` writes the update channel's KRYPTIK-LATEST-1 for a manifest that is already signed (its version and role, its SHA-256, the base, the date) and signs it in kryptik-latest. Re-running it with a later date for an unchanged release is how a channel shows nothing is being withheld. It is the first piece of the release tooling the roadmap asks for, and it is here now because the acceptance job is never given a private key, so a statement for the update suite has to be made where the manifest is signed. Stage 06 writes images/channel-<version>/latest and latest.sig beside the payload, outside it because `apply` refuses a payload holding anything unlisted; verifies it through the anchor the image carries; and writes a control, the same statement signed by the release key in the manifest's namespace, which the image must refuse. Stage 04, from review: the probe's negative half requires the signature to exist before it counts a refusal, so it cannot pass because signing failed. And a rule for the keys like the one for the kernel tree: they live outside the sysroot and outside any cache of it, so a restored tree with release-trust stamped and a key missing loses the stamp and the step makes both keys and writes the anchor again. It has been rerunning on cached builds only because an earlier step happened to go stale. Suite: six rows, 46 in all. What the tool writes is what kryptik-update's own check-pointer accepts under an anchor shaped like the image's; one signed with the release key is refused; a re-issue changes only the date; no statement is written about an unsigned manifest.
… to end, on the installed system Not yet run: it needs a Distro run, and none is free. Written to be read. Step 3 gains two lines that need no network. The signed statement that B is current, which stage 06 now writes beside B's payload, rides on the refusals disk with its control; the real updater judges both against the image's real anchor. The statement verifies, signed by kryptik-latest; the same text signed by the release key does not. Step 8, last, from the state step 7 leaves (slot b, B committed). Nothing newer exists to fetch there, so first a rollback to slot a, and the copy step 7 applied from is removed, so the staged release is the second payload's worth on kryptik-state and not the third. Then a release host on this side of QEMU's user network, bound to loopback, serving B's statement and links to B's payload; the guest with a network for this step and no other. Zone 0 names the channel and the net zone's service is restarted, because the zone sees that file only from its next launch. In order: status names B once the net zone has brought the statement; seventy seconds later nothing has been staged, because nobody asked; the person asks; the release arrives and is complete, and the staged directory holds exactly the six files; `kryptik update apply`; slot b trial-boots and commits; the home file is intact. The release host's log must show the statement, then the manifest and its signature, before any image. Each wait is a loop in the guest that gives up before the driver would, in a subshell so that giving up is not the login shell's exit, and as a failed command, because the driver judges the exit status and the word it then expects is also in the command line the console echoes. The statement is made in stage 06 and not here because the acceptance job is never given a private key.
…it that fails on a stall, a release host that cannot outlive the suite From the review of the step, by the suite's author. The net zone is restarted the way the guest checks already do it and have proven: down, a pause, up, then a NEW "netzone: READY" line in the catch-all log, and only then is `status` read. `s6-svc -r` is proven nowhere, and before the new line the old zone could still answer. The wait for the release is no longer a clock. Gigabytes through QEMU's user network on a nested-KVM runner may take longer than any one driver step allows, so each wait fails when the staged line has not changed for 100 seconds, succeeds at "complete", and also succeeds at the end of its six minutes if bytes were still arriving, for the next wait to take over; four of them, then "complete" is required. A stall fails in under two minutes and a slow link costs waits, not the run. The release host is tools/image/release-host.py, shared with the fetch suite, which had its own copy: static files on loopback and a port the kernel picks, Range honoured with a 206 as the design asks of a release host (python's http.server ignores it, and a resumed fetch would re-read gigabytes), streamed rather than read whole, a name that leaves the root refused, one log line per request. It is killed from an EXIT trap, so no way out of the suite leaves it behind. And two commands given to the guest as root had single quotes in them, which the driver wraps its `su -c` argument in; they use double quotes now, and the restart command ends in a subshell rather than an exit, or the driver's own marker after it would never print. Checked offline: the fetch suite on the shared host, 11 rows; a 3 GiB sparse file streamed whole and from a Range near its end; the arrival wait against stand-ins for complete, stalled and still arriving; both suites parse and pass shellcheck. The step itself still needs a run.
The seed file came in with the parser tests, which said the new verbs were worth a line each once they existed. Six lines: the poll, a statement inside and just past its size limit, a piece of the manifest, a piece of an image at an offset, and a name that climbs. The test's zone holds no network, so every one of them is refused by who is asking before a byte of payload is read, and nothing the fuzz sends can reach the staging area. 203 unit tests.
…er it # Conflicts: # docs/roadmap.md
…ool, every append checked in the launcher, no passwords in test transcripts kryptik-efiboot ran its two helper programs through popen, which means through a shell, with a device name pasted into the command line. The name comes from devices.sh on the verified root, so it was not exploitable, but a root tool that builds command lines out of strings is one edit from trusting the wrong one. run_read now forks and execs an argument array with no shell; stderr goes to /dev/null as before. Exercised lifted out of the tool: an argument full of $(...), backticks, ; and | comes back literally and nothing runs; only the first line is returned; a program that is not there or prints nothing is an error. kryptik-launch sized its request correctly since the earlier fix, but still added snprintf's return value to an offset unchecked, which is the pattern that turns a short buffer into a write past its end. Every piece is now checked to have fitted before the next is placed. The 2000-character runtime directory that overflowed before is clean under AddressSanitizer. vm-drive.py wrote everything it sent to the guest into its transcript, passwords included, and transcripts are uploaded with every acceptance report. They are the test image's throwaway passwords, but there is no reason for them to be there: login and su now send the password with secret=True and the transcript says "(a password)".
… the transcript The first version passed secret=True to send(), which still wrote to the log on its other branch, and the scanner rightly could not tell the two apart: it raised the same alert three lines lower. send_secret() writes the password to the socket and a fixed string to the transcript, so the separation is in the structure and not in a flag.
…l runs it directly kryptik-efiboot used to reach devices.sh through a shell and now execs it, so the file's own exec bit is what it depends on. The updater and efiboot already have the x beside them in the boot check; the helper did not.
…lock symlinks and not magic links The from-nothing build (run 35496552035, 9329c91) stopped in stage 04 at util-linux: libmount/src/hook_idmap.c:335, 'RESOLVE_NO_SYMLINKS' undeclared. Everything before it had passed on the new toolchain, the compiler check included. It is upstream's, and visible only where the C library's <fcntl.h> does not bring <linux/openat2.h> in, which is glibc before 2.43. hook_idmap.c uses the constant and includes nothing that defines it. Looking for where the other files get it found the worse half: include/fileutils.h defines a fallback of 0x02, and in the kernel's ABI 0x02 is RESOLVE_NO_MAGICLINKS; RESOLVE_NO_SYMLINKS is 0x04. context.c preprocesses to `mnt_context_is_restricted(cxt) ? 0x02 : 0` on such a libc, so the restricted-mount hardening 2.42 added blocks the wrong thing. Kryptik's mount is not setuid, so that mode is not reachable here; it is fixed anyway, because a carried patch should not leave a known-wrong constant beside the line it touches. build/patches/util-linux-2.42.3 holds one patch: fileutils.h includes the kernel header where configure found it and its fallback is 0x04, and hook_idmap.c includes fileutils.h as context.c and hook_mount.c do. The row becomes s_util_linux, which applies the set through apply_repo_patches with the same configure flags as before. Reproduced and checked on a glibc 2.39 host: the released tarball fails at the identical line; the patch applies to it with no fuzz through the project's own function, which refuses a tampered copy; the whole package then builds; and context.c preprocesses to 0x04 where it had 0x02.
… touches the filesystem with it Code scanning raised three path alerts on tools/image/release-host.py: the requested path reached isfile, getsize and open. The check that the normalised name stays under the root was there, but it shared one condition with the isfile call, so the name was used before it had been judged. The check now stands alone and first, and only a name that passed it reaches any filesystem call. Behaviour is the same, probed both ways: a file and a link inside the root are served (the suites link to a payload rather than copy it), /../ and /sub/../../ outside it are 404, as are the root itself and a directory. The fetch suite passes on it, 11 rows. It binds loopback only and exists for the suites; the alerts were still right about the order.
…lds against glibc 2.40, and asks the kernel to block symlinks and not magic links
…ool, every append checked in the launcher, no passwords in test transcripts
…stream is held to a written review, and CI asks for it
…elieves only what is signed and stores only what a signed manifest provides for
Each suite carried its own verdict counters, test accounts and password hashes, control-disk preseed, serial driver wrapper, start_vm, stop_vm, ROOTSH and partition-offset helper: the same twenty lines six times, with three spellings of part_start. They are in tools/image/suite-lib.sh now. A suite that differs says how: update-test sets DRIVE_TIMEOUT, zones-test keeps its per-call timeout and passes --net user itself. No behaviour changes; 90 lines out, 24 in, plus the 35-line shared file.
/home, /var and the /etc overlay sat on plain ext4, so a stolen disk gave up zone 0's home, the Wi-Fi passphrases and the zone volumes' headers. The installer asks twice for a passphrase before its first write (one line of standard input when unattended), formats partition 4 as LUKS2 with aes-xts-plain64 and argon2id, and puts the ext4 inside it. sysinit asks on the console, three times at most, and a failed unlock, a missing header or a plain filesystem in the partition's place is the degraded state that already exists. The passphrase reaches cryptsetup on a descriptor through the shell's builtin printf: never an argument, never a file. Two things the design did not foresee. cryptsetup's own prompt discards pending input after printing, so sysinit turns echo off first and reads the line itself. And the early getty reads the same terminal from the first moment, so kryptik-console now waits for sysinit to finish, and gives up waiting for it to start after 30 s so a broken service database still ends in a console. kryptik-recover backs up and restores the header; kryptik state passphrase changes the passphrase. vm-drive.py answers the prompt wherever it appears and run-ovmf.sh's smoke mode attaches it, so no suite gained a step; the install suite proves the header, the absent superblock and that a file in /home cannot be read from the raw partition, the state suite three wrong passphrases and a destroyed header, and the integrity suite plants its /etc entries through a mapping opened on the host. vm-drive.py loses a grab method nothing called and a record path that read an attribute that never existed.
…k reads the disk kryptik-update copied the new kernel to the ESP, renamed it to kryptik-<slot>.efi and only then compared its hash with the manifest's. The payload directory may change after it is verified (the manifest is snapshotted for that reason), so a swapped kernel failed the comparison with its final name already on the ESP, where rollback and kryptik-recover --commit-slot accept a kernel by its existence. The staged file is hashed now, removed on a mismatch, and renamed only when it is the manifest's. The three writers of a root image (the updater, the installer, recovery) wrote with conv=fsync and read straight back, which on any machine with memory to spare reads the page cache. blockdev --flushbufs sits between the two now, so 'verifies after write' is about the disk.
This was referenced Sep 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The "state partition is encrypted" item of version 1.0, as designed in
docs/design/state-encryption.md, plus two things found on the way.Stacked on #40, #39, #26 and #33 (the branch starts at
integration/with-channel), so the diff against main shows those too. The three commits of this branch are the last three.The six installed-system suites share one copy of their helpers (
tools/image/suite-lib.sh). Verdict counters, test accounts, control-disk preseed,start_vm,stop_vm,drive,ROOTSH,part_start: the same twenty lines six times, three spellings of one function. No behaviour change. 90 lines out, 24 in, plus the 35-line file. It comes first because the state passphrase then lives in one place.The state partition is LUKS2.
kryptik-installasks twice for a passphrase before its first write (one line of standard input when unattended), formats partition 4 withaes-xts-plain64and argon2id, ext4 inside.sysinitasks on the console, three times at most. A failed unlock, a destroyed header, or a plain filesystem in the partition's place is the degraded state that already exists. A plain filesystem is refused, not mounted: otherwise swapping the partition would be believed without a question.printf: never an argument, never a file.kryptik-recover --backup-state-header / --restore-state-header;kryptik state passphrase.Two things the design did not foresee:
sysinitturns echo off first (sttydiscards nothing) and reads the line itself.kryptik-consolenow waits untilsysinithas finished, however it ends, and gives up waiting for it to start after 30 s, so a broken service database still ends in a console.Suites:
vm-drive.pyanswers the prompt wherever it appears (fromKRYPTIK_STATE_PASSPHRASE), andrun-ovmf.sh's smoke mode attaches the driver, so none of the 35 login sites changed. install-test proves the LUKS header, the absent ext4 superblock, that a file written under/homecannot be read from the raw partition, and that the passphrase is on no command line and in no file under/runor/etc. state-test proves three wrong passphrases (asked exactly three times) and a destroyed header. integrity-test plants its/etcentries through a mapping opened on the host, so the allow-list is tested behind the encryption.Not yet checked by a suite, and said so in the design:
kryptik state passphraseand the two header commands.A kernel is judged before it gets its name on the ESP; a read-back reads the disk.
kryptik-updaterenamed the staged kernel tokryptik-<slot>.efiand then compared its hash. The payload directory is mutable after verification, so a swapped kernel failed the check with its final name already on the ESP, whererollbackandkryptik-recover --commit-slotaccept a kernel by existence. It is hashed while still.newand removed on a mismatch.blockdev --flushbufssits between the two.Checked locally:
test-services95,test-sysinit-etc-upper16,test-boot-success28,test-step-errexit58,test-update-manifest-snapshot21,test-installer19,test-release-manifest46, shellcheck clean, and a fake-console test of the driver (a prompt split across reads, three in a row, each answered once, nothing in the transcript). None of that boots anything: the Distro run on this branch (35531833163) is the test.