Skip to content

A zone cannot touch the launcher's log, and the update chain's remaining gaps are closed - #44

Draft
DevomB wants to merge 51 commits into
mainfrom
update-staging-hardening
Draft

DevomB wants to merge 51 commits into
mainfrom
update-staging-hardening

Conversation

@DevomB

@DevomB DevomB commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Stacked on #42 (and through it on #41, #43, #40, #39, #26, #33). This branch's own commits are the last six.

A zone writes into a pipe; the launcher is the only writer of its log. Launched by the daemon, a zone's stdout and stderr were the launcher's own log file, inherited. O_APPEND stops neither ftruncate nor fallocate, and the zone's seccomp filter allows both: any program in a zone could erase the launcher's record of what crossed the boundary (brokered transfers, clock claims, update verbs are logged there and nowhere else), forge lines that read as the launcher's, and fill the state partition in one call. The zone's side of the fork now gets a pipe; the launcher's existing supervision loop relays it: every line under the zone's mark, control bytes replaced, 1 KiB a line, 1 MiB a launch, read and dropped past that so nothing blocks. serve.sh makes the attack: against the old launcher the log held the zone's second line and nothing else; against this one both lines are there, marked.

Update staging (kryptikd): a refused manifest is not retried for an hour (the statement already had that rule; the manifest did not, and a refusal cleared the stage); an existing staging directory is checked for type, owner and mode; an atomic rename is followed by a sync of its directory; the broker no longer reads the role and running version for each of the three thousand update-put requests a release arrives as.

release-manifest.sh pointer verifies the manifest's signature against the image's anchor before it writes a statement about it; it used to check that manifest.sig existed.

kryptik-update opens the four payload files once and hashes, measures, greps, writes and copies them through those descriptors, so what was hashed is what is written; a link is refused. The unlisted-file check runs before the multi-gigabyte hash instead of after, sees names that begin with a dot, and compares names whole instead of as patterns.

docs/status.md names the last run that passed (35482105602, read back against the corrected verdict rule) and what no run has proven yet.

Checked locally: kryptikd 204/0, serve 59/0, launcher 126/0, cli 27/0, test-update-fetch 11/0, test-update-manifest-snapshot 24/0 (three new refusals), test-release-manifest 47/0 (one new), shellcheck and dash clean. Nothing here has booted; it needs its own Distro run after #42's.

…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.
…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.
… has a size

transact_on read replies with a plain recv on an unconnected socket. These
sockets are opened inside the nic zone's network namespace, where the zone
holds CAP_NET_ADMIN, which is what the kernel asks of a process that sends
to another netlink port. The error arm read its code four bytes past what
had been validated: a 16-byte forged error read stale zeros as success, and
one placed at the end of an 8192-byte datagram indexed past the buffer,
which aborts the launcher (panic = abort) after the zone's LUKS volume is
open, so its Drop never closes it. The socket is connected to port 0 now
(the kernel refuses unicast to a connected socket from any other port), an
error must carry its code, and the gathered reply is capped at 64 KiB.

The zone's /tmp was the one tmpfs mounted without a size, so it was bounded
by half the host's memory, and a zone without [limits] has no cgroup to stop
it. 256 MiB, like /dev/shm.
…led accept, and logs at a rate

global_remove deleted the global's entry on the first registry's event. The
compositor sends one event per registry a client holds, so a second registry
(a toolkit's beside a library's) was never told and kept a stale wl_output or
wl_seat, and a bind that raced the removal was refused as a hidden interface
and killed the client. The entry stays; whether the global still exists is
the compositor's to answer. No test covered the event: one does now, and it
fails on the old logic.

An accept error other than WouldBlock was only logged. The failed connection
is still pending, so the listener was readable again at once: a loop at full
speed writing a line per pass, which a client reaches by parking descriptors
until the table is full. The listener is left alone for a second after a
failed accept.

Lines a zone's clients can cause (connected, ended, refused) are held to
twenty a second with a count of what was dropped: the log is a file in the
session's runtime directory. The interface name of a refused bind is printed
escaped; it is the client's text and could carry newlines and escapes.

PeerClosed was never constructed and object_count never called; both go.
… a skipped item, and lists what it exports

Four ways the report could say more than was measured.

A driver that exited 0 was a PASS whatever its summary said: '25 passed, 3
failed' was parsed, shown in the checks column and ignored. A summary that
counts a failure is a FAIL now, in each of the five wordings the suites use.

--no-host skipped the mandatory host-suites item without a row, so the
verdict never saw that it was missing. It has a row, INCOMPLETE, which is
never a pass.

When the release under test had no ISO, the newest ISO of any other release
was tested in its place. It is no longer looked for: the ISO items are
INCOMPLETE instead.

The export copied results.tsv before its own row existed, and SHA256SUMS
covered the media, the certificates and root.json only. The results and logs
are copied again at the end, and the list then takes every file of the
export but itself: manifests, signatures, the report, the instructions,
RELEASE.txt. The media lines come from the hashes that were compared, and
they are compared against the hashes taken when the run began, so a medium
that changed during the run is a mismatch too (and two passes over each
3 GB file are gone).

