Skip to content

kryptik-stick reads exFAT, as SD cards over 32 GB come, every field bounded before it is used - #490

Merged
DevomB merged 98 commits into
mainfrom
stick-exfat
Oct 15, 2026
Merged

DevomB merged 98 commits into
mainfrom
stick-exfat

Conversation

@DevomB

@DevomB DevomB commented Oct 13, 2026

Copy link
Copy Markdown
Owner

What was wrong: kryptik-stick read FAT alone, and every SD card over 32 GB (SDXC), which is what phones and cameras fill, comes formatted exFAT, so a zone given one could read nothing off it. The guide names exFAT as a gap.

What changed (read only, as d6 decided under the owner's word: write comes after the read path has a green run):

  • tools/stick/src/exfat.rs, a reader of Kryptik's own, in the zone like the FAT one (ADR-025); no crate and no kernel change. Before anything is read or allocated:
    • the boot sector's geometry is checked against the stick's own size (sector 512 to 4096, cluster at most 32 MiB, the FAT and heap inside the volume, the FAT long enough for the cluster count), and the boot region's checksum is verified;
    • the up-case table's length (at most 128 KiB) and checksum are verified before use;
    • each file's entry set (2 to 18 secondaries) is read whole and its checksum verified before any field is read;
    • a straight run (NoFatChain) must lie inside the heap, and every chain is counted against the heap's clusters.
  • Each command keeps the FAT walk's entry, byte and depth budgets. It also counts the directory clusters it reads, so a chain that loops through unused or deleted entries stops on that cap, not on yielded entries alone (the residual accepted for fatfs). Directories are read 4 KiB at a time.
  • Names compare through the volume's up-case table, show through shown() and land through local_name(); an unpaired surrogate shows as U+FFFD.
  • kryptik-stick says "exFAT, read-only (exFAT is read here, not written)"; put refuses with that.

How it was checked: unit tests build a small exFAT volume as a formatter does (2 MiB, 512-byte sectors, 4 KiB clusters, bitmap, up-case table, label) and read it: listing, a case-blind path, a whole tree, zeros past the valid data, a chain in FAT order, a name that is not UTF-16. A corpus of hostile volumes is each refused with its reason: a root whose chain loops through deleted entries, a straight run past the heap, a set that fails its checksum, an up-case table too long or with a bad checksum, a set running off its directory, a set of 19 secondaries, a directory naming the root, a bad boot checksum, a volume larger than its stick, a cluster size past the spec. CI's kryptik-stick job runs them (no compiler on this laptop). Still to come on this branch: the zones-test round with an exFAT card image the host makes, checked by exfatprogs' fsck, and the design and guide lines.

DevomB added 30 commits October 13, 2026 05:51
…or, in the zone that holds it

ADR-025 hands a zone a descriptor on the partition the user gave it, and
needs a reader there that parses the filesystem on that descriptor:
mtools, exfat-fuse and ntfs-3g take paths, and reopening a block device
by path is refused to the zone.

tools/stick is that reader, a crate of its own, since kryptikd keeps to
libc alone (ADR-010). It is fatfs 0.3.6, without chrono, behind
kryptik-stick: no arguments says what the zone was given, then list
[DIR], get PATH [DEST], put FILE [DIR]. The descriptor comes from the
zone's broker (`stick`, answered `ok ro|rw WHAT` with SCM_RIGHTS), or
--device FILE for an image. A read-only grant refuses every write
whatever fatfs asks. Nothing on either side is written over. Names from
the stick reach the terminal with control characters as `?`, and reach a
path only as one component. A tree nested past 32 directories is
refused. A put is synced to the device before it is reported. FAT's
dates come from the zone's clock as UTC, held to 1980-2107.

CI tests it and builds it for musl. The Distro workflow builds it beside
kryptikd, held to the same path check, and stage 04 installs it
(KRYPTIK_STICK_BIN, the stick step). The licence texts of fatfs,
bitflags, byteorder and log are in build/licences, and
check-rust-licences.sh reads its lock.
The stick reader parses a FAT filesystem in the zone, but nothing in zone 0
could hand it a stick: the design (removable-storage.md) has kryptikd make a
device-mapper linear mapping of the one partition the user picks and pass it
to the zone through its broker, never mounting it.

stick.rs decides which block devices may be given (a partition of a USB disk
or SD card, or such a disk with no partition table; held by nothing, mounted
nowhere, not on a disk the system's own mappings use), makes the mapping
read-only unless the user chose to write, records the grant under
/run/kryptik-stick, and on eject wipes the mapping's table so every read and
write on a descriptor the zone kept fails, then removes it once the last
closes. A zone file says `[storage] removable = false` to take no stick; the
vault's does, and the net zone never takes one.

The broker answers `stick` with the zone's descriptor and `ok ro|rw WHAT`;
the launch daemon has `sticks`, `stick-grant ZONE PART ro|rw` and
`stick-eject ZONE` for the chrome, and `info` says what a zone holds; root
has `kryptikd stick list|grant|eject`. A zone's stick goes back when the zone
stops.

Checked with unit tests on a stand-in /sys of the kernel's shape (a USB
partition named with its vendor, model, size and GPT name; a stick with no
partition table; an SD card; an NVMe disk refused; a held, mounted or
system-disk partition refused; bad names), the grant record round trip, the
mapping lookup, the zone key, and the broker parse.
…s suite plugs one in

A stick plugged in now opens a window of the chrome naming each partition
by what sysfs says of it; a letter and a zone's number give it to that zone
to read, or with w to write too, and e with the number takes it back. The
menu's d opens the same window, and its zone lines say which zone holds a
stick. kryptik-launch gains --sticks, --stick-grant and --stick-eject for it.

Eject now writes out what the zone left unsynced, then wipes the mapping's
table and drops what was cached of the stick: a wiped table alone left the
pages the zone had read readable through a descriptor it kept. A stick
pulled out without eject loses its grant the next time the sticks are
listed, which the chrome does whenever a block device comes or goes, and a
grant is refused while a program in the zone still holds the mapping of a
stick taken back from it.

zones-test.sh plugs a FAT stick into the running guest through QMP
(vm-drive.py's plug: and unplug: steps, run-ovmf.sh --usb-port) and runs
guest-tests/stick-check.sh between the plugs: zone 0 mounts and probes
nothing; vault and the net zone are refused; untrusted, given it read-only,
lists it and copies a file off whole, cannot write, and has no node for it;
another zone asking is refused; eject frees the partition at once and a
descriptor the zone kept reads only EIO; what the zone puts with a write
grant is on the image the host reads after pulling it out; a program
holding the stick when it is pulled out reads EIO and the grant ends with
no oops; and the install medium plugged in offers its ESP, which the reader
lists, and its root, which the reader refuses. chrome-sticks.sh checks the
window and the watcher against a stand-in launcher, and vm-drive.py's test
the plug steps against a stand-in QMP socket.
If lvm2's rules ship, 13-dm-disk.rules runs blkid on every device-mapper
device as it appears or changes, so a grant would have zone 0 parse the
stick's superblock as root through kryptik-stick-ZONE, which
59-kryptik-removable.rules keeps eudev from doing on the partition itself.
12-kryptik-stick.rules sets device-mapper's disable flags for those
mappings, by their sysfs name and by the name 10-dm.rules imports.

stick-check.sh's new round, after the grant: eudev holds no ID_FS_
property for the mapping or the partition.
A video call in a zone had the microphone and no camera: the kernel built no
video support. ADR-026 grants the camera as the microphone is granted.

The kernel builds Video4Linux and uvcvideo as modules, filtered to cameras
and test drivers, with no camera button input device; modprobe.d/camera.conf
keeps uvcvideo from loading by alias, and stage 05 checks both. vivid is
built as a module for the zones suite to stand in for a camera, without its
framebuffer; the hardening checker's finding on it is accepted, with why.

kryptikd's camera.rs: a zone that may hold the camera (a new [camera] use
key; vault's file says no, and the net zone never does) gets at start a
directory of its own from a tmpfs that allows device nodes, bound at
/run/kryptik/camera without nodev, /dev/video0 linking into it, and a
Landlock rule for it. A grant loads uvcvideo, binds again what an earlier
grant's end let go of, finds the first capture node in sysfs and makes it
there, owned by the zone's root, mode 0600. Its end, by the user, the
zone's stop or the screen lock, removes the node, unbinds the driver from
the device so a kept descriptor fails, and unloads uvcvideo unless
something still holds it, which the chrome then says.

The launch daemon has camera-grant and camera-revoke, kryptik-launch
--camera-grant and --camera-revoke, root kryptikd camera grant|revoke|
status. The chrome's menu has k<N> and k and a Camera line, a window says
which zone holds the camera for as long as it does, and kryptik-lock takes
the camera back as the screen locks.

Checked: camera.rs unit tests on a stand-in sysfs (the first capture node,
not a metadata one, numbered as numbers, its name shown plainly; the device
and bus to let go of; the record round trip), the zone key, and
chrome-camera.sh against a stand-in launcher. zones-test step 2c runs
guest-tests/camera-check.sh with vivid: no load by alias, no camera before a
grant, vault and net refused, the grant's node and a captured frame, the
zone's stop ending it, another zone finding none, k alone failing a kept
descriptor and unloading uvcvideo, a grant to a running zone. gui-check's
lock round: the screen locking takes it back.
A mapping ejected while its zone still held the stick is removed by
device-mapper once the zone's last descriptor closes, from a workqueue, so
the eject kryptikd runs at the zone's stop can find it and then lose it
between two dmsetup calls, and logged that as a failure. Eject now treats a
mapping that is gone as done, says whether a grant ended rather than
whether a mapping was there, and still clears a mapping left without a
grant.

stick-check.sh's rounds that look for the mapping to be gone wait up to ten
seconds for that removal instead of looking once.
The chrome opens its camera window within a second or two of a grant, and
a window that maps takes the keyboard: granted after the round's zone 0
window had it, the camera window could take it before the lock did, which
the round would read as the lock's doing. Granted first, and waited for,
the window is under the round's own.
A directory tree nested one level past MAX_DEPTH stops the copy off the
stick with the limit named, where a stick built to loop would otherwise
recurse until the zone's stack or disk ran out.
Every walk of the stick's directories, path lookup included, now goes
through one loop that stops with "the stick's filesystem is malformed"
past FAT's 65,536 entries in a directory, or past as many entries or bytes
as the volume has room for in one command. fatfs follows a cluster chain
that loops, so a directory could be endless (list grew its list until the
zone's memory ran out), and directories aliasing an ancestor multiplied
under get, past what the depth limit stops, until the zone's disk was full.
put looks for the name within the same bounds before fatfs walks the
directory to add to it.

A name holding a control character is no file name here: FAT allows none,
and the errors that name a path here carried it to the terminal. Errors
show paths as names are shown, and the marks that turn text around show as
'?' too.

The stick's row in stage 04 sits above kryptikd's comment, which is
kryptikd's.
… as kryptikd is

Stage 06's strict audit flags every static Rust binary for both, and lists
kryptikd and the proxy by name; kryptik-stick is built the same way.
…wn aliases

kmod's modprobe skips a blacklisted module's own aliases with or without
-b, so a dry run by the alias names nothing either way and could not show
the alias belongs to uvcvideo; modinfo reads it from the module.
…he camera

A zone holding the microphone or the camera has a window of the chrome's
that says so, but a window the user puts in full screen covers it, and only
dwl's bar above that window stays in sight: a zone could take a grant, wait
for full screen, and record with nothing on screen saying so.

The bar now reads, after the zone's name, "mic ZONE" and "cam ZONE" for
whichever zones hold them, on every fullscreen window, not only the holder's.
dwl learns them from two files through one inotify descriptor on its event
loop, and draws every fullscreen bar again on a change: the camera's grant
record, which kryptikd writes as root, and the microphone's holder, which
the mixer now writes into its own control directory (uid 87, 0640, group
kryptik) at its start and on every grant and revoke. dwl takes a file only if
it is a regular file of its writer's that no one else may write, and a name
only if it is a zone dwl knows; anything else marks nothing. kryptikd's
launch daemon mounts the camera's tmpfs as it starts, so the compositor's
watch is on it and not on the directory beneath.

gui-test, with vivid as the camera: untrusted fullscreen, granted the camera
and recorded as the microphone's holder, its bar reads "untrusted mic
untrusted cam untrusted" pixel for pixel; both taken back, and a record of
the microphone's holder put in place by root rather than the mixer, the bar
names the zone alone. The sound suite checks the mixer's record on the grant
and once the zone's stop has ended it.
…a directory made anew drops

An inotify watch belongs to the directory's inode: the mixer's control
directory made again, or anything mounted over a watched directory, ends
the watch, and every later grant went unmarked until dwl restarted. dwl now
also reads both records once a second, draws the fullscreen bars again only
when a holder changed, and sets both watches again each time, which keeps a
watch that is there as it is.

The gui round makes the mixer's directory anew before the records are
written, so the marks it checks come through a directory dwl did not first
watch, and puts the mixer's own back after.
…Bluetooth and input methods, and the laptop bullet rewritten
…ooth and input methods, and the laptop bullet rewritten
DevomB added 27 commits October 14, 2026 21:24
…, and the desktop suite checks it

# Conflicts:
#	docs/status.md
…ke, and the desktop suite checks it

# Conflicts:
#	docs/status.md
…ore lock-0's window opens, and the desktop suite is named beside the zones suite as loading vivid
…out round lists the sticks only once the kernel has let the stick's partition go
…deo0, as no other is granted the camera; the camera suite is named in the image's program list
…ard, so untrusted's fullscreen has ended for Alt+e to give back
… card pulled out, so a moved microphone and a pulled USB card no longer leave a loop spinning
…hes, a zone's sched and ioprio calls refused, proxy-calls ending at a proxy's exit
@DevomB
DevomB changed the base branch from guide-laptop-checks to main October 15, 2026 12:31
@DevomB
DevomB marked this pull request as ready for review October 15, 2026 12:31
@DevomB
DevomB merged commit dad40fc into main Oct 15, 2026
6 checks passed
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