Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 27 additions & 27 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -222,21 +222,21 @@ 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
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
Expand Down
99 changes: 50 additions & 49 deletions docs/design/broker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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/<zone>/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
Expand All @@ -47,7 +47,7 @@ anything else -> error: <reason>\n
## File transfer

The zone sends `transfer <dest> <name>` 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:

Expand All @@ -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/<zone>`, 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;
Expand All @@ -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

Expand All @@ -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

Expand All @@ -153,11 +153,11 @@ channel.

A cross-zone paste is a user gesture in zone 0: the chrome's `m<N><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.

Expand All @@ -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.<zone>.<claimed>` 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.<zone>.<claimed>`, from which the compositor draws the zone's border.

## Tests

Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading