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
3 changes: 1 addition & 2 deletions compartments/kryptikd/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ edition = "2021"
description = "Kryptik compartment manager: zone lifecycle and isolation"
license = "GPL-2.0-or-later"

# kryptikd is the most privileged process on the system, so it depends on
# `libc` alone (ADR-010); anything else, TOML parsing included, is written here.
# The most privileged process on the system depends on libc alone; even TOML parsing is ours (ADR-010).
[dependencies]
libc = "0.2"

Expand Down
49 changes: 15 additions & 34 deletions compartments/kryptikd/probes/boundary-checks.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
#!/usr/bin/env bash
# Regression probes for the kryptikd zone boundary, through the real launcher
# (`kryptikd run`). Exit status is the number of failures.
# Regression probes for the zone boundary, through `kryptikd run`; the exit status counts failures.
# Unprivileged, what needs root or the target kernel is reported as not run; zones-test.sh also
# runs it as root on the installed system.
# compartments/kryptikd/probes/boundary-checks.sh [path/to/kryptikd]
# Unprivileged on a developer host, where what needs root and the target kernel
# is reported as not run; also run as root on the installed system
# (tools/image/zones-test.sh).
set -u

HERE="$(cd "$(dirname "$0")" && pwd)"
Expand All @@ -15,9 +13,7 @@ K="$(cd "$(dirname "$K")" && pwd)/$(basename "$K")"
# shellcheck source=fixtures.sh
source "$HERE/fixtures.sh"