test-acceptance-inputs.sh runs item(), the parser, the verdict and the list
as they are written in acceptance.sh: 15 new rows, four of which fail on
the old rule. The last PASS on main (run 35482105602) was read back against
this: no row of its results.tsv counts a failure.
…ves up

LICENSE ended in a note to append the GPL's text before a release, and
pointed at a docs/licensing.md that does not exist. It carries the text as
gnu.org publishes it, and points at the tool that records what each shipped
source is licensed under.

The threat model called a stolen laptop 'defended at rest' while the state
partition was plain ext4. With the partition encrypted the claim can be made,
and it is made exactly: defended against a reader, not against a writer,
which is why what /etc may carry stays a list on the verified root. The
side-channel section said core scheduling was not implemented; every zone
takes a cookie and the command line carries nosmt, and what remains is
named.
accept_pointer refused a statement older than the newest one accepted and
never looked at the clock. One statement signed with a date far ahead (a
compromised freshness key, or a release host with a wrong clock) was
accepted and stored, after which every honest statement was 'a replay'
until that date arrived, and staleness read zero days the whole time, so
the warning that the news is old never fired either. It takes the clock
now and refuses a date more than a day past it, before anything is stored.
The same statement again is still accepted, as it was: that is what a
re-issue looks like to a machine that polls faster than the schedule.

The staging tests' clock sat 46 days before the statements they use; it is
an hour after them, and the stored-statement test refuses next year's.
…not when a uid exists

useradd ran before the password was set, the preseed was deleted whatever had
happened, and every later boot stopped at the first uid of 1000 or more. Root
cannot log in at a terminal, so a power cut or a failed passwd between those
steps left an installed machine that nobody could log into and that never
asked again.

Done is now read from the account database: a regular user and root both
carry a password hash. Whatever is missing is what the next boot does: it
finds the user that already exists rather than asking for a name, sets the
passwords that are not set, and removes the preseed only once it has done its
work. No marker file, which could only ever disagree with the accounts.

test-firstboot.sh takes the three predicates from the script and walks the
states a cut-short setup leaves behind.
…ted, by the shared rule

Both found the medium with blkid -t PARTLABEL=kryptik-media | head -1: the
first disk the kernel enumerated that carried the label, which is the defect
devices.sh exists to prevent. With a second Kryptik stick attached, or a disk
labelled to look like one, the image and kernels installed or restored could
be that disk's, checked only against that disk's own unsigned root.json.
kryptik_part answers from the disk under the running root, which the firmware
verified, and refuses more than one.

The installer carried private copies of two devices.sh functions that had
drifted from it (the original gained a fix for stacked devices; the copy did
not). They are gone and the library is sourced. Its guard against installing
over the running system took the root's device name from /proc/mounts, which
with no initramfs is /dev/root and names nothing; kryptik_root_disk knows that
case.
…ersion, virt-firmware by version

Every action was named by a tag its owner can move (checkout@v5, cache@v5,
upload-artifact@v6, download-artifact@v7) and the compiler of the two
binaries that ship was 'stable', whatever that was on the day. The 23 uses
are pinned to the commit each tag names today, with the release in a comment.
The toolchain is 1.98.1, today's stable, named once in the workflow's
environment, and the step that builds kryptikd and kryptik-wlproxy refuses to
run with any other rustc. virt-firmware is 26.9.

Not done, and said in the workflow: rustup checks its download against the
channel's manifest, not against a hash kept here, which is what the roadmap
asks of the compiler. The pip package is pinned by version, not by hash.

CI's suite loop takes tools/test-*.py as well: test-launch-secrets.py, which
proves the display proxy never inherits the passphrase descriptor, matched no
glob and no list and ran nowhere.
…ound, not listed

run-tests.sh (acceptance's host-suites item, and make test) ran a hand-written
list of fifteen suites. Eighteen tools/test-* files were not on it, among them
the tests of the release manifest, the /etc quarantine, the source fetcher and
the two signature and provenance verifiers, so a release could be accepted
with them red. The list is built from the files now; four are named because
they run elsewhere, with the reason. Through it on a developer host: 39
suites, 38 pass, and the one that does not is the hooks suite asking git
about a Windows worktree from WSL.

Two things that would have broken the moment the list was general:
test-launch-secrets.py was recorded 100644, which on Linux is 'not
executable', and the hooks suite read the real repository with a plain git,
which refuses as root over a checkout that is not root's (acceptance runs it
so). The mode is fixed and CI's exec-bit gate covers the pattern; the suite
passes safe.directory, as acceptance.sh already does.

Shellcheck took a list of places that left out the boot services, the
installer, the updater, the net zone and every installed-system suite. It
takes every tracked file that begins with a shell's name: 130 scripts. That
found two warnings, an assignment in kryptik-chrome nothing read and an
ls | grep in a developer script; both are gone.
…n stage 05 asks

