diff --git a/tools/kryptik b/tools/kryptik index 064d053b..aafaa51b 100755 --- a/tools/kryptik +++ b/tools/kryptik @@ -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##*/}" @@ -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 @@ -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 @@ -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; } @@ -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 @@ -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=$? @@ -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="" @@ -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 ;; diff --git a/tools/provenance-inventory.sh b/tools/provenance-inventory.sh index 896c8b52..376a1b42 100755 --- a/tools/provenance-inventory.sh +++ b/tools/provenance-inventory.sh @@ -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) @@ -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" @@ -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 @@ -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" @@ -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 @@ -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 @@ -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 @@ -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\))' \ @@ -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"), @@ -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", @@ -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: @@ -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"), }) @@ -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", []): diff --git a/tools/tests/boot-success.sh b/tools/tests/boot-success.sh index 45488d7e..f9352cae 100755 --- a/tools/tests/boot-success.sh +++ b/tools/tests/boot-success.sh @@ -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" diff --git a/tools/tests/update-manifest-snapshot.sh b/tools/tests/update-manifest-snapshot.sh index 9631c04e..f4298ea0 100755 --- a/tools/tests/update-manifest-snapshot.sh +++ b/tools/tests/update-manifest-snapshot.sh @@ -52,8 +52,7 @@ 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)" @@ -61,8 +60,7 @@ ssh-keygen -q -t ed25519 -N '' -f latestkey >/dev/null 2>&1 || { echo "cannot ma 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' @@ -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= 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" @@ -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)" @@ -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 @@ -177,8 +170,7 @@ 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)" @@ -186,8 +178,7 @@ first="$(bash "$T/check.sh" 2>/dev/null | head -1)" && 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")" @@ -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" @@ -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")" diff --git a/tools/tests/verify-signatures.sh b/tools/tests/verify-signatures.sh index 01dd0f56..0702ef64 100755 --- a/tools/tests/verify-signatures.sh +++ b/tools/tests/verify-signatures.sh @@ -1,12 +1,9 @@ #!/usr/bin/env bash -# Tests for tools/verify-signatures.sh. Offline, with real GnuPG: per-run keys -# produce GOODSIG, EXPKEYSIG, REVKEYSIG, BADSIG and NO_PUBKEY. Each case runs -# the tool against a throwaway KRYPTIK_ROOT. +# Tests for tools/verify-signatures.sh: offline, real GnuPG, per-run keys, a throwaway KRYPTIK_ROOT. set -uo pipefail -# common.sh prefers these over paths derived from KRYPTIK_ROOT, so an exported -# one would point the tool at the real tree. +# common.sh prefers these, when exported, to paths derived from KRYPTIK_ROOT. unset KRYPTIK_SOURCES KRYPTIK_WORK KRYPTIK_LOCK KRYPTIK_OUT KRYPTIK_ROOT ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -78,8 +75,7 @@ printf 'TAMPERED AFTER SIGNING\n' >> "${SRC}/bad.tar.gz" # Upstream publishes nothing alongside this one. printf 'no signature is published for this\n' > "${SRC}/nosig.tar.gz" -# The tool's keyring: good, expired and revoked (revocation applied). GnuPG -# prefixes its revocation certificates with ':' against accidental import. +# The tool's keyring: good, expired and revoked; sed strips the ':' guarding gpg's revocation. REVFPR="$(GNUPGHOME="$FIXG" gpg --batch --list-keys --with-colons revoked@example.test \ | awk -F: '$1=="fpr"{print $10; exit}')" GNUPGHOME="$FIXG" gpg --batch --quiet \ @@ -90,8 +86,7 @@ sed 's/^://' "${FIXG}/openpgp-revocs.d/${REVFPR}.rev" \ | GNUPGHOME="$BUILDG" gpg --batch --quiet --import >/dev/null 2>&1 GNUPGHOME="$BUILDG" gpg --batch --quiet --export > "${W}/keyring.gpg" -# The unknown key is reachable only by the id its signature names, as a long -# key id or a fingerprint (gpg may report either). +# The unknown key is served only under the id its signature names: long key id or fingerprint. UNKFPR="$(GNUPGHOME="$FIXG" gpg --batch --list-keys --with-colons unknown@example.test \ | awk -F: '$1=="fpr"{print $10; exit}')" UNKID="${UNKFPR: -16}" @@ -139,8 +134,7 @@ echo # --- harness ---------------------------------------------------------------- -# write_manifest ...: rows shaped like fetch-sources.sh --list, each -# declaring probe: whichever of .sig, .asc and .sign is published. +# write_manifest NAME...: rows shaped like fetch-sources.sh --list, each declaring probe. write_manifest() { : > "${W}/manifest" local n @@ -277,8 +271,7 @@ write_manifest good fresh_root; run --strict "--notes=${W}/notes-stale.tsv" expect_fail "a no-usable-key note for a source whose key is held fails --strict" "stale note" -# A noted source whose signature could not be fetched this run: unverifiable, -# and the note untried, not stale. +# A noted source whose signature could not be fetched: unverifiable, its note untried, not stale. printf 'fixture payload for unreached\n' > "${SRC}/unreached.tar.gz" rm -f "${SRC}/unreached.tar.gz.sig" "${SRC}/.signatures/unreached.tar.gz.sig" printf 'unreached no-usable-key https://example.invalid/ No route to the key was found. Checked 2026-09-28.\n' > "${W}/notes-unreached.tsv" @@ -375,8 +368,7 @@ printf 'fixture payload for kern, altered\n' | gzip -c > "${SRC}/kern.tar.gz" fresh_root; run expect_fail "a kernel row whose tar is not the signed one fails" "BAD SIGNATURE" -# verify-provenance.sh checks these, so nothing is fetched for them, even -# where a signature exists. +# verify-provenance.sh checks these, so nothing is fetched for them even where a signature exists. : > "${W}/manifest"; add_row good sha256; add_row expired tag; add_row nosig none fresh_root; run --report="${W}/declared.tsv" if [[ "$RC" -eq 0 ]] \ @@ -428,8 +420,7 @@ fresh_root; run expect_pass "a sha256.txt row is left to verify-provenance.sh" \ "good: no OpenPGP signature upstream; the publisher's .sha256.txt is verify-provenance's" -# A signature over a checksum list beside the file, like cmake's (sha256) -# and pixman's (sha512). The list must give the file's digest. +# A signed checksum list beside the file, like cmake's (sha256) and pixman's (sha512). printf 'fixture payload for summed\n' > "${SRC}/summed.tar.gz" { printf '%s other.tar.gz\n' "$(printf other | sha256sum | cut -d' ' -f1)" printf '%s summed.tar.gz\n' "$(sha256sum "${SRC}/summed.tar.gz" | cut -d' ' -f1)"; } \ @@ -541,8 +532,7 @@ else sed 's/^/ /' "${FAKE}/keys.manifest" fi -# Known limit: keys.manifest is the only record of how a cached key got there, -# so without it the key counts as verified. The remedy, --refresh, is next. +# Known limit: a cached key without its keys.manifest entry counts as verified; --refresh is next. rm -f "${FAKE}/keys.manifest" run if grep -qF "verified: 1" "$OUT"; then @@ -572,8 +562,7 @@ KEYRING="${W}/keyring.gpg" write_manifest good expired revoked bad nosig unknown fresh_root; run --fetch-unknown-keys -# good + expired verified; unknown unaudited; nosig unverifiable; -# revoked + bad fatal. +# good and expired verified, unknown unaudited, nosig unsigned, revoked and bad fatal. for want in "verified: 2" "unaudited: 1" "unsigned: 1" \ "REVOKED KEYS: 1" "FAILED: 2"; do if grep -qF "$want" "$OUT"; then @@ -799,8 +788,7 @@ else red "an unresolvable wkd locator warns and leaves the source unverified (exit ${RC})"; show fi -# A key already held is still merged from where it is published: good is in -# the tool's keyring, and its published copy carries its revocation. +# good is held, but its published copy carries a revocation, which the merge must pick up. GOODFPR="$(GNUPGHOME="$FIXG" gpg --batch --list-keys --with-colons good@example.test \ | awk -F: '$1=="fpr"{print $10; exit}')" REVG="${W}/gnupg-revoke" @@ -821,8 +809,7 @@ else red "a held key is merged from its locator, so a revocation published there is seen (exit ${RC})"; show fi -# A held key whose locator now serves another key: the held copy must not -# carry the source through. +# A held key whose locator serves another key: the held copy must not carry the source. fresh_root write_manifest good prov_table "${GOODFPR} korg file://${PROV}/unknown.asc 2026-09-11 good good fixture, whose locator now serves another key" @@ -834,8 +821,7 @@ else red "a held key refused at its locator fails the source it signs (exit ${RC}, got $(klass_of "${W}/r6.tsv" good))"; show fi -# A locator that serves the recorded key and another: only the recorded one is -# taken, so a source signed by the other stays unverified. +# A locator serving the recorded key and another: only the recorded one is taken. fixgpg --quick-generate-key "extra fixture " ed25519 sign never >/dev/null 2>&1 printf 'fixture payload for extra\n' > "${SRC}/extra.tar.gz" fixgpg --yes --local-user extra@example.test \ @@ -927,8 +913,7 @@ bad_prov "${UNKFPR} korg file://${PROV}/unknown.asc 2026-09-11 unknown" \ # --- the platform-published kind -------------------------------------------- -# github: GitHub publishes the key of the account that published the pinned -# release. Its own class, since holding that account defeats both at once. +# github: the release publisher's account key; its own class, as holding that account defeats both. fresh_root write_manifest unknown prov_table "${UNKFPR} github file://${PROV}/unknown.asc 2026-09-11 unknown unknown fixture; fixture/repo v1.0 was published by nobody" diff --git a/tools/verify-signatures.sh b/tools/verify-signatures.sh index 115f0b34..116ca815 100755 --- a/tools/verify-signatures.sh +++ b/tools/verify-signatures.sh @@ -4,12 +4,11 @@ # ./tools/verify-signatures.sh [--strict] [--refresh] [--fetch-unknown-keys] # [--report=FILE] [--notes=FILE] # --strict release gate: anything unverified or unaudited fails, -# except a signer no publisher states, when -# tools/source-notes.tsv says so (no-usable-key) +# except an unheld key the notes record as no-usable-key # --refresh discard cached keys and re-import # --fetch-unknown-keys import keys the signatures name (unaudited) # --report=FILE per-source results to FILE -# --notes=FILE the caveats, not tools/source-notes.tsv +# --notes=FILE caveats from FILE, not tools/source-notes.tsv source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" load_config @@ -18,11 +17,10 @@ have gpg || die "gpg not found. Install gnupg." KEYDIR="${KRYPTIK_ROOT}/build/work/keys" SIGDIR="${KRYPTIK_SOURCES}/.signatures" -# With the sources, so a cache of them carries it (tools/fetch-sources.sh). +# Kept with the sources so their cache carries it; tools/fetch-sources.sh fetches it too. GNU_KEYRING="${KRYPTIK_SOURCES}/.keys/gnu-keyring.gpg" -# A private GNUPGHOME, not --keyring: GnuPG 2.4 with keyboxd silently ignores -# --keyring and verifies against the user's own store. +# Not --keyring: GnuPG 2.4 with keyboxd ignores it and verifies against the user's own store. export GNUPGHOME="${KEYDIR}/gnupg" FETCH_UNKNOWN=0 @@ -42,9 +40,7 @@ for a in "$@"; do done [[ -n "$REPORT" ]] && : > "$REPORT" -# A signer no publisher states, accepted deliberately: the note names the -# routes that were tried. Such a source is not held against --strict; a note -# for a source whose key is held is stale, and --strict fails on it. +# no-usable-key notes let their sources pass --strict; --strict fails a note whose key is held. declare -A NOTED_NO_KEY=() if [[ -f "$NOTES" ]]; then while read -r n_pkg n_kind _; do @@ -56,28 +52,23 @@ NOTED_LIST=() declare -A NOTE_USED=() declare -A SEEN_SOURCE=() -# report -# One tab-separated line per source, for provenance-inventory.sh. The class is -# an assurance class (which kind of key verified it), not a pass/fail. +# report SOURCE CLASS DETAIL for the inventory; CLASS is an assurance class, not pass or fail. report() { [[ -n "$REPORT" ]] || return 0 printf '%s\t%s\t%s\n' "$1" "$2" "${3//$'\t'/ }" >> "$REPORT" } -# Keys accepted without audit, awaiting out-of-band confirmation. Read on every -# run, not only with --fetch-unknown-keys (see UNAUDITED_FPRS). +# Unaudited keys awaiting out-of-band confirmation, read on every run (see UNAUDITED_FPRS). KEYS_MANIFEST="${KRYPTIK_ROOT}/keys.manifest" mkdir -p "$KEYDIR" "$SIGDIR" "$GNUPGHOME" "$(dirname "$GNU_KEYRING")" chmod 700 "$GNUPGHOME" IMPORTED_MARK="${GNUPGHOME}/.kryptik-imported" -# This GNUPGHOME has the GNU keyring in it: kept beside the keys, not beside -# the keyring file, which outlives any one checkout's keys. +# Beside the keys, not the keyring file, which outlives any one checkout's keys. GNU_IMPORTED="${GNUPGHOME}/.gnu-keyring-imported" -# A host that throttles (freedesktop.org answers 418 to a busy runner, others -# 429 or 503) is asked again after a pause; a 404 is an answer. +# Throttling answers (418 from freedesktop.org, 429, 503) are retried after a pause; a 404 is final. quiet_fetch() { # quiet_fetch URL OUT local code try for try in 1 2 3; do @@ -112,7 +103,7 @@ manifest_source() { "${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list } -# recv_key : from a keyserver, or KRYPTIK_SIGCHECK_KEYSOURCE in tests. +# recv_key KEYID: from a keyserver, or KRYPTIK_SIGCHECK_KEYSOURCE in tests. recv_key() { local keyid="$1" f if [[ -n "${KRYPTIK_SIGCHECK_KEYSOURCE:-}" ]]; then @@ -152,10 +143,8 @@ import_keys() { return 0 fi - # Fetched over the network, so it gives "signed by whoever the keyring - # says" unless its keys are checked out of band (docs/supply-chain.md). - # The keyring counts as imported only when the GNU keyring and every - # pinned key are in it; what a run could not fetch, the next fetches again. + # Fetched over the network: it vouches for whoever the keyring says until checked out of band + # (docs/supply-chain.md). It counts as imported once it and every pinned key are in. local count log "fetching GNU keyring" if [[ ! -s "$GNU_KEYRING" ]]; then @@ -167,8 +156,6 @@ import_keys() { count="$(gpg --batch --list-keys 2>/dev/null | grep -c '^pub' || true)" [[ "$count" =~ ^[0-9]+$ && "$count" -ge 100 ]] && : > "$GNU_IMPORTED" fi - # Without it nothing a GNU maintainer signed can be checked: a run that - # could not fetch it is not a pass. if [[ ! -f "$GNU_IMPORTED" ]]; then rm -f "$IMPORTED_MARK" if [[ "$STRICT" -eq 1 ]]; then @@ -180,9 +167,7 @@ Run again: what was fetched is kept." warn "the GNU keyring could not be fetched: what GNU maintainers signed is unverifiable this run" fi - # Safe from a keyserver: it cannot serve another key under a full - # fingerprint. One it does not serve is fetched again next run, and the - # sources that key signs are unverifiable until then. + # Safe from a keyserver, which cannot serve another key under a full fingerprint. log "fetching pinned maintainer keys (${#PINNED_FPRS[@]})" local fpr missing=0 for fpr in "${PINNED_FPRS[@]}"; do @@ -222,10 +207,7 @@ mark_unverifiable() { UNVERIFIABLE_LIST+=("$1") } -# Upstream signs with nothing OpenPGP, by the manifest's own declaration or -# by a listing that holds none: the lock pins the file, and -# tools/verify-provenance.sh checks whatever else upstream publishes. A -# declared signature that is missing or is not a signature stays unverifiable. +# No OpenPGP signature upstream: the lock pins the file and verify-provenance.sh checks the rest. UNSIGNED=0 UNSIGNED_LIST=() mark_unsigned() { @@ -233,8 +215,7 @@ mark_unsigned() { UNSIGNED_LIST+=("$1") } -# Unaudited keys, from keys.manifest. Once cached, such a key gives a plain -# GOODSIG, so the manifest (read on every run) is what keeps it marked. +# keys.manifest's keys: a cached one gives a plain GOODSIG, so only this list keeps it unaudited. declare -a UNAUDITED_FPRS=() if [[ -f "$KEYS_MANIFEST" ]]; then while read -r _pkg fpr _rest; do @@ -242,16 +223,14 @@ if [[ -f "$KEYS_MANIFEST" ]]; then done < <(grep -v '^[[:space:]]*#' "$KEYS_MANIFEST" || true) fi -# Keys trusted by fingerprint in the tree, reported as a stronger class than the -# fetched GNU keyring. Each must be a fingerprint the project publishes on its -# own origin, with that source noted here so it can be rechecked. +# Pinned keys outrank the GNU keyring. Each is a fingerprint its project publishes on its own +# origin, noted with it so it can be rechecked. PINNED_FPRS=( # kernel.org mainline and stable: pgpkeys.git and kernel.org's WKD serve both (2026-10-02; key-provenance.tsv). "ABAF11C65A2970B130ABE3C479BE3E4300411886" # Linus Torvalds, mainline "647F28654894E3BD457199BE38DBBDC86092693E" # Greg Kroah-Hartman, stable - # Signs CPython 3.12.x and 3.13.x, per - # https://www.python.org/downloads/metadata/pgp/ (retrieved 2026-09-11). + # CPython 3.12.x and 3.13.x, per https://www.python.org/downloads/metadata/pgp/ (retrieved 2026-09-11) "7169605F62C751356D054A26A821E680E5FA6305" # Thomas Wouters, CPython 3.12/3.13 # From https://openssl-library.org/source/ (retrieved 2026-09-11): the page @@ -265,31 +244,21 @@ PINNED_FPRS=( # release statement, so openssh is on the update path. "7168B983815A5EEF59A4ADFD2A3F414E736060BA" # Damien Miller, OpenSSH - # From https://www.greenwoodsoftware.com/less/pubkey.asc (retrieved - # 2026-09-27), linked from the download page beside each release's .sig. - # DSA-1024 signing with SHA-1: weak, as docs/supply-chain.md says. - "AE27252BD6846E7D6EAE1DD6F153A7C833235259" # Mark Nudelman, less + # https://www.greenwoodsoftware.com/less/pubkey.asc (retrieved 2026-09-27) + "AE27252BD6846E7D6EAE1DD6F153A7C833235259" # Mark Nudelman, less (DSA-1024 with SHA-1: weak) - # From https://www.netfilter.org/files/coreteam-gpg-key-0xD70D1A666ACF2B21.txt - # (retrieved 2026-09-27), the "key" linked beside each release on the - # download pages. https://www.netfilter.org/about.html names it the current - # key, valid until 2028-10-12, and the older keys revoked. + # https://www.netfilter.org/files/coreteam-gpg-key-0xD70D1A666ACF2B21.txt (retrieved 2026-09-27); + # https://www.netfilter.org/about.html names it the current key, valid until 2028-10-12. "8C5F7146A1757A65E2422A94D70D1A666ACF2B21" # Netfilter Core Team, libnftnl and nftables - # From https://cmake.org/download/ (retrieved 2026-09-27): beside each - # release's SHA-256.txt.asc the page names the signer 2D2CEF1034921684 and - # links it to the keyserver's lookup of this primary, whose signing - # subkey that is. + # https://cmake.org/download/ (retrieved 2026-09-27) names its signing subkey 2D2CEF1034921684 "CBA23971357C2E6590D9EFD3EC8FEF3A7BFB4EDA" # Brad King, cmake checksum lists - # libexpat names no release signer. This is the key gentoo.org's WKD serves - # for sping@gentoo.org, and the pin means only that; tools/source-notes.tsv - # carries the undesignated-signer caveat. + # libexpat names no signer: gentoo.org's WKD serves this for sping@gentoo.org (source-notes.tsv) "3176EF7DB2367F1FCA4F306B1F9B0E909AF37285" # Sebastian Pipping, expat ) -# Primary and subkey fingerprints. GOODSIG names the signing subkey, while the -# pins and keys.manifest record primaries. +# Primaries and subkeys: GOODSIG names a subkey, while pins and keys.manifest hold primaries. key_fingerprints() { gpg --batch --with-colons --fingerprint --fingerprint "$1" 2>/dev/null \ | awk -F: '$1=="fpr"{print $10}' @@ -317,8 +286,7 @@ key_is_pinned() { _key_in "$1" "${PINNED_FPRS[@]}"; } # --- published key provenance ---------------------------------------------- -# Per key, where a publisher states its fingerprint by a route independent of -# the signature. Tool data, not tree data, so it is found via BASH_SOURCE. +# Where publishers state key fingerprints; tool data, not tree data, so found via BASH_SOURCE. KEY_PROVENANCE="${KRYPTIK_SIGCHECK_PROVENANCE:-$(dirname "${BASH_SOURCE[0]}")/key-provenance.tsv}" declare -a PROV_FPR=() PROV_KIND=() PROV_LOC=() PROV_SIGNS=() @@ -356,8 +324,7 @@ load_key_provenance() { else why="a github locator must be https://github.com/.gpg" fi - # Without the release-author tie the row says only - # "GitHub hosts this key". + # Without the release-author tie the row says only "GitHub hosts this key". [[ "$rest" == *published\ by* ]] \ || why="a github row must record which account published the release" ;; @@ -399,11 +366,8 @@ primaries_in() { | awk -F: '$1 == "pub" { p = 1; next } p && $1 == "fpr" { print toupper($10); p = 0 }' || true } -# anchored_import FPR FILE: merge the key FPR from FILE into the keyring, with -# the revocations, subkeys and signatures FILE carries for it, and nothing else -# FILE holds. A keyring of its own picks it out, and the export is checked to -# be that key alone. Prints FILE's primary fingerprints. Returns 1 when FILE -# lacks FPR, and 2 when FPR is there but cannot be taken alone. +# anchored_import FPR FILE: merge key FPR alone from FILE and print FILE's primary fingerprints; +# returns 1 when FILE lacks FPR, and 2 when FPR is there but cannot be taken alone. anchored_import() { local fpr="$1" file="$2" home found rc=1 found="$(primaries_in "$file" | tr '\n' ' ')" @@ -524,11 +488,7 @@ has_provenance_row() { load_key_provenance -# check_sig [how]: classify gpg's status output. -# An empty datafile checks a signed message, which carries its own data. -# EXPKEYSIG counts as verified: the signature is valid and only the keyring's -# copy of the key has expired (maintainers extend expiry; the keyring lags). -# how, when given, ends each report detail. +# check_sig NAME SIG DATA [HOW]: classify gpg's status; an empty DATA checks a signed message. check_sig() { local name="$1" sigfile="$2" datafile="$3" how="${4:+; $4}" local out signer keyid @@ -549,6 +509,7 @@ check_sig() { out="$(gpg --batch --status-fd 1 --verify "${signed[@]}" 2>/dev/null || true)" + # EXPKEYSIG counts: the signature is valid and only the keyring's copy of the key has expired. if printf '%s' "$out" | grep -qE "^\[GNUPG:\] (GOODSIG|EXPKEYSIG)"; then local kind kind="$(printf '%s' "$out" | sed -n 's/^\[GNUPG:\] \(GOODSIG\|EXPKEYSIG\) .*/\1/p' | head -1)" @@ -617,9 +578,7 @@ check_sig() { if printf '%s' "$out" | grep -q "^\[GNUPG:\] NO_PUBKEY"; then keyid="$(printf '%s' "$out" | sed -n 's/^\[GNUPG:\] NO_PUBKEY //p' | head -1)" - # The key the signature names is circular trust: it proves only who - # signed. So it goes to keys.manifest for an out-of-band audit and is - # never counted as verified. + # A key the signature names proves only who signed: recorded for audit, never verified. if [[ "$FETCH_UNKNOWN" -eq 1 ]]; then if recv_key "$keyid"; then out="$(gpg --batch --status-fd 1 --verify "${signed[@]}" 2>/dev/null || true)" @@ -694,7 +653,6 @@ verify_gnu() { check_sig "$name" "$sig" "${KRYPTIK_SOURCES}/${file}" || true } -# A suffix is not a format: python.org's .sig is Sigstore, its .asc OpenPGP. # A signed message carries its data; a detached signature does not. is_signed_message() { local packets @@ -702,13 +660,13 @@ is_signed_message() { grep -q ':literal data packet:' <<< "$packets" } +# A suffix is not a format: python.org's .sig is Sigstore, its .asc OpenPGP. is_pgp_signature() { [[ -s "$1" ]] || return 1 gpg --batch --list-packets "$1" 2>/dev/null | grep -q ':signature packet:' } -# Try .sig, .asc and .sign; one that is not OpenPGP passes the turn on. The -# report names the suffix found, which the manifest can then declare. +# Try .sig, .asc and .sign, skipping any that is not OpenPGP; the report names the suffix found. verify_any() { local name="$1" url="$2" file="$3" local suffix sig @@ -739,8 +697,7 @@ verify_any() { report "$name" no-signature-upstream "none of .sig/.asc/.sign is published" } -# verify_detached [how]: the detached signature at -# sigurl over the file data, cached under its own name. +# verify_detached NAME DATA SIGURL [HOW]: the signature at SIGURL over DATA, cached under its name. verify_detached() { local name="$1" data="$2" sigurl="$3" how="${4:-}" local sig="${SIGDIR}/${sigurl##*/}" suffix=".${sigurl##*.}" @@ -752,8 +709,7 @@ verify_detached() { report "$name" no-signature-upstream "no ${suffix} published beside the tarball" return fi - # A host can answer a busy runner with a page in place of the file: what - # came back is not kept, and the file is asked for once more. + # A busy host can answer with a page instead of the file: drop it and ask once more. if ! is_pgp_signature "$sig"; then rm -f "$sig" [[ "${KRYPTIK_SIGCHECK_SELFTEST:-0}" == "1" ]] || sleep 5 @@ -766,8 +722,7 @@ verify_detached() { report "$name" signature-not-openpgp "published ${suffix} is not an OpenPGP signature" return fi - # A signed message carries its data: it vouches for this data only if - # what it carries is exactly this data. + # A signed message vouches for DATA only if it carries DATA byte for byte. if is_signed_message "$sig"; then local carried="${sig}.carried" gpg --batch --quiet --yes --output "$carried" --decrypt "$sig" >/dev/null 2>&1 || true @@ -785,8 +740,7 @@ verify_detached() { check_sig "$name" "$sig" "$data" "$how" || true } -# The digest LIST gives FILE: the first field of the line naming it (a -# leading * marks binary mode), or of a list that is one bare digest. +# FILE's digest in LIST: from the line naming it (* marks binary mode), or a lone bare digest. listed_digest() { # listed_digest LIST FILE awk -v f="$2" ' NF >= 2 { n = $NF; sub(/^\*/, "", n); if (n == f) { print tolower($1); hit = 1; exit } } @@ -794,10 +748,7 @@ listed_digest() { # listed_digest LIST FILE END { if (!hit && NR == 1 && bare != "") print bare }' "$1" } -# verify_sums : a detached signature beside -# the file over a checksum list, named for the signature without its -# suffix. The file must match its digest in the list, and the signature the -# list. +# verify_sums NAME URL FILE SIG: FILE must match the list SIG signs (named SIG minus its suffix). verify_sums() { local name="$1" url="$2" file="$3" signame="$4" local listname="${signame%.*}" @@ -825,8 +776,8 @@ verify_sums() { verify_detached "$name" "$list" "${url%/*}/${signame}" "signs ${listname}" } -# kernel.org signs the uncompressed tar (.tar.sign), for the kernel and -# for util-linux, kbd, kmod, iproute2, libcap and e2fsprogs. +# kernel.org signs the uncompressed tar (.tar.sign): the kernel, util-linux, kbd, kmod, +# iproute2, libcap and e2fsprogs. verify_kernel() { local name="$1" url="$2" file="$3" local sign="${SIGDIR}/${file%.xz}.sign" @@ -976,8 +927,7 @@ if [[ "$NOTED" -gt 0 ]]; then warn "${NOTED} source(s) signed by a key no publisher states, accepted by note (${NOTES#"$KRYPTIK_ROOT"/}):" printf ' - %s\n' "${NOTED_LIST[@]}" fi -# A note that no unheld key needed: the key is held now, or the source went. -# A signature that could not be checked this run tried no note. +# An unused note is stale, unless its source is outside the manifest or went unchecked this run. untried_this_run() { # untried_this_run NAME local u for u in "${UNVERIFIABLE_LIST[@]}"; do [[ "$u" == "$1 ("* ]] && return 0; done