kdos

The security model

This chapter describes who may do what on a KDOS machine, which mechanism enforces each rule, and what is not protected at all. It is written for anyone who administers a KDOS machine, reviews its security, or changes one of its privileged programs. It assumes the vocabulary of Architecture overview; a box is a rootless container that runs an application, and a pack is the read-only image a box is composed from (both are defined in the glossary and described in Packs and boxes).

The chapter runs in this order: the threat model, the setuid binaries and the two KDOS wrote, the system accounts, the root daemons, the firewall, polkit, the containers and the compositor’s filter for boxed clients, mount options, signing and trust, and the two paths by which untrusted bytes reach a terminal. Its last section, What is not protected, is part of the specification rather than a footnote: a model that lists only its defences gets relied on for things it does not do. A reader with time for one section should read that one.

What this model is for

KDOS is a single-user workstation. One human account ships (kdos, uid 1000), that account is in the wheel group, and wheel is trusted. The design defends against four things:

It does not defend against the person at the keyboard, or against software running as that person. A recurring pattern in what follows is that a mechanism is not made safe where it can be made unnecessary: the lock screen needs no privileged daemon, locate needs no setgid binary, and no root daemon accepts a path.

setuid binaries

A setuid binary runs as its owner, here root, whoever starts it. The recipes in the ports tree produce nineteen setuid-root binaries, two of them KDOS’s own:

Binary From What it is for
kdos-checkpass KDOS Checking the caller’s own password against /etc/shadow for the lock screen
kdos-resctl KDOS Signalling and renicing another user’s process for the resource monitor
sudo sudo Running a command as another user
su shadow Switching to another account. util-linux’s su is not built (--disable-su), so shadow’s is the only one
passwd, chage, gpasswd, chfn, chsh, newgrp shadow Account management
newuidmap, newgidmap shadow Writing a rootless container’s user-namespace map
pkexec, polkit-agent-helper-1 polkit polkit’s privileged actions
ssh-keysign OpenSSH Host-based authentication
dbus-daemon-launch-helper dbus System-bus activation. Owned root:messagebus, mode 4110, so only the bus daemon can run it
mount.nfs nfs-utils Mounting an NFS share listed in fstab as an ordinary user
unix_chkpwd pam Lets pam_unix check a password for a caller that is not root; without it every unprivileged PAM check fails, vlock and wayvnc included
fusermount3 fuse Mounting a userspace filesystem from a session with no user namespace: sshfs, gocryptfs, fuse-overlayfs, the document portal, rclone mount, and restic mount through the fusermount link beside it

Most of these bits come from the upstream make install. Four recipes set a bit themselves: kdos-lock and kdos-res install their helpers with mode 4755, shadow repeats the mode on newuidmap and newgidmap so that a configure change cannot drop it, and pam sets it on unix_chkpwd. The dbus port’s postinstall.sh restores the helper’s group and mode after the package manager has installed it as root:root.

newuidmap and newgidmap are the reason every boxed application starts. The container engine (podman) runs them to write a process’s user-namespace map, which the kernel allows only from a process that already holds CAP_SETUID. Podman checks the binaries first; if they are not setuid it exits with status 125 and prints should have setuid or have filecaps setuid. Without those two bits no boxed application starts.

These are deliberately not setuid:

The list above is derived from the recipes; the built image is the authority. To check a machine:

find / -xdev -perm -4000 -type f

KDOS uses mode bits rather than file capabilities because every host binary reaches the image inside a kpkg package, and the package archive is a GNU-format tar written without --xattrs. A mode bit survives that archive; a file capability, which is an extended attribute, does not. The later copies (the squashfs system image, the installer’s rsync -aHAX, and a pack built with mkfs.erofs as root) would all carry an extended attribute, but by then it has already been lost.

Losing a setuid bit is a silent failure: one chown or one archive copy without the right flag is enough, and nothing reports it. kdos doctor checks the five that matter most (kdos-checkpass, kdos-resctl, newuidmap, newgidmap, and dbus-daemon-launch-helper with its messagebus group) and prints the repair when one is wrong: chown root and chmod 4755 for the first four, chown root:messagebus and chmod 4110 for the bus helper. The same check confirms that /etc/subuid and /etc/subgid exist, because without them no rootless box can map a user.

No setgid binaries, and a per-user locate index

No recipe installs a setgid binary. util-linux is built --disable-makeinstall-chown, so wall and write are not setgid tty; the games (nethack, moon-buggy, bsd-games) keep their scores without a shared setgid directory. The check is the same find with -perm -2000.

plocate (the locate command) is the one program whose upstream design depends on setgid. Upstream installs it setgid to a plocate group, with one shared database in /var/lib at mode

  1. That database names every path on the machine; the binary reads it on the caller’s behalf and filters out, per result, any path the caller could not have reached.

KDOS ships none of that: the recipe resets the binary to 0755 and removes /var/lib/plocate, and there is no plocate group. Each user’s index is built by that user, from their own home directory, into their own cache:

Piece Where
The builder /usr/local/bin/kdos-updatedb, which runs /usr/sbin/updatedb --require-visibility no --prune-bind-mounts yes --database-root "$HOME" --output <index>
The index ${XDG_CACHE_HOME:-$HOME/.cache}/kdos/plocate.db
The schedule ~/.config/kdos/timers.d/20-updatedb.timer, one of the per-user timers the session runs (seeded from /etc/skel)
How locate finds it $LOCATE_PATH, exported by /etc/profile.d/40-plocate.sh

An index built this way can contain only paths its owner could already list, so the visibility check has nothing to guard. --require-visibility no is required: with the check on, updatedb insists on the plocate group and writes nothing. --prune-bind-mounts yes keeps a bind-mounted tree, such as a box’s graft into the home directory, from being indexed twice.

The port carries one behavioural patch, locate-path-replaces-default.patch. Upstream searches its compiled-in /var/lib database first and exits on the first database it cannot open, so on a machine with no shared database every search would fail before reaching the user’s own. With the patch, a set and non-empty $LOCATE_PATH replaces the default instead of following it.

kdos-checkpass

