diff --git a/docs/decisions.md b/docs/decisions.md index c8549cca..188b9acd 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -34,9 +34,9 @@ containers. X11 lets any client log every other client's keystrokes and capture the whole screen, which ADR-003 cannot allow. -**Cost:** X11 programs are not supported. No Xwayland is built, and a zone -runs Wayland clients alone; if the graphical applications of Version 2 need -one, it runs inside the zone, where it can leak only that zone. +**Cost:** X11 programs are not supported. No Xwayland is built, and zones run +only Wayland clients. If Version 2's graphical applications need Xwayland, it +runs inside the zone, where it can leak only that zone. ## ADR-005: hardened_malloc as the system allocator @@ -54,9 +54,9 @@ Socket activation and journald do not justify a large privileged PID 1 in a system that assumes a local attacker looking for privileged code. **Cost:** off the LFS path, so every service definition is written from -scratch; seatd instead of logind; the `net` zone runs its own DHCP client, and -no other zone touches a real interface; logging is s6-log per service, with no -aggregation. +scratch. Seats come from seatd, not logind. The `net` zone runs its own DHCP +client, and no other zone touches a real interface. Logging is s6-log per +service, with no aggregation. **Revisit if** writing service definitions becomes the main cost of the base system. @@ -119,18 +119,19 @@ process would contradict that. **Cost:** -- rustc is not built from source: that needs an existing rustc, or mrustc, a - project of its own. The shipped kryptikd and kryptik-wlproxy are built by - Rust's release tarballs, held to the hashes in `build/config/rust.lock` - (checked against the Rust release key when pinned): a trust anchor - [supply-chain.md](supply-chain.md) otherwise avoids. +- rustc is not built from source, since that needs an existing rustc or + mrustc, a project of its own. The shipped kryptikd and kryptik-wlproxy are + built with Rust's release tarballs, held to the hashes in + `build/config/rust.lock` (checked against the Rust release key when + pinned). That is a trust anchor [supply-chain.md](supply-chain.md) + otherwise avoids. - kryptikd depends on `libc` only; every new crate is a supply-chain decision justified in review. - A Rust toolchain is a lot to carry for one daemon. -**Rejected:** C (smallest bootstrap, but see above); Go, whose runtime and -scheduler fight `clone()`, `unshare()` and per-thread namespace state; shell, -unsuitable for holding privilege and parsing untrusted zone state. +**Rejected:** C (smallest bootstrap, but not memory-safe); Go, whose runtime +and scheduler fight `clone()`, `unshare()` and per-thread namespace state; +shell, unsuitable for holding privilege and parsing untrusted zone state. Rust is for kryptikd and Kryptik's own tools, not a distribution-wide rule: coreutils stays coreutils. @@ -149,9 +150,9 @@ it. **Cost:** half the logical CPUs on an SMT machine, roughly 15 to 30 percent of parallel throughput. Single-threaded performance is unchanged. -**Decided:** `nosmt` stays. Each zone asks for its own core-scheduling -cookie at launch ([privileged launch](design/privileged-launch.md#core-scheduling)), -which keeps two zones off the two threads of one core; it cannot keep a zone +**Decided:** `nosmt` stays. Each zone asks for its own core-scheduling cookie +at launch ([privileged launch](design/privileged-launch.md#core-scheduling)), +which keeps two zones off the two threads of one core. It cannot keep a zone off the thread beside the kernel, since the kernel's own execution carries no cookie, and that is the leak the mitigations exist for. Closing it with SMT on means a flush on every kernel entry, which costs more than the threads give. @@ -175,10 +176,9 @@ Wi-Fi. These are vendor binaries, not built from source as [supply-chain.md](supply-chain.md) otherwise requires, and run by the device's own processor under the kernel's control of the bus (IOMMU on and strict). -Kryptik establishes that the tarball is the one kernel.org signed, its hash is -pinned, each file's licence is the one `WHENCE` records, and the copy the -kernel loads is on the verified root, so replacing it means re-signing the -kernel. +Kryptik checks that the tarball is the one kernel.org signed, pins its hash +and checks each file's licence against `WHENCE`. The kernel loads the +firmware from the verified root, so replacing it means re-signing the kernel. **Left out:** NVIDIA (nouveau needs tens of megabytes of GSP firmware per generation, and the firmware framebuffer gives those machines a display); @@ -222,7 +222,7 @@ on old microcode, and nobody would notice. The firmware loads Kryptik's kernel as the UEFI application, and nothing else runs before the verified root: no shim, no boot loader, no initramfs. The -command line is compiled in and names the root slot and its dm-verity root +command line is compiled in and carries the root slot and its dm-verity root hash, so the firmware's one signature check covers the code and the hash of everything it will run ([boot and updates](design/boot-and-updates.md)). A machine trusts that signature once Kryptik's certificate is in its firmware's @@ -230,13 +230,13 @@ database ([release keys](release-keys.md)). **Why:** every stage between the firmware and the root is a file to sign, a parser to attack and a place for an unmeasured change. A shim chains from -Microsoft's key, which Kryptik does not use; a boot loader chooses and edits -what boots, which the compiled-in command line forbids on purpose; an -initramfs finds the root, which `dm-mod.create=` does inside the kernel. +Microsoft's key, which Kryptik does not use. A boot loader chooses and edits +what boots, which the compiled-in command line forbids. An initramfs finds the +root, which `dm-mod.create=` does inside the kernel. **Cost:** the certificate is enrolled by hand on every machine, and firmware -that carries only Microsoft's keys refuses the media; the controller the root -sits on is built into the kernel (ADR-013); A/B updates and recovery are the +that carries only Microsoft's keys refuses the media. The controller the root +sits on is built into the kernel (ADR-013). A/B updates and recovery are the firmware's boot entries and a judged trial, not a loader's menu. **Rejected:** shim and a loader, two more signed stages and a configuration diff --git a/docs/design/broker.md b/docs/design/broker.md index 428cb7e0..d1a79240 100755 --- a/docs/design/broker.md +++ b/docs/design/broker.md @@ -13,7 +13,7 @@ started from the desktop gets its own Wayland proxy. Builds on Every zone has its own host uid range (`[identity] uid_base`). A connection accepted in zone 0 carries `SO_PEERCRED`, whose uid the kernel asserts and a zone cannot choose, so no token, handshake or crypto is needed. Each zone's -broker accepts exactly the uid its launcher mapped the zone to; anything else +broker accepts only the uid its launcher mapped the zone to; any other peer gets `error: unidentified peer` and nothing more. The peer pid is never used (it is a zone 0 pid and may be reused). On an unprivileged developer launch every zone maps to the same user, so identity distinguishes nothing there. @@ -22,11 +22,11 @@ every zone maps to the same user, so identity distinguishes nothing there. The launcher binds one socket per zone in its registry entry (`/run/kryptik/zones//broker`, 0600, owned by the zone's identity) and -bind-mounts that one file into the zone at `/run/kryptik/broker`; the +bind-mounts that file into the zone at `/run/kryptik/broker`. The intermediate opens it (`O_PATH`) in its own mount namespace, before the id -switch. A zone reaches only its own endpoint, and the broker socket plus, for -a desktop launch, the proxy socket are all it has under `/run/kryptik`. Zones -inherit only fds 0 to 2 and reach the broker with `connect(2)`. +switch. A zone reaches only its own endpoint: under `/run/kryptik` it has the +broker socket and, for a desktop launch, the proxy socket, and nothing else. +Zones inherit only fds 0 to 2 and reach the broker with `connect(2)`. `kryptikd run` serves one request per connection between `waitpid` polls: one header line, one reply, a 5 s deadline, every refusal decided from the @@ -47,7 +47,7 @@ anything else -> error: \n ## File transfer The zone sends `transfer ` with one `SCM_RIGHTS` descriptor it -opened `O_RDONLY`. A descriptor rather than a path means kryptikd never +opened `O_RDONLY`. Because it sends a descriptor, not a path, kryptikd never resolves a path the zone controls. Checks, in order, stopping at the first failure: @@ -57,20 +57,21 @@ failure: 2. Exactly one descriptor, and `dest` is not the sender. 3. The sender's `[transfer] to` lists `dest`, a configured zone (absent means no transfers). The zone holding the NIC receives nothing: `kryptikd check` - refuses a `[transfer]` list that names it, and the broker refuses it again. - The only shipped policy is `dev`'s `to = "work"`. + refuses a `[transfer]` list that includes it, and the broker refuses it + again. The only shipped policy is `dev`'s `to = "work"`. 4. The descriptor is a regular file, `O_RDONLY` and not `O_PATH`, on the sender's data mount (`st_dev` of `/home/`, read through the zone's pid 1 root at request time), and within the size limit (below). `/proc/self/fd/N` is never consulted. 5. The destination is running. A refusal here tells the sender whether a - zone its own `[transfer] to` names is running, which timing would tell it + zone in its own `[transfer] to` is running, which timing would tell it anyway. -6. The user consents (below). Asked last, so a request that would be refused - anyway never becomes a question. After a question the user saw and did not - allow, the same launch asks nothing for a minute: every question takes - focus in zone 0, so a zone may not raise them in a loop. A refusal that - showed nothing (no channel, nobody watching) does not pause. +6. The user consents ([consent](#consent)). Asked last, so a request that + would be refused anyway never becomes a question. After a question the + user saw and did not allow, the same launch asks nothing for a minute: + every question takes focus in zone 0, so a zone may not raise them in a + loop. A refusal that showed nothing (no channel, nobody watching) does not + pause. No file larger than 1 GiB is carried, and a zone may lower that for itself with `[transfer] max_bytes = N`, a whole number of bytes from 1 to 1073741824; @@ -94,13 +95,13 @@ tree. As root, the copy runs with the destination's filesystem uid and gid, which is also what lets it create files in an ephemeral zone's tmpfs home. The name is taken with `O_CREAT|O_EXCL|O_NOFOLLOW`; a collision or planted link moves on to `-2`, `-3`, and so on, with no stat-then-create, temporary -file or `rename`. The file is 0600, owned by the destination. Exactly the -size the `fstat` found, the size the question showed, is copied, from the -file's first byte whatever the descriptor's position (`copy_file_range` with -its own offset and a running count). A file that grew or shrank since is -refused, and any failure removes the partial file. The sender still owns the -file, so it can change bytes within that size; only the size is fixed. The -sender learns only the outcome and the final name. +file or `rename`. The file is 0600, owned by the destination. The copy is the +size `fstat` found, which the question showed, read from the file's first +byte whatever the descriptor's position (`copy_file_range` with its own +offset and a running count). A file that grew or shrank since is refused, and +any failure removes the partial file. The sender still owns the file, so it +can change bytes within that size; only the size is fixed. The sender learns +only the outcome and the final name. ## Consent @@ -121,25 +122,24 @@ at most 60 s; silence, a malformed answer or a missing directory is a refusal, and a question whose sender goes away is withdrawn. The window takes focus when it maps, so a zone could time a request to land -under keys the user meant for the zone's own window. So the window drops -whatever was typed in its first second, half-typed lines too, and then asks -for a two-digit code drawn for that question alone: only the code, typed +under keys the user meant for the zone's own window. The window therefore +drops whatever was typed in its first second, half-typed lines included, then +asks for a two-digit code drawn for that question alone. Only the code, typed after it shows, answers yes. No zone sees a zone 0 window, so none can type -the code. It is kept beside the question for zone 0's tests, which grants -nothing: whatever can read the directory could write the answer. +the code. The code is kept beside the question for zone 0's tests, which +grants nothing: whatever can read the directory could write the answer. The session's group can write in that directory, so nothing found there is trusted. The broker opens the directory once and uses names relative to it -without following links; the question goes to an `O_EXCL` temporary name with +without following links. The question goes to an `O_EXCL` temporary name with a random nonce that is also in its id, so no answer can be planted in -advance; an answer that is not a plain file (a link, a FIFO) is a refusal. -The chrome's own writes there only create, never follow or clobber +advance, and an answer that is not a plain file (a link, a FIFO) is a +refusal. The chrome's own writes there only create, never follow or clobber (`set -C`), and its watcher removes a `.dialog`, `.code` or `.answer` that -outlived its question. -The watcher holds `watcher.lock` exclusively while it runs; a broker that can -take the lock shared knows nobody is watching and refuses at once. -`--auto-approve-transfers` approves everything for tests without a session, -and the launcher warns about it. +outlived its question. The watcher holds `watcher.lock` exclusively while it +runs, so a broker that can take the lock shared knows nobody is watching and +refuses at once. `--auto-approve-transfers` approves everything for tests +without a session, and the launcher warns about it. ## Clipboard @@ -153,11 +153,11 @@ channel. A cross-zone paste is a user gesture in zone 0: the chrome's `m` (move zone N's clipboard to zone M) runs `kryptik-launch --clipboard-move FROM TO`, -which the launch daemon carries out; as root it is `kryptikd clipboard move -FROM TO`. Both zones must be running. The payload leaves the source and -replaces the destination's: one payload crosses, once, and a second gesture -finds nothing to move. No zone can fetch another's payload or trigger a -move: the verb does not exist on the zone-facing socket. Zone 0 has no +which the launch daemon carries out (as root, `kryptikd clipboard move FROM +TO` does the same). Both zones must be running. The payload leaves the source +and replaces the destination's: one payload crosses, once, and a second +gesture finds nothing to move. No zone can fetch another's payload or trigger +a move: the verb does not exist on the zone-facing socket. Zone 0 has no transfer command either; the `kryptik` tool refuses `transfer`, `clipboard` and `mount` and points at the broker. @@ -172,8 +172,8 @@ serves exactly one zone. The proxy advertises only `wl_compositor`, `wl_subcompositor`, `wl_shm`, `wl_seat`, `wl_output`, `xdg_wm_base`, `zxdg_decoration_manager_v1` and `wp_viewporter`, disconnects a client that binds anything else, bounds objects, pending bytes and descriptors per -client, and rewrites every window's app_id to `kryptik..` and -title to `[zone] ...`, from which the compositor draws the zone's border. +client, and rewrites every window's title to `[zone] ...` and its app_id to +`kryptik..`, from which the compositor draws the zone's border. ## Tests @@ -188,7 +188,7 @@ title to `[zone] ...`, from which the compositor draws the zone's border. - `tools/tests/chrome-confirm.py` runs the chrome's real question window on a pty: the code shown allows, a plain `y` refuses, and keys or a half line typed before the question showed are dropped, for the clock question too. -- The launcher suite's broker section: `version` names the zone, an unknown +- The launcher suite's broker section: `version` reports the zone, an unknown verb is refused, the socket is 0600, a foreign peer is refused on a privileged launch. - `build/guest-tests/gui-check.sh` on the installed desktop: the proxy hides @@ -197,17 +197,18 @@ title to `[zone] ...`, from which the compositor draws the zone's border. refused without a question; the code typed delivers byte-identical, a plain `y` refuses, and no question is left behind. - The two hand-written parsers of zone bytes have seeded mutation tests, so a - failure repeats on every machine. The broker's damages each request in + failure repeats on every machine. The broker's test damages each request in `compartments/kryptikd/fuzz-corpus/broker-requests` a hundred-odd ways and sends it over a real connection: no panic, no overrun of the deadline, one well-formed reply. It does so for a plain zone, for a sender whose policy and data mount let transfers through with a descriptor on every request, - and, unprivileged only, for the nic zone, whose time and update verbs would - otherwise reach the host's clock and update state. The proxy's (`protocol.rs`, `session.rs`) damages a - valid body of every message in the generated tables (no panic, no read past - the body, only exact parses accepted) and feeds a damaged opening through a - live session in arbitrary fragments (only whole messages reach the - compositor). Inputs that ever break a parser join the corpus. + and, only when unprivileged, for the nic zone, whose time and update verbs + would otherwise reach the host's clock and update state. The proxy's tests + (`protocol.rs`, `session.rs`) damage a valid body of every message in the + generated tables (no panic, no read past the body, only exact parses + accepted) and feed a damaged opening through a live session in arbitrary + fragments (only whole messages reach the compositor). Inputs that ever + break a parser join the corpus. - `.github/workflows/fuzz.yml` fuzzes both parsers under libFuzzer weekly, twenty minutes each, on a nightly pinned by date. `compartments/kryptikd/fuzz` takes the request parser, seeded from that corpus, and holds every request diff --git a/docs/supply-chain.md b/docs/supply-chain.md index 2589c59f..ecec9518 100644 --- a/docs/supply-chain.md +++ b/docs/supply-chain.md @@ -4,23 +4,25 @@ Kryptik builds what it ships from source, which turns "do I trust this distribution's build servers" into "do I trust these tarballs". The exceptions: -- **Device firmware and CPU microcode** (ADR-012), selected by - `build/config/firmware.list` from the pinned `linux-firmware` release. The - tarball is signed by its kernel.org maintainer and verified like the - kernel's, and each file's licence is the one its `WHENCE` records. That - shows where the bytes came from, not what they do: they run on the device's - own processor, behind the IOMMU (strict by default). +- **Device firmware and CPU microcode** (ADR-012). Device firmware, selected + by `build/config/firmware.list`, and AMD's microcode come from the pinned + `linux-firmware` release. The tarball is signed by its kernel.org + maintainer and verified like the kernel's, and each file's licence is the + one its `WHENCE` records. That shows where the bytes came from, not what + they do: device firmware runs on the device's own processor, behind the + IOMMU (strict by default). Intel's microcode comes from Intel's own + archive, which is unsigned ([assurance per source](#assurance-per-source)). - **The Rust compiler**: kryptikd and kryptik-wlproxy are built from source by an upstream toolchain pinned by version, not one built here (ADR-010). Two build tools that do not ship are special cases: - **cmake** (the `cmake-bin` manifest row): Kitware's Linux binary generates - json-c's build files in the stage 04 chroot, since compiling cmake cost a - quarter of stage 04 for one package. Its hash in `sources.lock` was checked - against Kitware's published SHA-256 list; it is unpacked under the build - tree, never installed, and excluded from the image by stage 06. The source - tarball stays in the manifest as the fallback. + json-c's build files in the stage 04 chroot, since compiling cmake costs a + quarter of stage 04 for one package. Like the source tarball, it is held to + Kitware's signed SHA-256 list. It is unpacked under the build tree, never + installed, and excluded from the image by stage 06. The source tarball + stays in the manifest as the fallback. - **kernel-hardening-checker** runs in stage 05 and CI on the resolved kernel configuration. Its upstream tags are lightweight and unsigned, so the hash of its GitHub archive in `sources.lock` is its only provenance, and @@ -63,23 +65,24 @@ a compliance scanner. ### Expired keys are not tampering Some sources (glibc, gmp, mpc, patch and ncurses among them) are signed with -keys the keyring believes expired. The signatures are valid; the keyring's +keys the keyring believes expired. The signatures are valid: the keyring's copy predates the maintainer extending the key. `verify-signatures.sh` counts them as verified and lists them separately, because a tool that cries -tampering at routine expiry gets ignored. `BADSIG` (the file does not match -its signature) and `REVKEYSIG` (the key was revoked, possibly compromised) -always fail; `--strict`, the gate CI runs on every push, also fails on a -signature that could not be checked or a signer never established: a key -taken from the signature itself, a key not held, a file not downloaded, a -key whose published copy (`tools/key-provenance.tsv`) could not be read that -run. A row there speaks only for the sources it names. A -key that no publisher states anywhere passes it only while -`tools/source-notes.tsv` records the routes that were tried -(`no-usable-key`); such a note for a key that is held fails it as stale, -while a signature the run could not fetch leaves its note untried. A -source that publishes no OpenPGP signature is not the gate's: the lock pins -it, and `tools/verify-provenance.sh --strict` checks whatever else its -publisher states. +tampering at routine expiry gets ignored. + +`BADSIG` (the file does not match its signature) and `REVKEYSIG` (the key was +revoked, possibly compromised) always fail. `--strict`, the gate CI runs on +every push, also fails on a signature that could not be checked and on a +signer never established: a key taken from the signature itself, a key not +held, a file not downloaded, a key whose published copy +(`tools/key-provenance.tsv`) could not be read that run. A row there speaks +only for the sources it names. A key that no publisher states anywhere +passes the gate only while `tools/source-notes.tsv` records the routes that +were tried (`no-usable-key`). Such a note for a key that is held fails the +gate as stale, while a signature the run could not fetch leaves its note +untried. A source that publishes no OpenPGP signature is outside this gate: +the lock pins it, and `tools/verify-provenance.sh --strict` checks whatever +else its publisher states. ### Signature strength varies @@ -94,7 +97,7 @@ publisher states. DSA-1024 over SHA-1 is below what should be relied on, so the signature on `less` is weaker evidence than the rest. Some sources publish a signature that no usable key checks, file's among them; `tools/source-notes.tsv` -names each, with the routes to a key that were tried. +lists each, with the routes to a key that were tried. ### xz @@ -124,8 +127,9 @@ declare a signed tag or a publisher's `.sha256`: - **iana-etc**. GitHub serves a `.sha256` beside the release tarball, from the same platform as the tarball itself. -Neither is a signature over the artifact, and the tool says so. Under -`--strict`, which CI uses on pushes, a check that could not run fails. +Neither a signed tag nor a publisher's checksum is a signature over the +artifact, and the tool says so. Under `--strict`, which CI uses on pushes, a +check that could not run fails. ## Assurance per source @@ -133,8 +137,7 @@ Neither is a signature over the artifact, and the tool says so. Under class, strongest first, from a key pinned in the tree down to `sources.lock` alone. It counts per class and prints no total: a maintainer signature, a signed tag and a publisher checksum are different strengths of evidence, and -one fraction would hide the weakest links. For the same reason this document -quotes no coverage figure. +one coverage figure would hide the weakest links. Some sources end at `sources.lock` alone because upstream signs nothing Kryptik could check. One of them is guarded further along the chain: