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
41 changes: 14 additions & 27 deletions tools/kryptik
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
#!/usr/bin/env bash
# kryptik: the user's command for zones. Fills in kryptikd's paths from
# kryptik.conf and never offers a way past kryptikd's refusals.
#
# When not root and the session's launch service (kryptik-launch) answers,
# zones start through it; otherwise kryptikd runs them directly. There is no
# transfer, clipboard or mount command: from zone 0 those would bypass the
# chrome, where the user approves each move.
# kryptik: the user's zone command; fills in kryptikd's paths and offers no way past its refusals.
# No transfer, clipboard or mount command: from zone 0 those would bypass the chrome's approval.
set -uo pipefail

PROG="${0##*/}"
Expand Down Expand Up @@ -82,8 +77,7 @@ $PROG has no commands for either, on purpose.
USAGE
}

# True when not root and the session's launch service answers. --runtime-dir
# is its cheapest request and fails fast rather than hanging.
# Not root and the launch service answers; --runtime-dir is its cheapest request and fails fast.
LAUNCH="${KRYPTIK_LAUNCH:-kryptik-launch}"
via_launch() {
[[ "$(id -u)" -ne 0 ]] || return 1
Expand Down Expand Up @@ -120,17 +114,14 @@ have_kryptikd() {
arguments. Install it, or set KRYPTIKD=/path/to/kryptikd."
}

# The flags that map a zone to an unprivileged host user. kryptikd takes them
# only as root, and then requires them unless the zone declares its own.
# Sets an array rather than printing for `< <(...)`: process substitution
# needs /dev/fd, which a minimal system may lack.
# An array, not printed for `< <(...)`: process substitution needs /dev/fd, which may be missing.
IDENTITY_ARGS=()
# The flags mapping a zone to a host user: kryptikd takes them only as root, then requires them.
set_identity_args() { # zone name
IDENTITY_ARGS=()
[[ "$(id -u)" -eq 0 ]] || return 0

# kryptikd refuses the flags for a zone with [identity] uid_base. A
# presence test is enough: kryptikd checks the value itself.
# Not for a zone with [identity] uid_base; kryptikd checks the value, so presence is enough.
if grep -qE '^[[:space:]]*uid_base[[:space:]]*=' "$ZONES_DIR/$1.toml" 2>/dev/null; then
return 0
fi
Expand Down Expand Up @@ -175,8 +166,7 @@ require_zone() {
}

# --- running state ----------------------------------------------------------
# A word, not a colour: it must survive a pipe, a monochrome terminal and a
# screen reader.
# A word, not a colour: it must survive a pipe, a monochrome terminal and a screen reader.
running_state() {
local out
out="$("$KRYPTIKD" status "$1" 2>/dev/null)" || { printf 'unknown'; return; }
Expand Down Expand Up @@ -252,13 +242,13 @@ cmd_stop() {

# --- starting ---------------------------------------------------------------

# The one place that runs a zone, for both shell and run.
# Shared by shell and run.
start_zone() {
local name="$1"; shift
if via_launch; then
[[ -f "$ZONES_DIR/$name.toml" ]] || die "no zone named $name in $ZONES_DIR"
# --ask reads an encrypted zone's passphrase on this terminal and passes
# it on a descriptor, never as an argument.
# --ask reads an encrypted zone's passphrase on this terminal and passes it on a
# descriptor, never as an argument.
"$LAUNCH" --ask --no-display "$name" -- "$@"
local rc=$?
(( rc == 0 )) || printf '\n%s: %s did not start through the launch service (exit %d).\n' "$PROG" "$name" "$rc" >&2
Expand All @@ -272,8 +262,7 @@ start_zone() {
local rc
set_identity_args "$name"

# Not captured: a shell needs the terminal, and kryptikd's warnings and
# refusals must reach the user unchanged.
# Not captured: a shell needs the terminal, and the user must see kryptikd's refusals unchanged.
"$KRYPTIKD" run "$name" --zones "$ZONES_DIR" \
--rootfs "$ROOTFS" "${IDENTITY_ARGS[@]}" -- "$@"
rc=$?
Expand All @@ -299,9 +288,8 @@ cmd_run() {
start_zone "$name" "$@"
}

# --- the net zone's Wi-Fi networks ------------------------------------------
# kryptikd keeps the credentials in zone 0 and restarts the net zone after a
# change (docs/design/net-zone.md). The passphrase always goes on stdin.
# --- the net zone's Wi-Fi networks, kept by kryptikd in zone 0 --------------
# The passphrase always goes on stdin; kryptikd restarts the net zone after a change.

# Read one line into PASSPHRASE: from the terminal with echo off, or a pipe.
PASSPHRASE=""
Expand Down Expand Up @@ -341,8 +329,7 @@ cmd_wifi() {
esac
}

# Installed systems only: the update state is zone 0's, reached through the
# launch service.
# Installed systems only: the update state is zone 0's, reached through the launch service.
cmd_update() {
case "${1:-}" in
status|fetch|apply) [[ $# -eq 1 ]] || die "$PROG update $1 takes no argument" 2 ;;
Expand Down
55 changes: 18 additions & 37 deletions tools/provenance-inventory.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
#
# ./tools/provenance-inventory.sh [options]
# --offline lock integrity only
# --identity also check signer identity against kernel.org's
# published developer keys
# --identity also check signers against kernel.org's developer keys
# --md markdown table
# --json JSON document
# --licences also collect licence evidence (slow)
Expand Down Expand Up @@ -63,9 +62,7 @@ done < "$MANIFEST"

# --- recorded provenance caveats --------------------------------------------

# Facts no check can see (a recipe that rewrites upstream files, a signer
# upstream never designated): caveats, never classes. They describe the tree
# being inventoried, so they come from KRYPTIK_ROOT. Optional unless --notes.
# Caveats describe the inventoried tree, so they come from KRYPTIK_ROOT; optional unless --notes.
NOTESF="${KRYPTIK_ROOT}/tools/source-notes.tsv"
if [[ -n "${NOTES_ARG:-}" ]]; then
NOTESF="$NOTES_ARG"
Expand Down Expand Up @@ -124,8 +121,7 @@ elif [[ "$OFFLINE" -eq 1 ]]; then
warn "--offline: signature and publisher evidence will not be collected."
warn "Every source will therefore show only what sources.lock establishes."
else
# The evidence is the verifiers' --report output. This is not a gate, so
# their exit status is ignored.
# Not a gate: the verifiers' --report output is the evidence, and their exit status is ignored.
dim " running tools/verify-signatures.sh"
"${KRYPTIK_ROOT}/tools/verify-signatures.sh" --report="$SIGREP" \
> "${WORK}/signatures.log" 2>&1 || true
Expand All @@ -136,9 +132,7 @@ fi

# --- signer identity --------------------------------------------------------

# Looks up each keys.manifest key in kernel.org's pgpkeys.git (developer keys
# with trust paths to Torvalds, one per long key id under keys/). A match means
# kernel.org publishes that key; its trust root is TLS to git.kernel.org.
# Looks up each keys.manifest key in kernel.org's pgpkeys.git; its trust root is TLS to kernel.org.
IDREP="${WORK}/identity.tsv"
# Keep what the test hook copied in.
[[ -n "${IDREP_PRESET:-}" ]] || : > "$IDREP"
Expand All @@ -154,8 +148,7 @@ collect_identity() {
local home="${WORK}/gnupg"
mkdir -p "$home"; chmod 700 "$home"

# Reference keys. The kernel tarball is already verified against the stable
# key, so a certification by it is no new trust decision.
# Reference keys: the kernel is verified against the stable key, so they add no new trust.
local ref rid
for ref in "79BE3E4300411886:Torvalds" "38DBBDC86092693E:Kroah-Hartman" \
"E63EDCA9329DD07E:Ryabitsev"; do
Expand Down Expand Up @@ -218,20 +211,17 @@ fi

# --- licence evidence and built artefacts -----------------------------------

# Off by default: listing a tarball means decompressing all of it. Uncollected
# licences read not-collected, never unknown.
# Off by default, as listing a tarball decompresses all of it; uncollected reads not-collected.
LICREP="${WORK}/licences.tsv"
: > "$LICREP"
if [[ "$LICENCES" -eq 1 ]]; then
log "Collecting licence evidence"
# Part of this tool, so found beside it rather than under KRYPTIK_ROOT.
# Part of this tool, so found beside it, not under KRYPTIK_ROOT.
"$(dirname "${BASH_SOURCE[0]}")/scan-licenses.sh" > "$LICREP" 2>/dev/null || true
dim " $(grep -c . "$LICREP" || true) source(s) scanned"
fi

# A built tree's identity: path, file count, size, BUILD_ID and the sha256 of
# the artifact-manifest.txt beside it. Re-hashing the files is left to
# `make verify-manifest`.
# A built tree's identity; `make verify-manifest` re-hashes its files.
ARTREP="${WORK}/artifacts.tsv"
: > "$ARTREP"
if [[ -n "$ARTIFACTS" ]]; then
Expand All @@ -240,9 +230,8 @@ if [[ -n "$ARTIFACTS" ]]; then
printf 'sysroot\t%s\tabsent\t-\t-\t-\t-\n' "$ARTIFACTS" >> "$ARTREP"
else
log "Recording built artefact identity"
# `|| true`: find and du fail on a chroot-built tree's root-owned
# directories yet still count, and under pipefail common.sh's ERR trap
# would abort. Such counts are recorded as partial.
# || true: find and du fail on a chroot-built tree's root-owned directories but still
# count, and common.sh's ERR trap would abort; such counts are recorded as partial.
art_files="$(find "$ARTIFACTS" -type f 2>/dev/null | wc -l || true)"
art_readable=yes
find "$ARTIFACTS" -type d >/dev/null 2>&1 || art_readable=partial
Expand All @@ -262,8 +251,7 @@ fi

if [[ "$MD" -eq 1 || "$JSON" -eq 1 ]]; then exec 1>&3 3>&-; fi

# The keyring the counts were measured against: one warmed by an earlier
# --fetch-unknown-keys run moves many sources out of lock-only.
# A keyring warmed by --fetch-unknown-keys moves sources out of lock-only, so the report names it.
KEYSTATE="unknown"
if [[ -f "${WORK}/signatures.log" ]]; then
KEYSTATE="$(grep -oE '(keyring ready \([0-9]+ public keys\)|using cached keyring \([0-9]+ keys\))' \
Expand Down Expand Up @@ -311,10 +299,8 @@ def rows(path):
return


# ---- assurance classes, strongest first ------------------------------------
#
# The order is the point. Each entry is (key, one-line statement of what it is
# worth). Nothing is ever summed across two of these.
# ---- assurance classes, strongest first, never summed ----------------------

CLASSES = [
("signed-tree-pinned-key",
"signed tag by a key pinned in-tree, and the archive reproduces that tag's tree"),
Expand Down Expand Up @@ -408,10 +394,8 @@ def classify(name):
if s and s[0] == "signature-pinned-key":
return "signature-pinned-key", s[1]

# verify-signatures.sh emits these directly when tools/key-provenance.tsv
# names a publisher for the key. Without this passthrough they would fall
# off the end of the chain and be reported as "unverified", which would be
# a worse answer than the one the verifier actually gave.
# verify-signatures.sh emits these when tools/key-provenance.tsv names the key's publisher;
# passed through, as falling off the chain would report them unverified.
if s and s[0] in ("signature-korg-published-key",
"signature-korg-certified-key",
"signature-savannah-published-key",
Expand Down Expand Up @@ -469,8 +453,7 @@ for r in rows(lic_path):
lic[r[0]] = {"spdx": r[2], "multiple": r[3] == "yes",
"files": r[4], "method": r[5]}

# recorded caveats, keyed by source name. Validated in the shell above, so a
# row reaching here is well formed and names a source in the manifest.
# recorded caveats, keyed by source name and already validated by the shell above
notes = {}
for r in rows(notes_path):
if len(r) >= 4:
Expand All @@ -485,8 +468,7 @@ for r in rows(art_path):
"files": int(r[3]) if r[3].isdigit() else None,
"size": r[4], "build_id": r[5],
"build_manifest_sha256": r[6] if len(r) > 6 else "-",
# A tree built through a chroot has root-owned directories this
# process cannot descend, so the count is a floor, not a fact.
# A chroot-built tree has root-owned directories, so a partial count is a floor.
"count_complete": not r[2].endswith("partial"),
})

Expand Down Expand Up @@ -546,8 +528,7 @@ if os.environ.get("JSON") == "1":
doc["per_licence_counts"] = lic_counts
doc["source_count"] = len(doc["sources"])

# Counted separately and deliberately never folded into per_class_counts:
# a caveat is not a weaker class, and a class is not a caveat.
# Never folded into per_class_counts: a caveat is not a weaker class.
note_counts = {}
for src in doc["sources"]:
for n in src.get("notes", []):
Expand Down
5 changes: 2 additions & 3 deletions tools/tests/boot-success.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#!/usr/bin/env bash
# Test boot-success.sh's decision table with stand-ins: a fake /run/kryptik,
# service directory and ESP, and fake s6-svstat, kryptikd, kryptik-efiboot,
# reboot and mount that record what they were asked.
# boot-success.sh's decision table, against a fake /run/kryptik, service directory and ESP, and
# fake s6-svstat, kryptikd, kryptik-efiboot, reboot and mount that record what they were asked.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT="$ROOT/build/service-scripts/boot-success.sh"
Expand Down
29 changes: 9 additions & 20 deletions tools/tests/update-manifest-snapshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,15 @@ mkpayload replacement 3

ssh-keygen -q -t ed25519 -N '' -f key >/dev/null 2>&1 || { echo "cannot make a key"; exit 77; }
ssh-keygen -q -t ed25519 -N '' -f latestkey >/dev/null 2>&1 || { echo "cannot make a key"; exit 77; }
# The trust anchor as stage 04 installs it: each key limited to its own
# namespace (release: manifests; latest: statements of what is current).
# The trust anchor as stage 04 installs it: each key limited to its own namespace.
{
printf 'kryptik-release namespaces="kryptik-release" %s\n' "$(cut -d' ' -f1,2 key.pub)"
printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 latestkey.pub)"
} > signers
ssh-keygen -Y sign -f key -n kryptik-release signed/manifest >/dev/null 2>&1 || { echo "cannot sign"; exit 77; }
printf 'development\n' > role

# The tool's functions, verbatim, with the environment they expect. die exits,
# so each case runs in its own bash.
# The tool's functions verbatim, with their environment; die exits, so each case runs alone.
{
echo 'NAMESPACE=kryptik-release'
echo 'MAGIC=KRYPTIK-MANIFEST-1'
Expand All @@ -80,9 +78,7 @@ grep -q '^verify_payload() {' verify.sh || { echo "could not extract verify_payl

grep -q '^pin() {' verify.sh || { echo "could not extract pin from $TOOL"; exit 1; }

# run_case NAME WHAT: verify_payload over a fresh copy of the signed payload,
# with WHAT changed the moment ssh-keygen verifies. Prints the tool's output,
# plus VERSION=<reported> if it accepted.
# run_case NAME WHAT: verify_payload on a copy of the signed payload, WHAT changed as it verifies.
run_case() {
local name="$1" what="$2"
rm -rf "$T/payload" "$T/snap-$name"; cp -a "$T/signed" "$T/payload"; mkdir -p "$T/snap-$name"
Expand Down Expand Up @@ -117,8 +113,7 @@ EOF
out="$(run_case plain none)"
if [[ "$out" == *"VERSION=2"* ]]; then ok "the signed payload verifies and reports version 2"; else bad "the signed payload did not verify: $(tail -2 <<<"$out" | tr '\n' ' ')"; fi

# Only the manifest replaced: the kept copy still matches the files, so the
# answer is version 2, never 3.
# Only the manifest replaced: the kept copy still matches the files, so the answer stays 2.
out="$(run_case manifest manifest)"
if [[ "$out" == *"VERSION=3"* ]]; then
bad "the replaced, unsigned manifest was read after the signature check (version 3)"
Expand Down Expand Up @@ -157,9 +152,7 @@ else
ok "control: the real verifier rejects the replacement manifest"
fi

# --- the update channel's two checks -----------------------------------------
# Zone 0 runs these before it believes a manifest or a statement of what is
# current (docs/design/update-channel.md).
# --- check-manifest and check-pointer, run by zone 0 before it believes either -----
check() { # check FUNCTION ARGS... -> the tool's output, REFUSED: on a refusal
{ echo "source $T/verify.sh"; printf 'SNAP=%q\n' "$(mktemp -d "$T/snap.XXXXXX")"; printf '%q ' "$@"; echo; } > "$T/check.sh"
bash "$T/check.sh" 2>&1
Expand All @@ -177,17 +170,15 @@ if [[ "$out" == *"version: 2"* && "$out" == *"sha256: $want_sha"* && "$(grep -c
else
bad "check-manifest on a signed manifest: $(tail -3 <<<"$out" | tr '\n' ' ')"
fi
# Zone 0 reads only stdout and wants the version on its first line
# (compartments/kryptikd/src/update.rs); the case above searches both streams.
# Zone 0 (kryptikd's update.rs) reads the version from stdout's first line; above, both streams.
staged stage
check cmd_check_manifest "$T/stage" > /dev/null
first="$(bash "$T/check.sh" 2>/dev/null | head -1)"
[[ "$first" == "version: 2" ]] \
&& ok "check-manifest: the first line of its standard output is the version, as zone 0 reads it" \
|| bad "check-manifest: the first line zone 0 reads is '${first}', not 'version: 2'"

# Signed by the right key in the pointer's namespace: a pointer's signature
# must never pass for a manifest's.
# The right key in the pointer's namespace: a pointer's signature must not pass for a manifest's.
staged crossed; rm -f "$T/crossed/manifest.sig"
ssh-keygen -Y sign -f key -n kryptik-latest "$T/crossed/manifest" >/dev/null 2>&1
out="$(check cmd_check_manifest "$T/crossed")"
Expand All @@ -203,8 +194,7 @@ out="$(check cmd_check_manifest "$T/stranger")"
&& ok "check-manifest: a manifest signed by a key that is not enrolled is refused" \
|| bad "check-manifest accepted a stranger's key: $(tail -2 <<<"$out" | tr '\n' ' ')"

# apply's rules too, as it is the same function: the role, and no downgrade
# (a network delivery is never a recovery).
# apply's rules too (the same function): the role, and no downgrade, as a download is no recovery.
resigned() { # resigned NAME SED-EXPRESSION -> the signed manifest, edited, signed again
rm -rf "${T:?}/$1"; mkdir -p "$T/$1"
sed "$2" "$T/signed/manifest" > "$T/$1/manifest"
Expand Down Expand Up @@ -256,8 +246,7 @@ out="$(check cmd_check_pointer "$T/ptr/stranger" "$T/ptr/stranger.sig")"
&& ok "check-pointer: a pointer signed by a key that is not enrolled is refused" \
|| bad "check-pointer accepted a stranger's key: $(tail -2 <<<"$out" | tr '\n' ' ')"

# Each key is enrolled for one namespace only, both ways round; this is what
# lets the statement key live where a timer can reach it.
# Each key holds one namespace, both ways round, so the statement key can live where a timer runs.
cp "$T/ptr/latest" "$T/ptr/by-release-key"
ssh-keygen -Y sign -f key -n kryptik-latest "$T/ptr/by-release-key" >/dev/null 2>&1
out="$(check cmd_check_pointer "$T/ptr/by-release-key" "$T/ptr/by-release-key.sig")"
Expand Down
Loading
Loading