Stage 05 kept a private list of options that must survive config resolution
and took only =y. The built-in rule made the virtio GPU driver a module, the
fragment says =m, the fragment check passed (374 lines, none dropped), and the
private list then failed the kernel as MISSING CONFIG_DRM_VIRTIO_GPU, two and
a half hours into each of three builds, because no build had reached stage 05
since the rule was merged. CI's config check could not have caught it: it
shared the fragment check with the stage and not this list.

The list and its rule are in build/lib/kconfig-check.sh now, and
tools/resolve-kernel-config.sh asks them too, so CI answers in half a minute.
Built in or a module is the fragment's to say, and the fragment check holds
it to that; this list exists so that deleting a guarantee from a fragment is
noticed, and a bool cannot come out =m, so nothing is loosened. Checked on
the resolved config: it passes; with DM_VERITY deleted or SECCOMP_FILTER
switched off it is refused.

Both checks read the .config once into a map. They asked the file with
sed | head for each of 374 fragment lines, some 750 processes and as many
passes over the whole file; the fragment check takes a quarter of a second.
…may be modules, and CI asks the question stage 05 asks
… log

Launched by the daemon, a zone's stdout and stderr were the launcher's own
log file: the same open file, inherited. O_APPEND stops neither ftruncate nor
fallocate, and the zone's seccomp filter allows both, so any program in a
zone could erase the launcher's record of what crossed the boundary (every
brokered transfer, clock claim and update verb is logged there and nowhere
else), write lines that read as the launcher's, and fill the state partition
with one call. serve.rs already said 'zone output can make this file
arbitrarily large' and bounded only the read.

The zone's side of the fork gets a pipe in place of 1 and 2, first thing, so
nothing from there down holds the log. The launcher's supervision loop, which
already polls the broker's socket, reads the pipe too: each line goes into the
log under the zone's mark, control bytes (terminal escapes among them) are
replaced, a line is cut at 1 KiB, and a launch may add 1 MiB; past that the
output is read and dropped, so a program that prints for ever neither blocks
nor fills anything. Dropping the relay drains it, so a launch that fails
before supervision begins still logs what the zone's side said. At a terminal
(kryptik shell) the zone keeps the terminal.

serve.sh makes the attack: between two lines the zone's command truncates its
stdout. Against the old launcher the log then held the second line and
nothing else; against this one both lines are there, marked. Unit test for
the mark, control bytes, long lines and the bound. kryptikd 204/0, serve
59/0, launcher 126/0, cli 27/0.
… looked at, and a rename reaches the disk

Three gaps in how zone 0 stages what the net zone hands it.

The statement of what is current is looked at once an hour 'so a hostile
zone cannot make zone 0 verify signatures all day'. The manifest had no such
rule: a refusal cleared the stage and the next pair was accepted at once, so
once a fetch had been asked for, the net zone could spawn the verifier on
input of its choosing as fast as it could send. A refusal is recorded now,
and until the interval has passed the next pair is refused unread.

private_dir took a directory that already existed as it found it. It checks
that it is a directory, this user's, and closed to everyone else.

put_file synced the file and renamed it, and never synced the directory, so
the rename itself could be lost to a power cut.

And the broker read the required role and the running version for every
update verb, including the three thousand update-put requests a release
arrives as, which use neither; only the two verbs that need them read them.
…e will accept

release-manifest.sh pointer checked that manifest.sig existed and was not
empty. A signature by the wrong key, in the wrong namespace, or of another
file passed, and the statement then announced a manifest every machine would
refuse: the channel would have looked alive and delivered nothing. It takes
the anchor the image carries (--signers) and verifies the signature against
it first, as stage 06 already did for the payload beside it. One new row in
test-release-manifest.sh: a manifest signed by a key the anchor does not hold
gets no statement.
…re it hashes

verify_payload hashed kryptik-root.img and the kernels by name, and apply
opened the names again for dd and cp. A payload directory can change in
between (the net zone staged it; a medium can be swapped), and the read-back
only stops a substituted image from being activated, not from being written
over the inactive slot. The four files a release is made of are opened once,
on fixed descriptors, and hashed, measured, grepped, written and copied
through /proc/self/fd from then on: what was hashed is what is written. A
link is refused, and so is a name that stopped being the opened file while
it was being opened.

The check for unlisted files ran after every listed file had been hashed, so
a stowaway cost a full pass over the root image before it was refused; it
runs first. It globbed *, which never sees a name beginning with a dot, and
asked grep whether the name matched the manifest as a pattern, so
kryptik-rootXimg counted as listed. It looks at every name and compares them
whole. Neither reached a write, since only named files were ever used, but
'nothing unlisted' was not what had been checked.

Three new rows: a linked root image, a dotfile and a look-alike name are each
refused. 24 of 24.
…has proven

It recorded a pass of 2026-09-16 and an unverified fix from the same week.
The last pass is run 35482105602 on 55e1652, with its own counts, read back
against the corrected verdict rule. Main has not passed since, for two
reasons that are named, and four things it carries have not been reached by
any run; those are named too.
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