kdos-checkpass is the lock screen’s password check, and the only privileged part of the lock screen. If it is wrong, the user is locked out of their own session, so its behaviour is specified exactly:

printf '%s' "$password" | kdos-checkpass    # exit 0 = correct
Exit code Meaning
0 Correct
1 Wrong
2 Could not tell: an argument was given, no passwd or shadow entry for the caller, an unreadable shadow file, a locked or empty hash, or an internal failure (reading standard input, dropping privilege, or crypt() refusing the hash)

A caller must treat 2 as a failure and say why: reporting “wrong password” on a machine with a broken shadow file sends the user looking in the wrong place. Without its setuid bit the program cannot read the shadow file, answers 2 to every attempt, and locks the user out.

The mode of /etc/shadow

/etc/shadow must be mode 0600, and the build has to set that explicitly. Git records one permission bit (executable or not), so a file under fs/ cannot carry a mode narrower than 644, and the file-system build step gives every non-executable file exactly that. A world-readable shadow file hands every password hash to every account and makes kdos-checkpass’s setuid bit pointless.

script/phases/10_bootstrap/000_file_system.sh therefore carries a table (the FSMODES block near its end) of the paths whose mode or owner git cannot express:

Path Mode Owner
etc/shadow 600 root
etc/polkit-1/rules.d 755 root
etc/polkit-1/rules.d/50-kdos.rules 644 root
etc/udev/rules.d/ and everything under it 644 (directories 755) root

A path in the table that is missing from the image is reported on standard error and skipped. testing/preflight.sh checks the result on the built tree (build/fs), because the source tree cannot express it: it accepts /etc/shadow at 600 or 640, and it requires the polkit and udev rules and their directories to be owned by root. Both daemons read every rule they find with no ownership check, so a rules directory the desktop user could write would let that user grant themselves any polkit action, or run any command as root on the next device event through a udev RUN+=.

kdos-resctl

kdos-resctl is the second KDOS setuid binary, used by the resource monitor kdos-res. Its security argument is that there is nothing to aim: three verbs, no paths and no options.

kdos-resctl dmi
kdos-resctl signal <pid> <TERM|KILL|STOP|CONT>
kdos-resctl renice <pid> <-20..19>

The dmi verb prints the populated memory slots (size and speed, from SMBIOS type 17). No shipped program calls it; the Memory page of kdos-res says that memory device details need kdos-resctl.

Exit code Meaning
0 Done
1 Refused
2 Usage error (any other argument vector)
3 The operation itself failed

System accounts

Every daemon that drops privilege drops to an account of its own. None of these accounts can log in: each has /sbin/nologin as its shell and ! as its password. The image ships these in /etc/passwd, /etc/group and /etc/shadow:

Account uid:gid Used by
dhcpcd 999:999 dhcpcd’s privilege-separated children
messagebus 997:997 dbus-daemon, the system bus
sshd 996:996 sshd’s privilege separation
tss 993:993 The TPM: tpm2-tss’s udev rules give /dev/tpm* to the user and /dev/tpmrm* to the group
lp 10:10 CUPS
utmp 14:14 utmpd and wtmpd, which own /run/utmps and /var/log/wtmp (Login records)
nobody 99:99 kiwix-serve and llama-server, which only read, and anything that asks for an unprivileged account by that name

More are created when their port is installed, by the port’s postinstall.sh with groupadd -r and useradd -r, which pick a free id: polkitd, avahi, avahi-autoipd, clamav, geoclue, gnuhealth, kolibri, maddy, minidlna, mosquitto, mumble-server, ngircd, nm-openvpn, nut, pcscd, postgres, prosody, radicale, tcpdump and usbmux. nut is also put in dialout, for a UPS on a serial line. libvirt’s hook creates the account qemu, whose primary group is the shipped kvm, so the guests libvirtd starts can open /dev/kvm, and the group libvirt, which libvirt’s own polkit rule admits and which has no member. brltty’s hook creates the group brlapi and adds the desktop account to it; that is the group brltty’s own polkit rule admits to its braille display interface. The file-system build step merges these files rather than overwriting them, so accounts a hook added survive a rebuild of fs/; where the repository and the image disagree about an entry, the repository wins.

Two rules keep the tables consistent:

The desktop account belongs to wheel, seat, lpadmin, audio, video, render, input, kvm, cdrom, dialout, tty and users. It is not in tss, so the TPM is root’s: run the tpm2_* tools under sudo.

Root daemons

Five KDOS daemons run as root, each started by its own init script, and each answers a socket in /run:

Daemon Socket What it does
kdos-powerd /run/kdos-powerd.sock Suspend, power-off, reboot, and four system settings: the firewall, the autologin account, the accent, the time zone
kdos-energyd /run/kdos-energyd.sock Per-application energy use
kdos-oomd /run/kdos-oomd.sock Kills a process under memory pressure, sparing the desktop
kdos-mountd /run/kdos-mountd.sock Removable media, encrypted volumes, network shares, disk health
kdos-packd /run/kdos-packd.sock Mounts, verifies, installs and composes application packs

Their verbs are in the daemons. They share one authorisation design.

Who may talk to them

The check is on who is connecting, not on the socket’s permissions. Every socket is mode 0666 and anyone may connect. The daemon asks the kernel for the connecting process’s user id (SO_PEERCRED, which the client cannot forge) and answers err not permitted to anyone it does not admit. A mode that looked like the authorisation would be a mode somebody eventually loosens.

Daemon Admits
kdos-powerd root and wheel: every verb. seat: ping, suspend, poweroff and reboot only
kdos-mountd root, seat and wheel: every verb
kdos-oomd root, seat and wheel (its only verbs are ping and status)
kdos-energyd root and wheel
kdos-packd root and wheel

seat is the group to which seatd, the seat daemon, hands the display and input devices: it means the person at the machine. The installer keeps the desktop account in seat and, for a non-administrator, takes it out of wheel. Such an account keeps the lid and the power keys and every kdos-mountd verb, and loses sudo, the polkit grants and every kdos-powerd configuration verb. kdos-powerd’s seat list names what is allowed, so a configuration verb added later belongs to administrators without anyone having to remember to say so.