# As root, zones need an explicit identity (kryptikd will not map a zone's root
# to uid 0), and the fixture roots must belong to it: setup drops to that uid
# before it opens the data directory.
# As root, zones need an identity other than uid 0, and setup opens their data as that uid.
IDFLAGS=()
if [[ "$(id -u)" -eq 0 ]]; then
IDFLAGS=(--zone-uid 100000 --zone-gid 100000)
Expand All @@ -33,8 +29,7 @@ fail() { echo "FAIL $1"; shift; [[ $# -gt 0 ]] && printf '%s\n' "$1" | sed 's/^
skip() { echo "SKIP $1"; SKIPS=$((SKIPS+1)); }
head_() { echo; echo "== $1"; }

# Noise every launch prints that no check is about: the experimental hint,
# the swap caveat, inherited groups an unprivileged launcher cannot drop.
# Lines every launch prints that no check is about.
denoise() {
grep -v 'KRYPTIK_EXPERIMENTAL=1 -\|supplementary host group\|not secure erasure\|auto-approve-transfers: every\|NOT encrypted at rest'
}
Expand All @@ -43,9 +38,7 @@ denoise() {
zrun() {
local zone="$1"; shift
[[ "${1:-}" == "--" ]] && shift
# No pipe, so ZRC is the launcher's status. stdout and stderr are captured
# apart and joined after, zone output first: on one pipe they interleave
# mid-line.
# Files, not a pipe: ZRC stays the launcher's status, and the two streams cannot interleave.
local out="$F/zrun.out" err="$F/zrun.err"
timeout 60 "$K" run "$zone" "${ZFLAGS[@]}" "${IDFLAGS[@]}" --zones "$F/zones" --rootfs "$F/roots" -- "$@" > "$out" 2> "$err"
ZRC=$?
Expand Down Expand Up @@ -134,8 +127,7 @@ import socket
for n,f,t in [('AF_VSOCK',40,1),('AF_ALG',38,5),('AF_PACKET',17,2)]:
try: socket.socket(f,t); print(n,'OPENED')
except OSError as e: print(n,'refused',e.errno)"
# The kernel runs a family's create code before it refuses a pair (EOPNOTSUPP,
# 95), so the filter refuses every family but AF_UNIX first (97).
# The kernel runs a family's create code before refusing a pair, so the filter refuses first (97).
MATCH="^unix-pair inet 97$" check "socketpair(2) makes AF_UNIX pairs and refuses AF_INET by family" 0 /usr/bin/python3 -c "
import socket
a,b=socket.socketpair(); a.close(); b.close()
Expand All @@ -162,8 +154,7 @@ MATCH="owns the NIC" checkz routedraw "a zone that does not own the NIC may not
MATCH="policy" checkz nopolicy "a policy file that does not exist is a refusal, not a fallback" 1 /bin/sh -c "echo RAN-ANYWAY"
MATCH="ptrace" checkz badpolicy "a policy may not re-allow something on the denied list" 1 /bin/sh -c "echo RAN-ANYWAY"

# Landlock: a zone policy is a second layer, and layers intersect, so it can
# only take access away.
# A Landlock policy is a second layer, and layers intersect, so it can only take access away.
MATCH="^ok$" checkz narrowed "a zone with a Landlock policy still runs and can read its root" 0 /bin/sh -c "test -r /usr/bin/env && echo ok"
MATCH="^ok$" checkz narrowed "... and writes where the policy grants write" 0 /bin/sh -c "echo x > /tmp/f && echo x > /dev/null && echo ok"
MATCH="^denied$" checkz narrowed "... but NOT its own HOME, which the base rules alone would allow" 0 /bin/sh -c "echo x > \$HOME/f 2>/dev/null && echo WROTE || echo denied"
Expand All @@ -177,8 +168,7 @@ MATCH="symbolic link" checkz swapped "... which its next start refuses rather th
# ---------------------------------------------------------------------------
head_ "F. Guarantees a build cannot give are refused, not implied"

# Unprivileged, the refusal is that a LUKS2 volume needs a root launch; as
# root, that the passphrase is missing. Both say "is encrypted:".
# Unprivileged it needs a root launch, as root a passphrase; both refusals say "is encrypted:".
MATCH="is encrypted:" checkz sealed "a zone declaring encrypted storage does not start on a plain directory" 1 /bin/sh -c "echo RAN-ANYWAY"
if "$K" run capped "${ZFLAGS[@]}" "${IDFLAGS[@]}" --zones "$F/zones" --rootfs "$F/roots" -- /bin/sh -c "echo LIMITS-RAN" 2>&1 | grep -q LIMITS-RAN; then
pass "[limits] is enforced here: cgroups are creatable and the zone ran"
Expand Down Expand Up @@ -214,16 +204,13 @@ MATCH="^kryptik-broker 1 zone=probe$" check "a zone reaches its own broker and i
"
MATCH="^error: unknown verb$" check "an unknown verb is refused" 0 /usr/bin/python3 -c "$BRK" "steal
"
# The clock (docs/design/time.md): only the zone that holds the network may
# claim a time. A malformed claim is refused at parse time, before the caller
# is checked.
# Only the zone holding the network may claim a time (docs/design/time.md); parsing comes first.
MATCH="does not hold the network" check "the clock's verb is refused from a zone that does not hold the network" 0 /usr/bin/python3 -c "$BRK" "time-offset 5 4
"
MATCH="is not an offset in seconds" check "a time claim outside the grammar is refused at parse time" 0 /usr/bin/python3 -c "$BRK" "time-offset 1e9 4
"
# The update channel (docs/design/update-channel.md): only the zone that holds
# the network may bring a release. Other zones are refused before any payload
# is read, and a malformed request at parse time, before that.
# Only the zone holding the network may bring a release (docs/design/update-channel.md); other
# zones are refused before any payload is read.
MATCH="does not hold the network" check "a statement of what is current is refused from a zone that does not hold the network" 0 /usr/bin/python3 -c "$BRK" "update-latest 5 3
helloabc"
MATCH="does not hold the network" check "asking whether a release is wanted is refused from a zone that does not hold the network" 0 /usr/bin/python3 -c "$BRK" "update-poll
Expand Down Expand Up @@ -255,10 +242,7 @@ s=socket.socket(socket.AF_UNIX); s.connect("/run/kryptik/broker")
s.sendmsg([("transfer %s %s\n"%(dest,name)).encode()],[(socket.SOL_SOCKET,socket.SCM_RIGHTS,array.array("i",[fd]))])
print(s.recv(300).decode().strip())'

# A real transfer between two running zones: packet announces itself, waits
# for the file and reports it; probe then sends one. Waits are bounded polls,
# not sleeps, as zone start-up time varies widely. The broker creates the file
# under its final name before filling it, so an empty file is still landing.
# packet polls for a non-empty file: the broker creates it under its final name before filling it.
"$K" run packet "${ZFLAGS[@]}" "${IDFLAGS[@]}" --zones "$F/zones" --rootfs "$F/roots" -- /usr/bin/python3 -u -c "
import os,time
print('PACKET-UP')
Expand Down Expand Up @@ -293,8 +277,7 @@ MATCH="not on the zone" check "a file from the zone's tmpfs, not its data mount,
MATCH="transfer limit is 16" check "a file over the destination's [transfer] max_bytes is refused" 0 /bin/sh -c "printf '%017d' 0 > /home/probe/big && python3 -c '$TX' packet big /home/probe/big 0"
MATCH="not running" check "a destination that is not running is refused" 0 /bin/sh -c "echo x > /home/probe/f && python3 -c '$TX' packet f /home/probe/f 0"
ZFLAGS=()
# Consent is asked only for a running destination. Nobody answers here, so a
# short deadline makes that a refusal rather than a timeout.
# Consent is asked only for a running destination; a short deadline makes no answer a refusal.
"$K" run packet "${IDFLAGS[@]}" --zones "$F/zones" --rootfs "$F/roots" -- /usr/bin/python3 -u -c "
import time
print('PACKET-UP')
Expand All @@ -309,9 +292,7 @@ MATCH="single path component" check "a name carrying a path separator is refused
# ---------------------------------------------------------------------------
head_ "H. The zone dies with its launcher"

# MARK is argv[0] of the zone's command, so `pgrep -f "^$MARK"` matches it and
# not the launcher, whose command line carries it further along. The zone must
# be up before its launcher is signalled; both waits are bounded polls.
# MARK is argv[0] of the zone's command, so "^$MARK" matches it and not the launcher.
MARK="kryptik-probe-sleep-$$"
zone_up() { for _ in $(seq 1 300); do pgrep -f "^$MARK" >/dev/null && return 0; kill -0 "$1" 2>/dev/null || return 1; sleep 0.1; done; return 1; }
zone_gone() { for _ in $(seq 1 100); do pgrep -f "^$MARK" >/dev/null || return 0; sleep 0.1; done; return 1; }
Expand Down
32 changes: 10 additions & 22 deletions compartments/kryptikd/probes/fixtures.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
#!/usr/bin/env bash
# Synthetic zones for the kryptikd boundary probes, sourced by boundary-checks.sh.
# Exports $F, a temporary root (removed on exit) holding zones/, zones/policy/
# and roots/. Nothing here is secret; payloads are the strings a check greps for.
# Synthetic zones for boundary-checks.sh, under $F (zones/, zones/policy/, roots/; removed on exit).
set -u

F="$(mktemp -d -t kryptik-probe-XXXXXX)"
Expand All @@ -17,17 +15,15 @@ zone() { # zone NAME BODY...
mkdir -p "$F/roots/$name"
}

# probe: the ordinary zone most checks run in. No policy file: base seccomp
# allowlist and CAP_NET_BIND_SERVICE only.
# probe: where most checks run, with no policy file: the base allowlist and CAP_NET_BIND_SERVICE.
zone probe \
'[zone]' 'name = "probe"' \
'[network]' 'mode = "routed"' \
'[storage]' 'mode = "ephemeral"' 'size = "64M"' \
'[transfer]' 'to = "packet"' \
'[ui]' 'border_color = "#123456"'

# packet: the transfer destination. Its policy allows AF_PACKET but keeps no
# capability, so the socket passes seccomp and the kernel refuses it.
# packet: the transfer destination, allowed AF_PACKET but no capability, so the kernel refuses it.
zone packet \
'[zone]' 'name = "packet"' \
'[network]' 'mode = "none"' \
Expand All @@ -39,8 +35,7 @@ printf '%s\n' \
'# Synthetic: the socket family is allowed, no capability is kept.' \
'allow-socket AF_PACKET' > "$F/zones/policy/packet.seccomp"

# capped: declares [limits]. Where cgroups cannot be created it must be
# refused, naming [limits].
# capped: declares [limits], which must be refused where cgroups cannot be created.
zone capped \
'[zone]' 'name = "capped"' \
'[network]' 'mode = "none"' \
Expand All @@ -55,16 +50,14 @@ zone keeper \
'[storage]' 'mode = "persistent"' \
'[ui]' 'border_color = "#fedcba"'

# sealed: encrypted storage must refuse to start, never fall back to a plain
# directory.
# sealed: encrypted storage must refuse to start, never fall back to a plain directory.
zone sealed \
'[zone]' 'name = "sealed"' \
'[network]' 'mode = "none"' \
'[storage]' 'mode = "encrypted"' 'volume = "/dev/null"' \
'[ui]' 'border_color = "#0f0f0f"'

# nicholder: the NIC owner every zone set needs, and the zone NIC-only policy
# rules apply to. It names no interface, so none is moved.
# nicholder: the NIC owner every zone set needs; it names no interface, so none is moved.
zone nicholder \
'[zone]' 'name = "nicholder"' \
'[network]' 'mode = "nic"' \
Expand All @@ -77,26 +70,23 @@ printf '%s\n' \
'keep-capability CAP_NET_RAW' \
'keep-capability CAP_NET_ADMIN' > "$F/zones/policy/nic.seccomp"

# routedraw: a zone that does not own the NIC may not keep CAP_NET_RAW,
# whatever its policy says.
# routedraw: does not own the NIC, so it may not keep CAP_NET_RAW whatever its policy says.
zone routedraw \
'[zone]' 'name = "routedraw"' \
'[network]' 'mode = "routed"' \
'[storage]' 'mode = "ephemeral"' 'size = "64M"' \
'[policy]' 'seccomp = "policy/nic.seccomp"' \
'[ui]' 'border_color = "#ff0088"'

# nopolicy: a missing policy file is a refusal, never a fallback to the base
# rules.
# nopolicy: a missing policy file is a refusal, never a fallback to the base rules.
zone nopolicy \
'[zone]' 'name = "nopolicy"' \
'[network]' 'mode = "none"' \
'[storage]' 'mode = "ephemeral"' 'size = "64M"' \
'[policy]' 'seccomp = "policy/absent.seccomp"' \
'[ui]' 'border_color = "#333333"'

# narrowed: Landlock grants read everywhere and write only in /tmp and /dev,
# so its HOME becomes read-only; the second layer can only subtract.
# narrowed: its Landlock policy grants write only in /tmp and /dev, so its HOME is read-only.
zone narrowed \
'[zone]' 'name = "narrowed"' \
'[network]' 'mode = "none"' \
Expand All @@ -109,9 +99,7 @@ printf '%s\n' \
'read-write /tmp' \
'read-write /dev' > "$F/zones/policy/narrowed.landlock"

# swapped: persistent, and its policy keeps HOME read-only but for work, with
# exec only in work/bin. Write in work lets it swap work/bin for a link to
# HOME, which a rule must refuse at the next start, not follow.
# swapped: can replace work/bin with a link to HOME, which its next start must refuse, not follow.
zone swapped \
'[zone]' 'name = "swapped"' \
'[network]' 'mode = "none"' \
Expand Down
Loading
Loading