The test lives in one function, kb_uid_allowed() in libkbase, and no daemon keeps a copy of it. It admits uid 0, refuses a uid with no passwd entry, and otherwise checks the account’s primary and supplementary groups. This is the authorisation boundary of the whole system; five copies would be a rule tightened on one socket and left loose on the others. To see how kdos-powerd would treat an account:

kdos-powerd --explain <user>

It asks the same function the socket does, so its answer cannot drift from the daemon’s.

kdos-mountd --fixture-serve is a test mode that admits anybody and grants nothing: its paths are a scratch directory, and every command it would run is printed instead. The init script starts the daemon with no arguments, so a running system never reaches it.

Clients never name a path

Every block-device verb takes an index into a list the daemon published a moment earlier, and every configuration verb takes a name from a list compiled into it. A daemon that accepted a device and a mount point would let anyone at the seat mount a USB stick over /etc.

There are two exceptions. Installing a pack names a file name, not a path, in a staging directory the daemon owns (/var/lib/kdos/packs/staging, mode 01777). The name must match [A-Za-z0-9._-] and contain no slash. That directory is the single place an unprivileged download may land, and the daemon publishes its location so clients do not derive it. kdos-mountd’s cifs and krb5 verbs name a server, a share, a user and a domain, because a network share is not a row the daemon can list in advance. The daemon still chooses the mount point (under /media/<user>/) and the options, and it checks each field’s characters, and resolves the server, before anything is created or mounted.

What each daemon refuses

Daemon Refuses
kdos-powerd Anything but ping, suspend, poweroff, reboot, and four verbs whose argument is checked before use: firewall (a service name from a table compiled into the daemon), autologin (an existing human account, or off), accent (one of the eight colour schemes in libkcolor’s table), timezone (a zone name of at most 64 characters from [A-Za-z0-9+_/-], with no leading, trailing or doubled slash)
kdos-mountd A block device that is not removable, with the checks listed below the table; a network share is mounted only through cifs or krb5, at a mount point the daemon chooses
kdos-packd Paths as arguments; a staged name outside [A-Za-z0-9._-]; a pack whose hash or signature fails; replacing or removing a pack that is composed into a running box
kdos-oomd Any argument at all: killing is its own decision or it does not happen
kdos-energyd Republishing the raw energy counter; a client-chosen sampling interval

kdos-mountd refuses:

The image travels as a descriptor so that the daemon never opens a file on the caller’s behalf. The caller opens the image as itself and passes the open file over the socket (SCM_RIGHTS); the daemon reads only what that descriptor already grants. A root daemon that accepted a path would open it as root, and a request naming /etc/shadow would copy a file the caller cannot read onto a stick the caller can. The disk is opened with O_EXCL, the kernel’s exclusive claim on a block device, so the write cannot start while anything on the disk is in use, and nothing else can open the disk for writing or mount it until the write ends.

The image does not ship /etc/kdos/mountd.conf, so formatting and image writes are off, and removable media mount noexec, until an administrator writes the file.

kdos-energyd exists because the CPU energy counter (RAPL) is root-only: fine-grained unprivileged reads can recover cryptographic keys through a side channel. What leaves the daemon is a per-application percentage accumulated over minutes. It samples every ten seconds, an interval compiled in and not configurable, the raw counter is never republished, and a client cannot drive the interval towards a fine-grained one. There is no write path into the power interface at all.

kdos-boxsock is not a root daemon: it is installed in /usr/bin, runs as the desktop user, binds one tagged Wayland socket per box, and holds that box’s security context open for the box’s lifetime.

The firewall

The firewall is nftables, loaded at boot by /etc/init.d/25_nftables.sh from /etc/nftables.conf. The script checks the whole file with nft -c first and never applies one that fails the check, because a ruleset applied halfway would leave the input chain’s drop policy in place with its accept rules missing. A failed check keeps a running ruleset. With none running, as at boot, it loads the file without its drop-ins, or else a minimal drop policy (established and related traffic, loopback, the necessary ICMP and ICMPv6, the DHCPv6 client), and reports [FAIL]: an empty ruleset would accept everything while sshd and every other listener start.

The shipped inet filter table:

Chain Policy Accepted
input drop Established and related traffic; loopback; the ICMP and ICMPv6 types that keep a network working (unreachable, too big, time exceeded, parameter problem, echo request, and IPv6 neighbour and router discovery); mDNS (UDP 5353); NetBIOS name service (UDP from port 137 to port 137); the DHCPv6 client (UDP 546); DNS and DHCP from 10.42.0.0/16, the range NetworkManager gives a hotspot’s clients
forward drop Established and related traffic, and anything to or from 10.42.0.0/16
output accept Everything

Every *.nft file in /etc/nftables.d/ is included after it. 40-podman.nft lets containers on a podman* bridge reach DNS and be forwarded; 40-libvirt.nft, from the libvirt package, does the same for guests on a virbr* bridge and lets them reach the network’s dnsmasq for DHCP. 50-kdos-services.nft is written by kdos-powerd and holds the services an administrator has opened.

Opening a port goes through the same rule as every other daemon verb: the client names a service, never a port. kdos-powerd carries the table of twenty-five names (ssh, http, https, ipp, smb, kiwix, mdns, mqtt, xmpp, nfs, babel, nut, snmp, mumble, caldav, mail, irc, dlna, tryton, caddy, mosh, syncthing, kdeconnect, vnc, xonotic), each with the one rule it adds, so a client that can only say ssh can open exactly TCP 22 and nothing else. The daemon rewrites 50-kdos-services.nft whole from the names that are on, checks the full ruleset with nft --check, and then reloads it. A rule written by hand into that file is dropped on the next change; a hand-written rule belongs in a file of its own beside it.

kdos-power firewall list          # root or wheel
kdos-power firewall ssh on

polkit, and why the desktop has no authentication agent

polkit is the system service that other daemons ask whether a user may perform an action. On a mainstream desktop it raises a password prompt through an authentication agent. KDOS has no agent and no prompt, for reasons that follow from having no session manager.

Who asks polkit

polkit is installed and polkitd is started by the init system. NetworkManager, ModemManager, bolt, fwupd, upower, fprintd, pcscd and brltty’s braille server ask it, and GeoClue asks it on ModemManager’s behalf. NetworkManager’s actions are the only ones a KDOS program calls. The native applications add their own askers: udisksd, libvirtd, pkexec on behalf of GParted and Resources, and the KAuth helpers of K3b and KTextEditor, which KAuth’s polkit backend checks.

Why a password prompt cannot work here

polkit never sees an active session on this machine. It finds a process’s session by calling org.freedesktop.ConsoleKit on the system bus; neither ConsoleKit nor elogind is installed (see principles), so the lookup fails and every check falls to the allow_any column of the action’s .policy file. The allow_active and allow_inactive columns are never read.

An action with no <allow_any> element is then a flat refusal. polkit consults an agent only when the result is a challenge, and a flat refusal is not one. NetworkManager’s policy has no <allow_any> for enable-disable-wifi, enable-disable-network or either wifi.share action, so no agent (one written for KDOS, or the pkttyagent polkit ships) could ever be asked about the Wi-Fi switch. An agent would also have nothing to register as: with no session it can register only for one exact process, matched by pid and start time, so a session-long agent would never be found for a program it did not start itself.

The same applies to bolt, fwupd and upower. Their actions are auth_admin, a challenge, and a challenge with no agent is a refusal. bolt and fwupd ship their own rules granting wheel, but both test subject.active and subject.local, which are never true here, so neither fires. fprintd’s enrol and verify actions are allow_active only. A Thunderbolt enrolment, a firmware update or a fingerprint enrolment is therefore done with sudo; polkit authorises root for every action without reading a rule.

What is granted instead: 50-kdos.rules

/etc/polkit-1/rules.d/50-kdos.rules grants a short, named list of actions to members of wheel, and nothing to anyone else:

Action Used by
org.freedesktop.NetworkManager.network-control kdos-net: connecting and disconnecting
org.freedesktop.NetworkManager.wifi.scan kdos-net: the network list
org.freedesktop.NetworkManager.enable-disable-wifi The Wi-Fi switch
org.freedesktop.NetworkManager.enable-disable-network The networking switch
org.freedesktop.NetworkManager.settings.modify.own Profiles another tool created with an owner
org.freedesktop.NetworkManager.settings.modify.system Joining, forgetting, and reading a saved passphrase back (see below)
org.freedesktop.NetworkManager.wifi.share.open, …wifi.share.protected A hotspot
org.freedesktop.ModemManager1.Device.Control, …Messaging mmcli: unlocking a SIM, enabling a modem, reading its text messages
org.debian.pcsc-lite.access_pcsc, …access_card Smart cards (gpg’s scdaemon, opensc, ykman, openconnect’s certificate login)
org.freedesktop.udisks2.filesystem-mount, …filesystem-mount-system, …filesystem-unmount-others, …filesystem-take-ownership Mounting from Dolphin’s and the file choosers’ device lists, and from GNOME Disks
org.freedesktop.udisks2.encrypted-unlock, …encrypted-unlock-system, …encrypted-lock-others, …encrypted-change-passphrase Unlocking and locking an encrypted volume, and changing its passphrase
org.freedesktop.udisks2.eject-media, …eject-media-system, …power-off-drive Ejecting a disc or a card, and powering off a USB drive
org.freedesktop.udisks2.loop-setup, …loop-delete-others Attaching and detaching a disk image
org.freedesktop.udisks2.open-device, …open-device-system Raw access to a block device: Impression and GNOME Disks writing or reading an image
org.freedesktop.udisks2.modify-device, …modify-device-system Formatting and partitioning in GNOME Disks
org.freedesktop.udisks2.rescan, …cancel-job, …ata-check-power, …ata-smart-update, …nvme-smart-update Re-reading a device, cancelling a job, and reading a drive’s health
org.gnome.gparted GParted, whose wrapper re-runs itself through pkexec
org.kde.k3b.updatepermissions K3b’s helper, which sets the burner device’s group and permissions
org.kde.ktexteditor6.katetextbuffer.savefile Kate and KDevelop saving a file the user cannot write
net.nokyan.Resources.kill Resources ending another user’s process
org.libvirt.unix.manage virt-manager managing qemu:///system

Deliberately not granted, and so left to sudo:

Action Why not
settings.modify.hostname The hostname belongs to the installer and /etc/hostname
settings.modify.global-dns A machine-wide resolver is not a per-user decision
checkpoint-rollback Nothing here creates a checkpoint, so a rollback could only undo somebody else’s
sleep-wake kdos-powerd owns suspend, with its own check
reload Re-reading NetworkManager’s configuration is administration
udisks2’s filesystem-fstab, encrypted-unlock-crypttab, modify-system-configuration, read-system-configuration-secrets /etc/fstab and /etc/crypttab belong to the installer
udisks2’s manage-md-raid, manage-swapspace, lvm2.manage-lvm, btrfs.manage-btrfs The storage layout under a running system is administration
udisks2’s ata-secure-erase, nvme-sanitize, nvme-format-namespace An irreversible firmware erase stays one deliberate sudo away
udisks2’s …-other-seat actions Never asked: udisksd is built without logind and treats every drive as on the caller’s seat
org.kde.k3b.addtogroup It edits /etc/group
org.gtk.vfs.file-operations (gvfs admin://), org.kde.ksysguard.processlisthelper.* No shipped surface depends on them
org.freedesktop.policykit.exec pkexec of an arbitrary command, which Resources’ memory-module view asks for, would be sudo with no password

libvirt’s own rule, /usr/share/polkit-1/rules.d/50-libvirt.rules, grants org.libvirt.unix.manage to the libvirt group its postinstall.sh creates; no account is placed in that group, so the grant to wheel is the one that applies. org.libvirt.unix.monitor, read-only access, is allow_any yes in libvirt’s policy and needs no rule.

Two further consumers work without a KDOS rule. brltty’s own rule grants org.a11y.brlapi.write-display to the brlapi group with no session test. GeoClue’s rule grants ModemManager’s Device.Control and Location to the geoclue account alone, so the location service can switch on a modem’s GPS; nobody logs in as that account.

The grant is a rules file of named actions, not a D-Bus policy that lets wheel onto NetworkManager’s write interfaces, because a bus policy cannot scope a Properties.Set to one property and cannot leave out the hostname or the machine-wide resolver. The rules file and an agent are alternatives: if an agent were ever added, this file is what would have to go.

Why settings.modify.system is granted

On this build it is the only permission a saved network can be under. NetworkManager is compiled -Dsession_tracking=no, so its check “does this user have a session?” always answers no. A connection profile that names an owner in its permissions list is therefore permanently invisible, and an invisible profile never connects automatically: a Wi-Fi network you joined would not come back after a reboot. So kdos-net writes no owner, everything it creates is a system connection, and forgetting one or reading its passphrase back needs settings.modify.system.

That grant lets anybody in wheel read every stored Wi-Fi passphrase. It is a shortcut, not a new capability: the shipped sudoers line (/etc/sudoers.d/00-sudo) is %wheel ALL=(ALL) ALL, and the passphrases are files under /etc/NetworkManager that sudo cat prints.

How polkit and ModemManager are started

polkitd is started by /etc/init.d/41_polkitd.sh and ModemManager by /etc/init.d/42_modemmanager.sh, rather than by D-Bus activation. Activation of a root service goes through dbus-daemon-launch-helper, which the bus may run only through the helper’s messagebus group; kpkg installs every package as root:root, and that group is on the helper only because dbus’s postinstall.sh sets it back. Starting these two directly keeps the machine’s network authorisation off that dependency. When activation fails it fails silently: the service never starts, every call is refused, and the only trace is a Spawn.ExecFailed in the log. kdos doctor checks the helper’s owner, group and mode for that reason.

Left to D-Bus activation, and so dependent on the helper: wpa_supplicant, fprintd, fwupd, boltd, upower, geoclue, udisksd, the KAuth helpers of K3b and KTextEditor, and NetworkManager’s dispatcher.

What the grant allows

Anybody in wheel reconfigures networking with no prompt, and mounts, formats, partitions and images disks, manages system virtual machines and saves root-owned files from Kate the same way. That is the same group that can already change the firewall through kdos-powerd, so it is the existing boundary applied to these actions too. It is not a password prompt, because this system cannot produce one; a design that pretended otherwise would be a control that fails silently.

The list of action ids is the entire boundary, and the only record of its use is a log line. NetworkManager is built -Dlibaudit=no, so an authorised change leaves no audit record beyond NetworkManager’s own message to syslog.

Containers

Boxes are rootless: the container engine runs as you, maps your identity into the container, and uses the setuid mapping helpers above for the one privileged step. A pack box is created with podman create --rootfs over the overlay kdos-packd composed, with the flags distrobox uses for the same job. A box built from an OCI image (a container image in the Open Container Initiative format, as a registry such as Docker Hub serves it) goes through distrobox itself, the upstream tool that wraps podman to make a container behave like part of the host; it also shares the host’s root at /run/host.

What a box shares with the host is set by its profile, ~/.config/kdos/boxes/<name>.conf. For a pack box each key below maps onto one podman flag that the kernel enforces, and applies when the box is created. For a box built from an OCI image, memory, cpus and pids are not applied, and network = none gives a private namespace with an interface, as private does. kdos-box profile prints the podman flag for those keys whichever kind of box it is.

Key Default Private or none means
home shared: your whole $HOME A home directory of the box’s own
network host: the host’s network namespace private: a namespace of its own; none: no interface at all
ipc shared with the host A private IPC namespace
processes the host’s pid namespace A private pid namespace
devices /dev and /sys bind-mounted from the host No host /dev; gpu = yes binds /dev/dri back alone
gpu yes With devices = private, yes binds /dev/dri back and no leaves the box without it; with devices shared it follows devices
audio yes Not enforced: sound reaches the box through the PipeWire socket in /run/user/<uid>, which every box shares
memory, cpus, pids unlimited --memory, --cpus, --pids-limit

Whatever the profile says, every box shares /tmp, /dev/shm, /run/user/<uid> (the session bus, the PipeWire socket and the session’s Wayland socket live there) and, when it exists, /run/cups. A box is therefore not a security boundary against you. It is a boundary against some of the desktop’s interfaces (the compositor globals in Sandboxed clients) and a way of packaging software. There is no engine flag that grants a box a speaker and denies it a camera, so gpu = no cannot be enforced while devices is shared, and audio = no is never enforced, because the PipeWire socket is in the shared runtime directory. kdos-box profile <name> and kdos-box create print a warning beside audio and gpu only while devices is shared; with devices = private, audio = no is accepted without a warning and changes nothing.

Resource limits (memory =, cpus =) become real cgroup limits when the desktop was started by autologin: kdos-getty places that session in the user’s delegated cgroup (/sys/fs/cgroup/user.slice/user-<uid>/session), and each pack box gets a sibling cgroup with memory.max set. A session started any other way (a password login on a tty, an ssh login) stays in the root cgroup, where podman accepts the limit and ignores it. In either case kdos-oomd reads the profiles and prefers a box that is over its declared memory budget as a victim.

A pulled image is not verified. /etc/containers/policy.json, written by the containers-common port, is upstream’s default, insecureAcceptAnything, which checks no signature. /etc/containers/registries.conf resolves an unqualified image name against docker.io. A base image is trusted as far as the registry and the TLS connection to it are.

Sandboxed clients

A client in a box is meant to reach the compositor through a socket that kdos-boxsock created for that box, $XDG_RUNTIME_DIR/kdos-box-<box>@<display>.sock; kdos-appbox sets WAYLAND_DISPLAY to it before anything in the box starts. The compositor tags every client on that socket with a security context naming the box (engine io.kdos.appbox, the box name as the app id). The client cannot see, choose or forge the tag. The compositor’s global filter then offers such clients a fixed allowlist of Wayland protocols:

Allowed Denied
Surfaces, subsurfaces, shared memory, wl_fixes and the xdg shell Screen capture, both generations (wlr screencopy; ext image-copy-capture and its output and toplevel sources)
The seat, relative pointer, pointer gestures, pointer constraints, cursor shape Buffer export (wlr export-dmabuf)
Outputs and xdg-output, dmabuf and wl_drm, viewporter, presentation time, fractional scale Data control (clipboard manipulation), both generations
Both decoration managers, activation, tablet, toplevel icon, dialog Foreign-toplevel management and the ext toplevel list
Text input (text-input-v3) Input method and virtual keyboard
The primary selection, and the ordinary data device (the clipboard and drag-and-drop) Output management, output power, gamma control, DRM leasing
Colour management, colour representation, alpha modifier, syncobj, single-pixel buffer The layer shell, session lock, virtual pointer, idle notification, workspaces
Idle inhibit, tearing control, xdg-foreign both ways The security-context manager itself

The list is an allowlist, so a protocol the compositor gains later is denied to boxes until somebody adds it. Three entries matter most:

The capture denial is what makes the portal the sanctioned route. A boxed screen recorder cannot bind the capture interfaces through its tagged socket, so it asks the screen-cast portal, which runs on the host and asks you which output to share. See the session.

A client with no security context, which is every program started on the host, is offered every global. The compositor also supports the <privilegedInterfaces> list of labwc (the compositor kdos-comp is built from) in rc.xml, which would restrict the privileged protocols for all clients, but the shipped configuration does not set it.

The tag holds only for a client that uses the socket it was given. A box shares $XDG_RUNTIME_DIR with the session (see Containers above), and the session’s own wayland-0 socket is in it, so a program in a box that opens wayland-0 by name connects untagged and is offered everything. The profile key wayland = no cannot take the display away for the same reason, and kdos-box profile prints that it is not enforced. The filter stops a well-behaved application from being handed capture, clipboard or input-method globals it did not ask for; it does not contain a hostile one.

Granting a box past the allowlist

The fixed list is the default, not the only answer: without a grant, a screen recorder in a box of its own could never be given the screen, even by the person who installed it. Add a grant line to the box’s profile, ~/.config/kdos/boxes/<name>.conf:

grant = screencopy, data-control

Eight names can be granted. A name that is not in this map grants nothing; the map is the whole policy.

Grant Unlocks
screencopy wlr screencopy, plus the ext image-copy-capture manager and its output source
toplevel-capture The ext foreign-toplevel image-capture source
export-dmabuf wlr export-dmabuf
data-control Both generations of data control
foreign-toplevel wlr foreign-toplevel management and the ext toplevel list
layer-shell wlr layer shell
input-method The input-method and virtual-keyboard managers
output-power wlr output power management

Where a protocol has two generations (screen copy, data control and the toplevel list) the short name unlocks both, because granting the older protocol and not the newer one would strand every client written against the newer.

Names are separated by commas or spaces; the first grant line in the file is read and at most sixteen names are taken from it. The compositor reads the file from $HOME/.config, not from $XDG_CONFIG_HOME, because kdos-appbox resolves the profile the same way and the two must not disagree. Grants are read at the first bind that reaches the filter and cached against the box name, because the filter runs for every global for every client. The cache holds eight boxes; when a ninth arrives the whole cache is emptied, and every box’s profile is read again at its next bind. SIGHUP to the compositor (its Reconfigure) drops the cache with the rest of the configuration, so an edited profile takes effect for the next client; a running one keeps what it already bound.

The kernel

The kernel’s configuration is kdos.config in the linux port, and six of its settings are there to remove a way in rather than to support hardware. Each is set explicitly, even where it matches the kernel’s own default, so the build fails if a later kernel stops honouring it (see How KDOS is built):

Setting Value What it removes
CONFIG_X86_X32_ABI off The x32 system-call ABI: a second entry path into every system call, which no program on KDOS uses
CONFIG_UEVENT_HELPER off A program the kernel forks as root for every device event, named by a writable /sys/kernel/uevent_helper; udevd reads events from netlink and needs none
CONFIG_PM_AUTOSLEEP off Suspend started by the kernel itself whenever no wakeup source is held; on KDOS suspend is always asked for from user space, by kdos-powerd writing /sys/power/state
CONFIG_LDISC_AUTOLOAD off Any user loading a line-discipline module by setting it on a terminal they opened. A process with CAP_SYS_MODULE still loads one, so pppd and hciattach, which run as root, are unaffected
CONFIG_PROC_MEM_FORCE_PTRACE on Writing to another process’s read-only memory through /proc/<pid>/mem merely by having the right to trace it; only a debugger actually attached with ptrace may
CONFIG_BUG_ON_DATA_CORRUPTION on Running on after a kernel list or reference-count check has found corrupted memory: the kernel stops the offending task with a BUG instead of logging a warning and continuing

Module signing

Every module is signed when the kernel is built (CONFIG_MODULE_SIG_ALL, SHA-512, an ECDSA P-384 key), and the certificate of that key is built into the kernel. CONFIG_MODULE_SIG_FORCE is off: the kernel still loads a module that is unsigned or signed by another key, and marks itself tainted (module verification failed in the kernel log). Signing tells you which modules came from the build that made the running kernel; it does not stop root from loading another.

The key is build/keys/linux-module-signing.pem on the machine that builds KDOS. The first kernel build that finds no key there writes one, with the subject in the port’s x509.genkey, and every later build signs with it, so two builds of the same tree carry the same certificate and the modules of either load untainted under the other’s kernel. make clean and make cleanbuild both keep build/keys, and git ignores all of build/, so the private key never enters the repository. Losing it costs nothing worse than a new certificate in the next kernel. A kernel built where there is no /kdos/build, as kdos update apply builds one on an installed machine, takes the kernel’s own default instead: a fresh key generated for that one build.

Mount options

Mounted Options
Application packs ro,nosuid,nodev
Data packs ro,nosuid,nodev,noexec, and never composed into a box’s root
Removable media (kdos-mountd) nosuid,nodev, and noexec unless exec = yes is set in /etc/kdos/mountd.conf
/tmp tmpfs, mode=1777,nosuid,nodev
/run tmpfs, mode=0755,nosuid,nodev
/sys/kernel/tracing, /sys/kernel/debug nosuid,nodev,noexec
/sys/fs/bpf nosuid,nodev,noexec,mode=0700

exec = yes is how an administrator says executables on removable media were meant to run. nosuid is not configurable: a setuid-root binary on a USB stick from another machine would give any local user root.

/tmp is one directory shared by every user and written by every root job: init scripts, timers and package hooks. /etc/sysctl.conf sets fs.protected_symlinks, fs.protected_hardlinks and fs.protected_fifos to 1 and fs.protected_regular to 2; the kernel’s own defaults are 0. At 0, a user who plants a link, a FIFO or a file at a name a root job is about to write in a sticky world-writable directory can turn that write onto any file on the machine.

The same file hardens the network stack and the kernel’s addresses:

Setting Value Why
net.ipv4.conf.{all,default}.accept_redirects, secure_redirects; net.ipv6.conf.{all,default}.accept_redirects 0 An ICMP redirect rewrites a route on the word of whoever sends it, so on a shared network any host on the link could put itself in the path
net.ipv4.conf.{all,default}.send_redirects 0 The machine routes only for its hotspot and its containers, whose clients have no other route to correct
net.ipv4.conf.{all,default}.rp_filter 2 The loose reverse-path check drops a packet whose source no interface routes back to. The strict check, 1, would break the asymmetric routes a VPN, the hotspot, podman’s bridge and a Babel mesh make
kernel.kptr_restrict 1 /proc/kallsyms, /proc/modules and the kernel log’s pointers read as zeroes to an account without CAP_SYSLOG, so they do not undo address randomisation; root still sees them

Signing and trust

KDOS uses three keyrings, and keeping them apart is structural rather than a convention:

Directory Attests Used for Shipped with
/etc/kdos/keys Who built a host package The binary-package host (binhost) index and package signature files No key
/etc/kdos/keys/packs That packs came from the same bake as the medium (the build run that produced them) Pack indexes and packs’ own signatures, read by kdos-packd and kdos-pack verify kdos-packs.pub
/etc/kdos/keys/tree That a published ports tree came from whoever holds the release key the image was built with kdos-ports.tar.xz.sig, read by kdos update fetch before the tree is unpacked kdos-release.pub, when the build had build/keys/kdos-release.key; otherwise no key

The keyring loader reads *.pub in one directory and does not descend into subdirectories, so the three are separate policies. A pack-signing key placed in /etc/kdos/keys would silently become a trusted publisher of host packages too.

The directory is the policy:

No key ships for host packages, deliberately: a distribution that shipped its own trusted key would be asking you to trust whoever built the image. Ports are built from source and their integrity is the sha256 in each recipe, checked when the build fetches it (see How KDOS is built). To trust a binhost of your choosing:

kpkg keygen builder                    # on the machine that builds
sudo cp builder.pub /etc/kdos/keys/    # on every machine that should trust it

kpkg keygen builder writes an Ed25519 key pair as two files: builder.key, the secret half, which stays on the build machine, and builder.pub, the public half you copy.

The key that ships in /etc/kdos/keys/packs asserts only that the packs beside it on a medium came from the same bake, the build run that produced the medium (see Packs and boxes). To make it say something about you, replace it and re-sign. The rest of the signing design is in Packaging.

The key in /etc/kdos/keys/tree is the one choice of trust an image makes for you: the recipe tree is the trust root for everything built on the machine, since every source is checked against a hash the tree names, so the image trusts the next tree from the same key as the tree it carries. ports/publish --release signs each release’s kdos-ports.tar.xz with build/keys/kdos-release.key, and the build copies that key’s public half into the ring. kdos update fetch refuses a tree whose signature does not verify, and a tree whose TREE stamp is older than the installed one, so a replayed old tree cannot pin old versions. Replace the key to follow a tree you publish yourself.

TLS trust anchors

The certificate authorities used for TLS are a third trust root, kept apart from both keyrings.

File What it is
/usr/share/ca-certificates/mozilla.pem The Mozilla CA bundle, installed by ca-certificates
/etc/ca-certificates/trust-source/anchors/ Your own local roots
/etc/ssl/cert.pem Generated: the Mozilla bundle followed by every local root
/etc/ssl/certs/ca-certificates.crt, /etc/ssl/ca-bundle.crt Symlinks to /etc/ssl/cert.pem

A program configured against any of the last three reads the same set.

The bundle is built, not downloaded ready-made. The ca-certificates port pins certdata.txt at an NSS release tag (the port’s version is that release, 3.130) and converts it with curl’s mk-ca-bundle.pl from a pinned curl release (8.22.0), taking the roots NSS trusts to issue server certificates: 121 in this certdata.txt. The converter drops any root already expired when it runs, so a rebuild after a root expires ships one fewer.

Library How it finds the anchors
OpenSSL, and everything linked against it Built --openssldir=/etc/ssl, so it reads /etc/ssl/cert.pem
GnuTLS, and everything linked against it Through p11-kit’s trust module, built -D trust_paths=/usr/share/ca-certificates/mozilla.pem:/etc/ca-certificates/trust-source. GnuTLS is built --with-default-trust-store-pkcs11="pkcs11:", so p11-kit is its only source
NSS, for a program that loads its builtin-roots module /usr/lib/libnssckbi.so, the module NSS loads as “Builtin Roots”, is a symlink to p11-kit’s trust module /usr/lib/pkcs11/p11-kit-trust.so, so such a program reads the same anchors as GnuTLS. NSS’s own certdata roots module is not installed
Python code that calls certifi.where(), requests included python3-certifi’s certifi/cacert.pem is a symlink to /etc/ssl/cert.pem

The two sides see a new local root at different times:

To trust a local certificate authority:

sudo cp root.crt /etc/ca-certificates/trust-source/anchors/   # must be PEM
sudo update-ca-certificates

A DER file in the anchors directory has no PEM block: update-ca-certificates skips it with a warning, and then only the p11-kit side trusts it. caddy trust does the whole job by itself: it writes Caddy’s local root into the anchors directory and runs trust extract-compat, which the p11-kit port replaces with a call to update-ca-certificates. Firefox, LibreWolf and Thunderbird compile their builtin roots into the browser itself and keep their own store, so a local root reaches them only through their own certificate settings.

GnuTLS reads a system-wide priority policy from /etc/gnutls/config (the path is compiled in from --sysconfdir=/etc; a policy anywhere else is ignored without a warning). The image ships none, so every consumer uses the library’s NORMAL priorities; a file written there restricts or widens them for every GnuTLS program at once.

The two halves fail independently, and the failure looks like a problem at the other end. If the p11-kit trust path pointed at something the image does not have, p11-kit would load nothing without a word (an absent path is not an error to it), gnutls_certificate_set_x509_system_trust() would return zero anchors, and every GnuTLS program would reject every server while curl and the rest of the OpenSSL side kept working. msmtp, openconnect, weechat and chrony’s NTS are the first to show it.

How an application is verified

Applications arrive in two ways, with different guarantees, and neither is hidden. The store lane builds a container image from the catalogue on this machine, over the network; the import lane installs a pack somebody else exported (see lane in the glossary, and Packs and boxes).

  Built by the store lane Imported
Bytes come from Debian’s archive (or another registry), over the network A .ktar set made by kdos app export, or a pack from kdos-box freeze
Signed by Nothing in /etc/kdos/keys A kdos-box freeze pack: its own signature block, when someone ran kdos-pack sign. A .ktar set: nothing the import checks (see below)
Checked by Nothing KDOS controls kdos-packd, when it takes the pack in
Needs a network Yes No

A store-lane build fetches content nobody here signed. kdos-box create says so before it pulls an OCI image, and it is true of every application built this way. What apt itself verifies still holds (the archive’s own GPG signature over a pinned snapshot) but nothing this system controls attests to the result, and the result is not signed afterwards.

An imported pack is checked by the daemon that installs it, not by the tool that chose it. A client that verified a pack and then asked for an install would have verified nothing. kdos-packd works through these checks when it moves a staged pack into its pack directory (/var/lib/kdos/packs):

  1. If the staging directory (/var/lib/kdos/packs/staging) holds an index (PACKAGES) whose signature verifies against /etc/kdos/keys/packs and which lists the pack, the pack’s SHA-256 must match the index entry. A match is accepted; a mismatch is refused. Neither kdos-appbox import nor kdos-box import puts an index there: each copies only the .kpack file it installs, so this check applies only when an index was placed in the staging directory by other means.
  2. Otherwise the pack’s own footer is checked: the payload must hash to the value the footer records, and then the pack’s signature block, if it has one, must verify against the same ring. A bad hash, a bad signature, or a signature with no key on this machine to check it is refused.
  3. A pack with no signature block and a correct payload hash is accepted and logged as unsigned, unless KDOS_REQUIRE_SIG is set in the daemon’s environment.

Only root can write the pack directory afterwards, so an installed pack is not re-hashed on every mount. A pack mounted straight off an installation medium has never been copied into the pack directory, so it is checked when it is first mounted, against its own payload hash and signature only; a bad hash, a bad signature or a signing key not in /etc/kdos/keys/packs is refused, and an unsigned medium pack mounts.

An unsigned pack is protected against corruption, not against tampering. Its payload hash is recorded in its own footer, which whoever alters the payload can rewrite. An unsigned index does not help: kdos-packd uses an index only when its signature verifies. What a signature adds is who made the pack. kdos-box freeze prints how to sign its output (kdos-pack sign <pack> <key>), and kdos-pack keygen <name> makes the key pair (<name>.key and <name>.pub), as Packs and boxes describes; a signed pack’s block is checked at install by step 2. kdos app export signs the set’s index (PACKAGES.sig beside PACKAGES) when KDOS_PACK_KEY names a readable secret key and otherwise says that the index is unsigned, but it does not sign the packs themselves, and kdos app import stages each pack without the index. Every pack of an exported set therefore reaches kdos-packd as an unsigned pack: it is accepted on its payload hash alone, and refused outright when KDOS_REQUIRE_SIG is set, whether or not the exporter had a key.

Refusing unsigned packs is opt-in. With KDOS_REQUIRE_SIG set, kdos-packd refuses an unsigned pack at install (a medium pack is not covered), and kdos-pack verify reports an unsigned one as a failure. It does not cover an OCI image pulled from a registry. kdos-pack reads the variable from the caller’s environment. kdos-packd reads it from its own, and the shipped init script, /etc/init.d/59_packd.sh, sets none, so turning it on for installs means exporting the variable in that script before its supervise line and restarting the daemon.

Untrusted image bytes

Anything that can write to a terminal can reach an image decoder: a shell script, a program inside a box, cat on a file somebody sent you. Three escape sequences carry a picture (sixel, a DCS sequence; iTerm2’s OSC 1337; and kitty’s graphics protocol, an APC sequence), and behind them are five formats, each decoded by a large C library with a long memory-safety history: sixel (libsixel), PNG (libpng), JPEG (libjpeg), WebP (libwebp) and GIF (libnsgif). HEIF and AVIF are not accepted, because both are containers around a video codec and would put a video decoder on that path.

libkimg is therefore the only place KDOS decodes untrusted image bytes. kimg_decode_all() is the one decode path (kimg_decode() is the same call for a single frame), so the budget, the audit and any new format all live in one function. Its rules:

The terminal library libkvt adds its own limit before libkimg sees anything:

In kdos-term the relevant settings go in ~/.config/kdos/term.conf (see kdos-term). A value outside the range is clamped to it:

Key Default Range Meaning
images yes yes, no no decodes nothing and discards every payload (the parser’s cap drops to 4 KiB); a kitty query is answered with a refusal so the program falls back at once
image_max 1024 4–65536 Largest single payload, in KiB
image_cells 200 1–1000 Largest picture, in cells along either side; the decode budget is 64 pixels per cell

OSC 8 marks a run of terminal text as a hyperlink. Following one is the one place a terminal hands an address it was given by a child process to a program that opens things, and its reach is the same as an image payload’s: a shell script, a program in a box, cat on a file somebody sent you. libkvt and kdos-term apply these rules:

What is not protected

This section lists what the mechanisms above do not do. A reader who assumes a protection that is not there is worse off than one who knows it is missing.

See also


Part III — Architecture, chapter 17. Previous: 16. Packs and boxes · Contents · Next: 18. The design language