diff --git a/.github/workflows/distro.yml b/.github/workflows/distro.yml index e2b79b86..6db2e27c 100644 --- a/.github/workflows/distro.yml +++ b/.github/workflows/distro.yml @@ -18,6 +18,12 @@ on: - '*.md' - '.github/workflows/ci.yml' workflow_dispatch: + inputs: + role: + description: "Build and test the release as a development build, or as a production one signed with a throwaway key medium: the release flow, rehearsed" + type: choice + options: [development, production] + default: development # Said in the reviewed file rather than left to a repository setting. The cache # and the artifacts use the runner's own credential, not this token. @@ -334,19 +340,41 @@ jobs: # images-production (tools/production-pair.sh). Then release A, and B = # A's version with .1 (sorts after A), which the update test applies over # A: the suites take the release built last as the one under test. A - # tag's run builds the tag's version as B, over a 0.0.0 A. + # tag's run builds the tag's version as B, over a 0.0.0 A. A production + # version (v1 and up, or the role asked for) is built as a release is, + # signed with a throwaway key medium that lives only while the media are + # built: the release flow rehearsed, and no job publishes its export. - name: Stage 06, the media (production 1.0.0 and 1.0.1, release A and the update B) timeout-minutes: 90 run: | make production-pair KRYPTIK_KRYPTIKD_BIN=/mnt/kryptik/kryptikd-musl KRYPTIK_WLPROXY_BIN=/mnt/kryptik/kryptik-wlproxy-musl ls -la "$KRYPTIK_WORK/images-production" + ROLE=development + if [ "${{ inputs.role }}" = production ]; then ROLE=production; fi if [ "$GITHUB_REF_TYPE" = tag ]; then VER_A=0.0.0; VER_B="${GITHUB_REF_NAME#v}" + case "$GITHUB_REF_NAME" in v0.*) ;; *) ROLE=production ;; esac + elif [ "$ROLE" = production ]; then + VER_A=0.0.0; VER_B=0.0.1 else VER_A="0.1.$(date -u +%Y%m%d).${GITHUB_SHA::8}"; VER_B="${VER_A}.1" fi - make media KRYPTIK_VERSION="$VER_A" KRYPTIK_KRYPTIKD_BIN=/mnt/kryptik/kryptikd-musl KRYPTIK_WLPROXY_BIN=/mnt/kryptik/kryptik-wlproxy-musl - make media KRYPTIK_VERSION="$VER_B" KRYPTIK_KRYPTIKD_BIN=/mnt/kryptik/kryptikd-musl KRYPTIK_WLPROXY_BIN=/mnt/kryptik/kryptik-wlproxy-musl + echo "a ${ROLE} release: ${VER_B}, updating from ${VER_A}" + KEYS=() + if [ "$ROLE" = production ]; then + tools/throwaway-key-medium.sh /mnt/kryptik/medium + KEYS=(KRYPTIK_ROLE=production KRYPTIK_KEYS=/mnt/kryptik/medium) + fi + make media KRYPTIK_VERSION="$VER_A" "${KEYS[@]}" KRYPTIK_KRYPTIKD_BIN=/mnt/kryptik/kryptikd-musl KRYPTIK_WLPROXY_BIN=/mnt/kryptik/kryptik-wlproxy-musl + make media KRYPTIK_VERSION="$VER_B" "${KEYS[@]}" KRYPTIK_KRYPTIKD_BIN=/mnt/kryptik/kryptikd-musl KRYPTIK_WLPROXY_BIN=/mnt/kryptik/kryptik-wlproxy-musl + if [ "$ROLE" = production ]; then + # The suites sign their control disks with the medium's own key, + # from a copy, as a release's acceptance does; the rest of the + # medium is gone before anything is packed. + sudo mkdir -p "$KRYPTIK_WORK/keys/medium" + sudo cp /mnt/kryptik/medium/kryptik-testctl /mnt/kryptik/medium/kryptik-testctl.pub "$KRYPTIK_WORK/keys/medium/" + rm -rf /mnt/kryptik/medium + fi ls -la "$KRYPTIK_WORK/images" | grep -E 'usb.img|\.iso|payload' # Never the private halves of the keys: the acceptance needs the @@ -443,7 +471,9 @@ jobs: SUITES: ${{ matrix.suites }} run: | rc=0 - sudo env KRYPTIK_WORK="$KRYPTIK_WORK" KRYPTIK_SOURCES="$KRYPTIK_SOURCES" NO_COLOR=1 \ + # A release's media honour control disks signed by their medium's key: its copy, when there is one. + k="$KRYPTIK_WORK/keys/medium/kryptik-testctl"; [ -f "$k" ] || k="" + sudo env KRYPTIK_WORK="$KRYPTIK_WORK" KRYPTIK_SOURCES="$KRYPTIK_SOURCES" NO_COLOR=1 KRYPTIK_TESTCTL_KEY="$k" \ TMPDIR="$TMPDIR" CARGO_TARGET_DIR="$CARGO_TARGET_DIR" tools/acceptance.sh --only "$SUITES" || rc=$? [ "$rc" -eq 0 ] || [ "$rc" -eq 2 ] @@ -537,13 +567,16 @@ jobs: echo "make acceptance did not pass; see the acceptance-report artifact" exit 1 - # A tag's run ends on the Releases page: the tested export, the - # corresponding source and the acceptance logs, as a draft to publish after - # a look (docs/releases.md). The one job that writes to the repository. + # A development release's tag (v0.x) ends on the Releases page: the tested + # export, the corresponding source and the acceptance logs, as a draft to + # publish after a look (docs/releases.md). A production release is built by + # hand with the key medium and published with the same tool; its tag's run + # rehearsed that with a throwaway medium, and nothing signed by one goes on + # the page. The one job that writes to the repository. release: name: Draft the release needs: [verdict] - if: ${{ !cancelled() && github.ref_type == 'tag' && needs.verdict.result == 'success' }} + if: ${{ !cancelled() && github.ref_type == 'tag' && startsWith(github.ref_name, 'v0.') && needs.verdict.result == 'success' }} runs-on: ubuntu-24.04 timeout-minutes: 120 permissions: diff --git a/Makefile b/Makefile index f04bae56..99c4d08e 100644 --- a/Makefile +++ b/Makefile @@ -72,6 +72,7 @@ CHROOT_ENV := KRYPTIK_ROOT="$(ROOT)" \ TERM="$(TERM)" \ NO_COLOR="$(NO_COLOR)" \ $(if $(CARGO_TARGET_DIR),CARGO_TARGET_DIR="$(CARGO_TARGET_DIR)") \ + $(if $(KRYPTIK_TESTCTL_KEY),KRYPTIK_TESTCTL_KEY="$(KRYPTIK_TESTCTL_KEY)") \ $(if $(TMPDIR),TMPDIR="$(TMPDIR)") CHROOT_RUN := $(SUDO) env $(CHROOT_ENV) "$(CHROOTD)" diff --git a/build/lib/release-keys.sh b/build/lib/release-keys.sh index 5d4bc93e..40fdb247 100644 --- a/build/lib/release-keys.sh +++ b/build/lib/release-keys.sh @@ -34,18 +34,20 @@ release_version() { # release_version ROLE VERSION dev_keys() { local rel="${KRYPTIK_WORK}/keys/release" sb="${KRYPTIK_WORK}/keys/sb" k mkdir -p "$rel" "$sb"; chmod 0700 "$rel" "$sb" - for k in kryptik-release kryptik-latest; do + for k in kryptik-release kryptik-latest kryptik-testctl; do if [[ ! -f "${rel}/${k}" ]]; then ssh-keygen -q -t ed25519 -N "" -C "${k} (developer)" -f "${rel}/${k}" < /dev/null echo "generated a new developer key: ${k}" fi done # Each key honoured in its own namespaces only: the release key's for - # manifests and the media's checksums, and the statement key's, kept at hand - # for re-signing the channel's statement, for that alone. + # manifests and the media's checksums; the statement key's, kept at hand + # for re-signing the channel's statement, for that alone; and the + # control-disk key's, which arms an unattended install and nothing else. { printf 'kryptik-release namespaces="kryptik-release,kryptik-media" %s\n' "$(cut -d' ' -f1,2 "${rel}/kryptik-release.pub")" printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 "${rel}/kryptik-latest.pub")" + printf 'kryptik-testctl namespaces="kryptik-testctl" %s\n' "$(cut -d' ' -f1,2 "${rel}/kryptik-testctl.pub")" } > "${rel}/release-signers" if [[ ! -f "${sb}/kryptik-sb.key" ]]; then openssl req -new -x509 -newkey rsa:3072 -nodes -days 3650 -sha256 \ @@ -69,20 +71,25 @@ EOF # A probe signed by each developer key and by a foreign one, in each namespace, # verified through the anchor as its readers verify: only the release key, in -# kryptik-release and kryptik-media, and the statement key, in kryptik-latest, -# may pass. The production keys sign nothing but what they are for, so this -# runs for development alone. +# kryptik-release and kryptik-media, the statement key, in kryptik-latest, and +# the control-disk key, in kryptik-testctl, may pass. The production keys sign +# nothing but what they are for, so this runs for development alone. probe_anchor() { - local t key ns who want got + local t key ns who want got tkey + tkey="$(dirname "$RELEASE_KEY")/kryptik-testctl" t="$(mktemp -d)" ssh-keygen -q -t ed25519 -N "" -f "$t/foreign" < /dev/null printf 'probe\n' > "$t/probe" - for key in "$RELEASE_KEY" "$LATEST_KEY" "$t/foreign"; do - for ns in kryptik-release kryptik-media kryptik-latest; do - who=kryptik-release; [[ "$ns" == kryptik-latest ]] && who=kryptik-latest + for key in "$RELEASE_KEY" "$LATEST_KEY" "$tkey" "$t/foreign"; do + for ns in kryptik-release kryptik-media kryptik-latest kryptik-testctl; do + case "$ns" in + kryptik-latest) who=kryptik-latest ;; + kryptik-testctl) who=kryptik-testctl ;; + *) who=kryptik-release ;; + esac want=refused case "$key:$ns" in - "$RELEASE_KEY:kryptik-release"|"$RELEASE_KEY:kryptik-media"|"$LATEST_KEY:kryptik-latest") want=accepted ;; + "$RELEASE_KEY:kryptik-release"|"$RELEASE_KEY:kryptik-media"|"$LATEST_KEY:kryptik-latest"|"$tkey:kryptik-testctl") want=accepted ;; esac rm -f "$t/probe.sig" # A refusal counts only when there was a signature to refuse. @@ -115,10 +122,13 @@ medium_keys() { if [[ -e "${m}/kryptik-latest" && ! -f "${m}/kryptik-latest.pub" ]]; then die "the key medium ${m} has kryptik-latest but no kryptik-latest.pub" fi - for f in release-signers kryptik-release kryptik-release.pub kryptik-latest kryptik-latest.pub kryptik-sb.key kryptik-sb.crt; do + if [[ -e "${m}/kryptik-testctl" && ! -f "${m}/kryptik-testctl.pub" ]]; then + die "the key medium ${m} has kryptik-testctl but no kryptik-testctl.pub" + fi + for f in release-signers kryptik-release kryptik-release.pub kryptik-latest kryptik-latest.pub kryptik-testctl kryptik-testctl.pub kryptik-sb.key kryptik-sb.crt; do [[ ! -L "${m}/${f}" ]] || die "${m}/${f} is a symlink: the medium holds its files itself" done - for f in kryptik-release kryptik-latest kryptik-sb.key; do + for f in kryptik-release kryptik-latest kryptik-testctl kryptik-sb.key; do if [[ -e "${m}/${f}" ]]; then private_ok "${m}/${f}"; fi done anchor_ok "${m}/release-signers" @@ -126,6 +136,9 @@ medium_keys() { if [[ -e "${m}/kryptik-latest" ]]; then pub_in_anchor "${m}/kryptik-latest.pub" kryptik-latest "${m}/release-signers" fi + if [[ -e "${m}/kryptik-testctl" ]]; then + pub_in_anchor "${m}/kryptik-testctl.pub" kryptik-testctl "${m}/release-signers" + fi openssl x509 -in "${m}/kryptik-sb.crt" -noout -checkend 0 > /dev/null 2>&1 \ || die "${m}/kryptik-sb.crt is not a certificate in force" ANCHOR="${m}/release-signers"; RELEASE_KEY="${m}/kryptik-release"; LATEST_KEY="" @@ -144,8 +157,10 @@ private_ok() { } # kryptik-release, held to kryptik-release (manifests) and kryptik-media (the -# media's checksums), and kryptik-latest, held to kryptik-latest alone, with -# Ed25519 keys (a security key's included). A principal may be listed more +# media's checksums), kryptik-latest, held to kryptik-latest alone, and +# kryptik-testctl, if listed, held to kryptik-testctl alone (the control disk +# that arms an unattended install), with Ed25519 keys (a security key's +# included). A principal may be listed more # than once, as the old key and its replacement are while a key is changed, # but a key only once: one that could sign both releases and statements would # undo the split. @@ -156,7 +171,8 @@ anchor_ok() { case "$p" in kryptik-release) want='namespaces="kryptik-release,kryptik-media"' ;; kryptik-latest) want='namespaces="kryptik-latest"' ;; - *) die "${a}: ${p} is neither kryptik-release nor kryptik-latest" ;; + kryptik-testctl) want='namespaces="kryptik-testctl"' ;; + *) die "${a}: ${p} is neither kryptik-release, kryptik-latest nor kryptik-testctl" ;; esac [[ "$ns" == "$want" ]] || die "${a}: ${p} is not held to its own namespaces (${want})" case "$t" in ssh-ed25519|sk-ssh-ed25519@openssh.com) ;; *) die "${a}: ${p}'s key is ${t}, not Ed25519" ;; esac diff --git a/build/service-scripts/testctl.sh b/build/service-scripts/testctl.sh index 6489667d..eb739a18 100755 --- a/build/service-scripts/testctl.sh +++ b/build/service-scripts/testctl.sh @@ -2,7 +2,8 @@ # Test control for install media, sourced by boot-time services. A disk # labelled kryptik-testctl carries a key=value file, read only when booted from # an install medium (kryptik.media=), so such a disk cannot reinstall or shut -# down an installed system. +# down an installed system, and only when the kryptik-testctl key the medium's +# anchor lists signed it, so no one else's disk can arm an install either. # testctl_load 0, with TESTCTL_FILE set, when a control file was read # testctl_get KEY the value, or empty # Keys: install_target=/dev/vdb smoke_poweroff=1 preseed_user=NAME @@ -11,6 +12,16 @@ TESTCTL_MNT=/run/kryptik/testctl TESTCTL_FILE="" +TESTCTL_ANCHOR="${TESTCTL_ANCHOR:-/usr/share/kryptik/trust/release-signers}" + +# The file and a signature over it by the kryptik-testctl key the anchor +# lists, in that key's own namespace: the holder of that key alone can arm an +# install on a machine that boots this medium. +testctl_signed() { # testctl_signed FILE + [ -r "$1" ] && [ -r "$1.sig" ] || return 1 + ssh-keygen -Y verify -f "$TESTCTL_ANCHOR" -I kryptik-testctl -n kryptik-testctl \ + -s "$1.sig" < "$1" > /dev/null 2>&1 +} testctl_media() { grep -qs '^media=.\+' /run/kryptik/boot-identity 2>/dev/null && return 0 @@ -27,6 +38,11 @@ testctl_load() { mount -o ro,nosuid,nodev,noexec "$dev" "$TESTCTL_MNT" 2>/dev/null || return 1 fi [ -r "$TESTCTL_MNT/kryptik-test.conf" ] || return 1 + if ! testctl_signed "$TESTCTL_MNT/kryptik-test.conf"; then + echo "testctl: the control file on ${dev} is not signed by this medium's kryptik-testctl key; ignored" + umount "$TESTCTL_MNT" 2>/dev/null + return 1 + fi TESTCTL_FILE="$TESTCTL_MNT/kryptik-test.conf" echo "testctl: control file read from ${dev} (install medium only)" return 0 diff --git a/docs/building.md b/docs/building.md index 6abbafd0..85e75a6c 100644 --- a/docs/building.md +++ b/docs/building.md @@ -66,7 +66,9 @@ make media KRYPTIK_ROLE=production KRYPTIK_KEYS=/media/ KRYPTIK_VERSION= The medium holds `release-signers` (the anchor the image will trust), `kryptik-release` and `kryptik-release.pub`, `kryptik-sb.key` and -`kryptik-sb.crt`, and optionally `kryptik-latest` and `kryptik-latest.pub`. +`kryptik-sb.crt`, and optionally `kryptik-latest` and `kryptik-latest.pub` +and `kryptik-testctl` and `kryptik-testctl.pub`, which the build reads only +to check they are the anchor's. Its private keys must be readable by their owner alone, who is root or the user running the build, and it must not be inside the work or output tree. Stage 06 checks all of that before it signs anything. It makes no key and @@ -122,3 +124,13 @@ make acceptance EXPORT=/tmp/kryptik-export `tools/release-publish.sh /tmp/kryptik-export` puts that export on the repository's Releases page once tag `v0.1.2` is pushed ([releases](releases.md)). + +The suites arm their unattended installs with a control disk the medium +under test checks against its anchor. A development medium honours the +build's own `kryptik-testctl` (under `/keys/release`), the default; a +production release's media honour the ceremony's, so its acceptance names a +copy: `make acceptance EXPORT=DIR KRYPTIK_TESTCTL_KEY=/kryptik-testctl` +([release keys](release-keys.md)). The boot tests enrol the Secure Boot +certificate the medium under test carries, and the export publishes that +certificate: a production release is tested as a machine that enrolled its +certificate boots it. diff --git a/docs/release-keys.md b/docs/release-keys.md index dbe43b85..f2b9760c 100644 --- a/docs/release-keys.md +++ b/docs/release-keys.md @@ -11,14 +11,16 @@ makes these keys and never keeps a copy (`build/lib/release-keys.sh`). | `kryptik-release` (Ed25519) | every release's manifest, and the checksums of its install media | offline, on the key medium | the thief can sign a release that every machine installs | | `kryptik-latest` (Ed25519) | the channel's "this release is current" statement, daily | on the release host, for its timer | machines can be held on an old release; nothing can be installed with it | | `kryptik-sb` (RSA, X.509) | the kernels, for Secure Boot | offline, on the key medium | the thief can sign kernels that machines which enrolled it will boot | +| `kryptik-testctl` (Ed25519) | a control disk that arms an unattended install or recovery on a machine booting your medium (the suites' installs, and your own) | on the key medium, and on the build machine for `make acceptance` | the thief can wipe a disk on a machine they boot your medium on with a control disk attached; nothing can be signed or installed with it | | the module key | the kernel's modules | nowhere: each kernel build makes one and throws it away | nothing to steal | -An image trusts the two Ed25519 keys through its anchor, +An image trusts the three Ed25519 keys through its anchor, `/usr/share/kryptik/trust/release-signers`. Each key is listed under its own name and held to its own namespaces: the release key to `kryptik-release` for manifests and `kryptik-media` for the media's checksums, the statement key to -`kryptik-latest`. So a statement key cannot sign a release or its media, and -no signature passes for one of another kind. A machine trusts the Secure Boot +`kryptik-latest`, the control-disk key to `kryptik-testctl`. So a statement +key cannot sign a release or its media, a control-disk key can arm nothing +but an install, and no signature passes for one of another kind. A machine trusts the Secure Boot key once its certificate is enrolled in the machine's firmware. ## Making them @@ -31,18 +33,24 @@ media, the key medium and its backup, ideally encrypted. mkdir kryptik-keys && cd kryptik-keys ssh-keygen -t ed25519 -C kryptik-release -f kryptik-release # set a passphrase ssh-keygen -t ed25519 -C kryptik-latest -f kryptik-latest # set a passphrase +ssh-keygen -t ed25519 -C kryptik-testctl -f kryptik-testctl # no passphrase: the suites sign with it unattended { printf 'kryptik-release namespaces="kryptik-release,kryptik-media" %s\n' "$(cut -d' ' -f1,2 kryptik-release.pub)" printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 kryptik-latest.pub)" + printf 'kryptik-testctl namespaces="kryptik-testctl" %s\n' "$(cut -d' ' -f1,2 kryptik-testctl.pub)" } > release-signers openssl req -new -x509 -newkey rsa:3072 -sha256 -days 3650 \ -subj "/CN=Kryptik Secure Boot/" -keyout kryptik-sb.key -out kryptik-sb.crt # set a passphrase -chmod 600 kryptik-release kryptik-latest kryptik-sb.key +chmod 600 kryptik-release kryptik-latest kryptik-testctl kryptik-sb.key ``` The backup gets the whole directory. The key medium gets everything except `kryptik-latest` and `kryptik-latest.pub`: the build does not need them. The statement key goes to the release host, and nowhere else that is online. +`kryptik-testctl` may also be copied to the build machine, where +`make acceptance` signs the control disks that install the media under test +(`KRYPTIK_TESTCTL_KEY` names it); it arms installs on machines that boot your +medium and signs nothing else. The build takes the key medium as it is. It refuses a private key anyone but its owner can read, a key owned by anyone but root or the user running the @@ -60,15 +68,20 @@ For each release: 1. Build up to the kernel on the build machine as usual (`make kernel`), in an empty work directory (a new `KRYPTIK_WORK`): a tree resumed from an earlier build can still hold files an older recipe installed (a known gap - in [status](status.md)). No key is needed for that, and none is ever - inside the chroot. -2. Attach the key medium and make the media. `sbsign` asks for the Secure - Boot key's passphrase once for each kernel it signs (three: both slots and - the USB medium's), and `ssh-keygen` for the release key's twice, for the - media's checksums and for the manifest. A release key on a security key - asks to be touched instead, each time: + in [status](status.md)). Then the throwaway pair the acceptance's + production suite updates across (`make production-pair`). No key of yours + is needed for either, and none is ever inside the chroot. +2. Attach the key medium and make the media: first a release for the update + suite to update from, numbered `0.0.0`, then the release. `sbsign` asks + for the Secure Boot key's passphrase once for each kernel it signs (three + per build: both slots and the USB medium's), and `ssh-keygen` for the + release key's twice per build, for the media's checksums and for the + manifest. A release key on a security key asks to be touched instead, + each time: ```sh + make media KRYPTIK_ROLE=production KRYPTIK_KEYS=/media//kryptik-keys \ + KRYPTIK_VERSION=0.0.0 KRYPTIK_CHANNEL=https://// make media KRYPTIK_ROLE=production KRYPTIK_KEYS=/media//kryptik-keys \ KRYPTIK_VERSION= KRYPTIK_CHANNEL=https://// ``` @@ -78,14 +91,29 @@ For each release: `kryptik-.SHA256SUMS` and its `.sig` beside the media, and the release record is under `KRYPTIK_OUT`. Publish the checksums and `release-signers` with the media: they are what a download is checked by. -4. Tag the revision you built from `v` and push the tag. The next - release's notes list what changed since it. -5. Put the release on the repository's Releases page from the export - `make acceptance EXPORT=DIR` wrote: `tools/release-publish.sh DIR - --source-bundle FILE --publish` ([releases](releases.md)). The payload - goes up with it, under the names its manifest gives: that page is the - channel's base. -6. Publish it into the channel: the release host is the repository's Pages +4. Test the media. The suites enrol the Secure Boot certificate the media + carry, install them with control disks signed by the medium's + `kryptik-testctl`, from its copy, and update the `0.0.0` release to + yours; the export they write is the release as the page publishes it: + + ```sh + make acceptance EXPORT=DIR KRYPTIK_TESTCTL_KEY=/kryptik-testctl + ``` + +5. Tag the revision you built from `v` and push the tag. Its run + builds and tests that revision the same way, signed with a throwaway key + medium, and drafts nothing from that. The next release's notes list what + changed since the tag. +6. Put the release on the repository's Releases page from the export: + + ```sh + tools/release-publish.sh DIR --source-bundle FILE --publish + ``` + + ([releases](releases.md); `make source-bundle` writes the bundle under + `KRYPTIK_OUT`). The payload goes up with it, under the names its manifest + gives: that page is the channel's base. +7. Publish it into the channel: the release host is the repository's Pages site and the `Update channel` workflow ([update channel](design/update-channel.md#the-release-host)). Once, put the statement key in the `KRYPTIK_LATEST_KEY` repository secret, made diff --git a/docs/releases.md b/docs/releases.md index d6977cd5..3b2e55d0 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -39,16 +39,19 @@ the release key made offline. The dated builds CI makes of every push to main --no-held`), then builds that version from nothing, with no cached tree, so the release holds only what a clean build makes: about three hours. It builds it as the release under test, over a `0.0.0` build the update - suite updates from, runs every suite on the images, and its last job - drafts the release: the export, the corresponding source - (`make source-bundle`) and the acceptance logs, with the release notes as - the page's text. Read the draft and publish it from the page. -4. A production release is built by hand with the key medium and its export - published with the same tool once the tag is pushed: + suite updates from, and runs every suite on the images. For a development + release (`v0.x`) its last job drafts the release: the export, the + corresponding source (`make source-bundle`) and the acceptance logs, with + the release notes as the page's text. Read the draft and publish it from + the page. +4. A production release (`v1.0.0` and up) is built by hand with the key + medium ([release keys](release-keys.md#using-them)). Its tag's run builds + and tests the revision the same way, signed with a throwaway key medium, + and drafts nothing from that: the page takes your export, with the same + tool, once the tag is pushed: `tools/release-publish.sh DIR --source-bundle FILE --publish`. Its payload goes up with it, and the `Update channel` workflow, run with the tag, - makes it what installed machines update to - ([release keys](release-keys.md#using-them)). + makes it what installed machines update to. ## What the page carries diff --git a/docs/user-guide.md b/docs/user-guide.md index e201ab79..4cce8740 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -125,12 +125,16 @@ Remove the medium. The installed disk boots on its own; it does not need the medium again unless it has to be recovered. **Unattended install** (what the VM tests do): a small control disk labelled -`kryptik-testctl`, made with `tools/image/mk-testctl.sh --out FILE -KEY=VALUE...`, carrying `install_target=/dev/vda`, the state passphrase as -`state_passphrase=...` (required), and optionally `preseed_user=`, -`preseed_password_hash=` and `preseed_root_hash=` for the first accounts. An -install medium honours it and reports `KRYPTIK_INSTALL: rc=0` on success; an -installed system ignores it. +`kryptik-testctl`, made with `tools/image/mk-testctl.sh --out FILE --key +KRYPTIK-TESTCTL KEY=VALUE...`, carrying `install_target=/dev/vda`, the state +passphrase as `state_passphrase=...` (required), and optionally +`preseed_user=`, `preseed_password_hash=` and `preseed_root_hash=` for the +first accounts. The file is signed by the release's `kryptik-testctl` key +([release keys](release-keys.md)), and a medium honours only a disk its own +anchor's key signed: anyone else's is named on the console and ignored, so a +disk attached to a machine that boots your medium cannot arm an install. An +install medium then reports `KRYPTIK_INSTALL: rc=0` on success; an installed +system ignores the disk either way. ## 3. First boot and daily use diff --git a/tools/acceptance.sh b/tools/acceptance.sh index 7af7f5df..55b9beaa 100755 --- a/tools/acceptance.sh +++ b/tools/acceptance.sh @@ -111,6 +111,25 @@ fi VER_A=""; [[ -n "$PAYLOAD_A" ]] && VER_A="$(version_of_payload "$PAYLOAD_A")" MEDIA_USB_A=""; [[ -n "$VER_A" && -f "${IMGDIR}/kryptik-${VER_A}-usb.img" ]] && MEDIA_USB_A="${IMGDIR}/kryptik-${VER_A}-usb.img" +# ------------------------------------------------------------- certificate -- +# The Secure Boot certificate the medium under test carries, read out of its +# ESP (/kryptik/kryptik-sb.crt): the one the boot tests enrol and the export +# publishes, whichever key signed the kernels, a development build's own or +# the key medium's. +medium_cert() { # medium_cert USB-IMAGE OUT; 0 when a certificate was read + local start + start="$(sfdisk -d "$1" 2>/dev/null | awk -F'[ ,]+' '$1 ~ /1$/ { for (i = 1; i <= NF; i++) if ($i == "start=") print $(i + 1); exit }')" + [[ -n "$start" ]] || return 1 + if mtype -i "${1}@@$(( start * 512 ))" ::/kryptik/kryptik-sb.crt > "$2" 2>/dev/null \ + && openssl x509 -in "$2" -noout > /dev/null 2>&1; then return 0; fi + rm -f "$2"; return 1 +} +MEDIUM_CERT=""; CERT_NAME="" +if [[ -f "$MEDIA_USB" ]] && medium_cert "$MEDIA_USB" "${OUT}/kryptik-sb.crt"; then + MEDIUM_CERT="${OUT}/kryptik-sb.crt" + CERT_NAME="$(openssl x509 -in "$MEDIUM_CERT" -noout -subject -nameopt multiline | sed -n 's/^ *commonName *= *//p')" +fi + # A production pair, built with a throwaway key medium and kept apart, since # its versions would sort above A and B here: the two highest payloads, the # lower one's USB medium, and the certificate its media carry. @@ -345,7 +364,11 @@ it_artifacts() { "${SELF}/check-artifact-hardening.sh" "$SYSROOT" --strict - it_licences() { "${SELF}/check-image-licences.sh" "$SYSROOT"; } it_kernel_config() { "${SELF}/validate-kernel-config.sh" --boot && "${SELF}/validate-kernel-config.sh" --hardened; } it_support_status() { "${SELF}/check-support-status.sh" --strict; } -it_ovmf_vars() { "${IMG}/ovmf-vars.sh"; } +it_ovmf_vars() { + [[ -n "$MEDIUM_CERT" ]] || { echo "no certificate could be read out of ${MEDIA_USB:-(no medium)}: /kryptik/kryptik-sb.crt on its ESP"; return 1; } + echo "the certificate the medium carries: ${CERT_NAME} ($(openssl x509 -in "$MEDIUM_CERT" -noout -fingerprint -sha256 | cut -d= -f2))" + "${IMG}/ovmf-vars.sh" --cert "$MEDIUM_CERT" +} it_smoke_usb() { "${IMG}/media-smoke.sh" --usb "$MEDIA_USB" --vars clean; } it_smoke_iso() { "${IMG}/media-smoke.sh" --iso "$MEDIA_ISO" --vars clean; } it_smoke_sb() { "${IMG}/media-smoke.sh" --usb "$MEDIA_USB" --vars enrolled; } @@ -355,8 +378,8 @@ it_state() { "${IMG}/state-test.sh" --usb "$MEDIA_USB"; } it_integrity() { "${IMG}/integrity-test.sh" --usb "$MEDIA_USB"; } it_zones() { "${IMG}/zones-test.sh" --usb "$MEDIA_USB"; } it_gui() { "${IMG}/gui-test.sh" --usb "$MEDIA_USB"; } -# Each update suite is handed the other role's newest payload too, which the -# release it installed must refuse. +# Each update suite is handed the other flow's newest payload too, signed by +# keys the release it installed does not trust, which it must refuse. it_update() { local foreign=(); [[ -n "$PROD_B" ]] && foreign=(--foreign "$PROD_B") "${IMG}/update-test.sh" --usb-a "$MEDIA_USB_A" --payload-a "$PAYLOAD_A" --payload-b "$PAYLOAD_B" --vars clean "${foreign[@]}" @@ -365,6 +388,7 @@ it_production() { echo "production pair:" sha256sum "$PROD_USB_A" "${PROD_A}/manifest" "${PROD_B}/manifest" "${PRODDIR}/kryptik-sb.crt" | sed 's/^/ /' "${IMG}/ovmf-vars.sh" --cert "${PRODDIR}/kryptik-sb.crt" --out "${PRODDIR}/vars" || return 1 + KRYPTIK_TESTCTL_KEY="${PRODDIR}/keys/kryptik-testctl" \ "${IMG}/update-test.sh" --usb-a "$PROD_USB_A" --payload-a "$PROD_A" --payload-b "$PROD_B" \ --vars-file "${PRODDIR}/vars/enrolled.fd" --foreign "$PAYLOAD_B" } @@ -498,9 +522,11 @@ it_export() { # B's own root.json: images/root.json is whichever release was built last. if [[ -n "$PAYLOAD_B" && -f "${PAYLOAD_B}/root.json" ]]; then cp "${PAYLOAD_B}/root.json" "${d}/" elif [[ -f "${IMGDIR}/root.json" ]]; then cp "${IMGDIR}/root.json" "${d}/"; fi - for f in "${KRYPTIK_WORK}/keys/sb/kryptik-sb.crt" "${KRYPTIK_WORK}/keys/sb/kryptik-sb.der"; do - if [[ -f "$f" ]]; then cp "$f" "${d}/"; else echo " missing trust material: $f"; ok=1; fi - done + # The certificate the media carry, as they carry it, and in DER form for + # a firmware's enrolment menu. + if [[ -n "$MEDIUM_CERT" ]] && cp "$MEDIUM_CERT" "${d}/kryptik-sb.crt" \ + && openssl x509 -in "$MEDIUM_CERT" -outform DER -out "${d}/kryptik-sb.der" 2>/dev/null; then : + else echo " no certificate read out of ${MEDIA_USB:-(no medium)} to publish"; ok=1; fi # The media's checksums as stage 06 signed them, and the anchor the tested # image carries, read out of B's root image: what a download is checked by. if [[ -n "$VER" && -f "${IMGDIR}/${sums}" && -f "${IMGDIR}/${sums}.sig" ]]; then @@ -584,7 +610,7 @@ if [[ -n "$EXPORT" ]] || wanted release; then echo "iso : $(basename "${MEDIA_ISO:-none}") sha256 ${H_ISO:-none}" echo "firmware : ${FW_PKG} (${FW})" echo "kernel : ${KERNEL_LINE:-not observed}" - echo "trust : kryptik-sb.crt / kryptik-sb.der (the developer Secure Boot key, a test anchor)" + echo "trust : kryptik-sb.crt / kryptik-sb.der (the Secure Boot certificate the media carry: ${CERT_NAME:-unknown})" echo "checksums : kryptik-${VER:-unknown}.SHA256SUMS and .sig, signed by the release key; release-signers checks them" echo "read : INSTRUCTIONS.md$([[ -f "${EXPORT}/RELEASE-NOTES.md" ]] && echo ", RELEASE-NOTES.md")" } > "${EXPORT}/RELEASE.txt" diff --git a/tools/image/install-test.sh b/tools/image/install-test.sh index db3b49a7..6fb22d0c 100755 --- a/tools/image/install-test.sh +++ b/tools/image/install-test.sh @@ -55,7 +55,7 @@ step "step 1: install from the medium onto a blank ${SIZE} disk" if [[ -z "$SIZE" ]]; then SIZE="$("${SELF}/test-disk-size.sh" --medium "$USB")" || die "could not size the test disk from the medium"; fi rm -f "$DISK"; truncate -s "$SIZE" "$DISK" CTL="${VMDIR}/testctl-install.img" -"${SELF}/mk-testctl.sh" --out "$CTL" install_target=/dev/vda smoke_poweroff=1 install_wait=5 \ +"${SELF}/mk-testctl.sh" --out "$CTL" --key "$TESTCTL_KEY" install_target=/dev/vda smoke_poweroff=1 install_wait=5 \ "${PRESEED[@]}" > /dev/null || die "control disk" smoke install-p1 --usb "$USB" --disk "$DISK" --testctl "$CTL" --vars "$VARS" --timeout "$TIMEOUT" qrc=$? @@ -155,7 +155,7 @@ refusal_case() { # refusal_case NAME DISK-SIZE EXTRA-RUN-ARGS... ; expects rc! local name="$1" size="$2"; shift 2 local d="${VMDIR}/refuse-${name}.img"; rm -f "$d"; truncate -s "$size" "$d" local ctl="${VMDIR}/testctl-${name}.img" - "${SELF}/mk-testctl.sh" --out "$ctl" install_target=/dev/vda smoke_poweroff=1 install_wait=5 > /dev/null + "${SELF}/mk-testctl.sh" --out "$ctl" --key "$TESTCTL_KEY" install_target=/dev/vda smoke_poweroff=1 install_wait=5 > /dev/null smoke "refuse-${name}" --usb "$USB" --disk "$d" --testctl "$ctl" --vars "$VARS" --timeout "$TIMEOUT" "$@" > /dev/null local t="${VMDIR}/refuse-${name}.txt"; boot_txt > "$t" want "$t" 'KRYPTIK_INSTALL: BEGIN' "${name}: the installer ran" @@ -190,7 +190,7 @@ fi # The disk this system runs from: the medium, the one USB disk in the guest. # No flag opens it. ctl="${VMDIR}/testctl-medium.img" -"${SELF}/mk-testctl.sh" --out "$ctl" install_target=/dev/sda install_replace=1 smoke_poweroff=1 install_wait=5 > /dev/null +"${SELF}/mk-testctl.sh" --out "$ctl" --key "$TESTCTL_KEY" install_target=/dev/sda install_replace=1 smoke_poweroff=1 install_wait=5 > /dev/null d="${VMDIR}/refuse-medium.img"; rm -f "$d"; truncate -s "$SIZE" "$d" smoke refuse-medium --usb "$USB" --disk "$d" --testctl "$ctl" --vars "$VARS" --timeout "$TIMEOUT" > /dev/null t="${VMDIR}/refuse-medium.txt"; boot_txt > "$t" @@ -198,6 +198,18 @@ want "$t" 'KRYPTIK_INSTALL: BEGIN target=/dev/sda' "medium: want "$t" 'KRYPTIK_INSTALL: .*is the disk this system is running from' "medium: refused as the disk this system runs from, even with --replace-kryptik" deny "$t" 'KRYPTIK_INSTALL: rc=0' "medium: never reported success" +# A control disk signed by some other key: the medium honours the +# kryptik-testctl key its anchor lists and no other, so nothing is armed. The +# disk's poweroff is ignored with the rest, so this boot runs to its timeout. +ssh-keygen -q -t ed25519 -N '' -C stranger -f "${VMDIR}/stranger-key" < /dev/null +ctl="${VMDIR}/testctl-stranger.img" +"${SELF}/mk-testctl.sh" --out "$ctl" --key "${VMDIR}/stranger-key" install_target=/dev/vda smoke_poweroff=1 install_wait=5 > /dev/null +d="${VMDIR}/refuse-stranger.img"; rm -f "$d"; truncate -s "$SIZE" "$d" +smoke refuse-stranger --usb "$USB" --disk "$d" --testctl "$ctl" --vars "$VARS" --timeout 120 > /dev/null || true +t="${VMDIR}/refuse-stranger.txt"; boot_txt > "$t" +want "$t" 'testctl: the control file on .* is not signed by this medium' "stranger: a control disk signed by another key is named and ignored" +deny "$t" 'KRYPTIK_INSTALL: BEGIN' "stranger: no install began" + # the LUKS UUID of IMG's kryptik-state partition, read from the host state_uuid() { local start @@ -208,7 +220,7 @@ state_uuid() { # An old installation: refused without the flag, and left exactly as it was. old="${VMDIR}/old-install.img"; rm -f "$old"; cp --sparse=always "$DISK" "$old" ctl="${VMDIR}/testctl-oldinstall.img" -"${SELF}/mk-testctl.sh" --out "$ctl" install_target=/dev/vda smoke_poweroff=1 install_wait=5 > /dev/null +"${SELF}/mk-testctl.sh" --out "$ctl" --key "$TESTCTL_KEY" install_target=/dev/vda smoke_poweroff=1 install_wait=5 > /dev/null smoke refuse-oldinstall --usb "$USB" --disk "$old" --testctl "$ctl" --vars "$VARS" --timeout "$TIMEOUT" > /dev/null t="${VMDIR}/refuse-oldinstall.txt"; boot_txt > "$t" want "$t" 'KRYPTIK_INSTALL: .*holds a Kryptik installation or medium' "oldinstall: refused without --replace-kryptik, and says why" @@ -223,7 +235,7 @@ fi if [[ "$QUICK" -eq 0 ]]; then before="$(state_uuid "$old")" ctl="${VMDIR}/testctl-replace.img" - "${SELF}/mk-testctl.sh" --out "$ctl" install_target=/dev/vda install_replace=1 smoke_poweroff=1 install_wait=5 \ + "${SELF}/mk-testctl.sh" --out "$ctl" --key "$TESTCTL_KEY" install_target=/dev/vda install_replace=1 smoke_poweroff=1 install_wait=5 \ "${PRESEED[@]}" > /dev/null smoke replace-oldinstall --usb "$USB" --disk "$old" --testctl "$ctl" --vars "$VARS" --timeout "$TIMEOUT" > /dev/null t="${VMDIR}/replace-oldinstall.txt"; boot_txt > "$t" diff --git a/tools/image/integrity-test.sh b/tools/image/integrity-test.sh index aa755d0b..6ff368e2 100755 --- a/tools/image/integrity-test.sh +++ b/tools/image/integrity-test.sh @@ -83,11 +83,14 @@ dd if="$DISK" of="$ESPIMG" bs=1M iflag=skip_bytes,count_bytes skip="$ESP_OFF" co cp "$ESPIMG" "${ESPIMG}.pristine" TMPK="$(mktemp -d)" mcopy -i "$ESPIMG" ::/EFI/BOOT/BOOTX64.EFI "$TMPK/good.efi" +# The certificate the medium carries, which signed the kernel it installed. +mtype -i "${USB}@@$(( $(part_start "$USB" 1) * 512 ))" ::/kryptik/kryptik-sb.crt > "$TMPK/medium.crt" 2>/dev/null +sbverify --cert "$TMPK/medium.crt" "$TMPK/good.efi" >/dev/null 2>&1 && green "control: the medium's certificate verifies the installed kernel" || red "control: the medium's certificate does not verify the installed kernel" openssl req -new -x509 -newkey rsa:2048 -nodes -days 1 -subj "/CN=not kryptik/" -keyout "$TMPK/k" -out "$TMPK/c" >/dev/null 2>&1 -# strip the developer signature, sign with the foreign key +# strip the signature, sign with the foreign key sbattach --remove "$TMPK/good.efi" 2>/dev/null || true sbsign --key "$TMPK/k" --cert "$TMPK/c" --output "$TMPK/foreign.efi" "$TMPK/good.efi" >/dev/null 2>&1 -sbverify --cert "${KRYPTIK_WORK}/keys/sb/kryptik-sb.crt" "$TMPK/foreign.efi" >/dev/null 2>&1 && red "control: the foreign kernel verifies against our key" || green "control: the foreign-signed kernel does not verify against the developer key" +sbverify --cert "$TMPK/medium.crt" "$TMPK/foreign.efi" >/dev/null 2>&1 && red "control: the foreign kernel verifies against the medium's certificate" || green "control: the foreign-signed kernel does not verify against the medium's certificate" mdel -i "$ESPIMG" ::/EFI/BOOT/BOOTX64.EFI mcopy -i "$ESPIMG" "$TMPK/foreign.efi" ::/EFI/BOOT/BOOTX64.EFI dd if="$ESPIMG" of="$DISK" bs=1M oflag=seek_bytes seek="$ESP_OFF" conv=notrunc status=none @@ -100,7 +103,7 @@ grep -qE 'Access Denied|Security Violation' <<<"$T2" && green "the firmware refu grep -q 'Linux version' <<<"$T2" && red "a foreign-signed kernel BOOTED under the enrolled key" || green "the firmware did not start the foreign-signed kernel" grep -q 'KRYPTIK_SMOKE: BEGIN' <<<"$T2" && red "Kryptik userspace ran from an untrusted boot file" || green "no userspace ran" # positive control: the same firmware and store boot the medium's signed kernel -"${SELF}/mk-testctl.sh" --out "${VMDIR}/testctl-smoke.img" smoke_poweroff=1 > /dev/null +"${SELF}/mk-testctl.sh" --out "${VMDIR}/testctl-smoke.img" --key "$TESTCTL_KEY" smoke_poweroff=1 > /dev/null smoke integ-p2ctl --usb "$USB" --testctl "${VMDIR}/testctl-smoke.img" --vars enrolled --timeout 300 > /dev/null 2>&1 boot_txt | grep -q 'Linux version' && green "control: the developer-signed medium boots under the same store" || red "control failed: the signed medium did not boot" # restore the pristine ESP @@ -129,7 +132,7 @@ grep -q 'login:' <<<"$T3" && red "a login prompt appeared on a tampered root" || # ----------------------------------------------------------------- step 4 -- step "step 4: recovery from the medium restores slot a; state survives" CTLR="${VMDIR}/testctl-recover.img" -"${SELF}/mk-testctl.sh" --out "$CTLR" recover_disk=/dev/vda recover_slot=a recover_mode=restore smoke_poweroff=1 install_wait=5 > /dev/null +"${SELF}/mk-testctl.sh" --out "$CTLR" --key "$TESTCTL_KEY" recover_disk=/dev/vda recover_slot=a recover_mode=restore smoke_poweroff=1 install_wait=5 > /dev/null smoke integ-p4 --usb "$USB" --disk "$DISK" --testctl "$CTLR" --vars enrolled --timeout "$TIMEOUT" > /dev/null boot_txt | grep -q 'KRYPTIK_RECOVER: rc=0' && green "kryptik-recover --restore-slot a succeeded from the medium" || { red "recovery did not report success"; boot_txt | grep 'KRYPTIK_RECOVER' | tail -5 | sed 's/^/ /'; } # The records recovery wrote on the ESP, read from the host: whole, and diff --git a/tools/image/media-smoke.sh b/tools/image/media-smoke.sh index 7b30759b..5d517b04 100755 --- a/tools/image/media-smoke.sh +++ b/tools/image/media-smoke.sh @@ -39,7 +39,9 @@ deny() { if grep -qE "$2" "$TXT"; then red "$1"; else green "$1"; fi; } VMDIR="${KRYPTIK_WORK}/vm"; mkdir -p "$VMDIR" TESTCTL="${VMDIR}/testctl-smoke.img" -"${SELF}/mk-testctl.sh" --out "$TESTCTL" smoke_poweroff=1 > /dev/null || die "could not make the control disk" +# Signed with the kryptik-testctl key the medium's anchor lists (suite-lib.sh says which). +TESTCTL_KEY="${KRYPTIK_TESTCTL_KEY:-${KRYPTIK_WORK}/keys/release/kryptik-testctl}" +"${SELF}/mk-testctl.sh" --out "$TESTCTL" --key "$TESTCTL_KEY" smoke_poweroff=1 > /dev/null || die "could not make the control disk" log "media smoke: ${KIND} ${MEDIUM##*/} (variables: ${VARS})" [[ "$REFUSED" -eq 1 ]] && [[ "$TIMEOUT" -gt 120 ]] && TIMEOUT=120 diff --git a/tools/image/mk-testctl.sh b/tools/image/mk-testctl.sh index 4963ce50..ed6597f8 100755 --- a/tools/image/mk-testctl.sh +++ b/tools/image/mk-testctl.sh @@ -1,8 +1,10 @@ #!/usr/bin/env bash # Make a kryptik-testctl control disk: an 8 MiB GPT image with one FAT -# partition labelled kryptik-testctl holding kryptik-test.conf. +# partition labelled kryptik-testctl holding kryptik-test.conf and its +# signature by the kryptik-testctl key, which the medium checks against its +# anchor before it honours a key: a disk signed by any other key is ignored. # -# tools/image/mk-testctl.sh --out FILE KEY=VALUE... +# tools/image/mk-testctl.sh --out FILE --key KRYPTIK-TESTCTL KEY=VALUE... # # Read only by an install medium; keys include (build/service-scripts/testctl.sh): # install_target=/dev/vdb smoke_poweroff=1 install_wait=SECONDS @@ -11,28 +13,32 @@ set -Eeuo pipefail SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=/dev/null source "${SELF}/../../build/lib/common.sh" -OUT=""; KV=() +OUT=""; KEY=""; KV=() while [[ "$#" -gt 0 ]]; do case "$1" in --out) OUT="${2:?}"; shift 2 ;; - -h|--help) sed -n '2,9p' "${BASH_SOURCE[0]}"; exit 0 ;; + --key) KEY="${2:?}"; shift 2 ;; + -h|--help) sed -n '2,11p' "${BASH_SOURCE[0]}"; exit 0 ;; *=*) KV+=("$1"); shift ;; *) die "unknown argument: $1" ;; esac done [[ -n "$OUT" ]] || die "--out is required" +[[ -n "$KEY" && -f "$KEY" ]] || die "--key is required: the kryptik-testctl key the medium's anchor lists" [[ "${#KV[@]}" -gt 0 ]] || die "at least one KEY=VALUE is required" case "$OUT" in /dev/*|/sys/*|/proc/*) die "refusing to write ${OUT}" ;; esac [[ -e "$OUT" && ! -f "$OUT" ]] && die "refusing: ${OUT} exists and is not a regular file" -for t in sfdisk mkfs.vfat mcopy truncate dd; do have "$t" || die "required tool not found: $t"; done +for t in sfdisk mkfs.vfat mcopy truncate dd ssh-keygen; do have "$t" || die "required tool not found: $t"; done tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT printf '%s\n' "${KV[@]}" > "$tmp/kryptik-test.conf" +ssh-keygen -Y sign -f "$KEY" -n kryptik-testctl "$tmp/kryptik-test.conf" < /dev/null > /dev/null 2>&1 \ + && [[ -s "$tmp/kryptik-test.conf.sig" ]] || die "could not sign the control file with ${KEY}" # partition: sectors 2048.. (6 MiB) PSECT=$(( 6 * 2048 )) truncate -s $(( PSECT * 512 )) "$tmp/part.img" mkfs.vfat -F 12 -n TESTCTL "$tmp/part.img" >/dev/null -mcopy -i "$tmp/part.img" "$tmp/kryptik-test.conf" ::/kryptik-test.conf +mcopy -i "$tmp/part.img" "$tmp/kryptik-test.conf" "$tmp/kryptik-test.conf.sig" ::/ rm -f "$OUT" truncate -s $(( 8 * 1024 * 1024 )) "$OUT" sfdisk --quiet --wipe always "$OUT" < /dev/null || die "the install control disk" smoke "$1" --usb "$2" --disk "$DISK" --testctl "$ctl" --timeout "$TIMEOUT" "${@:3}" > /dev/null boot_txt | grep -q 'KRYPTIK_INSTALL: rc=0' diff --git a/tools/image/update-test.sh b/tools/image/update-test.sh index a7ea9782..e46a4a70 100755 --- a/tools/image/update-test.sh +++ b/tools/image/update-test.sh @@ -6,8 +6,9 @@ # [--foreign DIR] [--disk FILE] [--timeout N] # [--vars clean|enrolled | --vars-file FILE] # -# --foreign DIR a payload of the other role, which B must refuse; required -# when B is a production release +# --foreign DIR a payload signed by a key B does not trust, which B must +# refuse: the other role's build, or another medium's; +# required when B is a production release # --vars-file FILE the firmware variable store to start from, for media # signed with a key other than this build's own # @@ -17,7 +18,7 @@ # # step 1 install A, boot, create a zone volume and a home file # step 2 apply B, reboot: slot b committed, data intact -# step 3 refusals on B: wrong key, the other role's build (--foreign), +# step 3 refusals on B: wrong key, a build it does not trust (--foreign), # modified image, truncated kernel, extra file, older release, full # disk, concurrent run; no trial armed. On a development B also a # manifest for the other role, signed by B's own key @@ -62,10 +63,13 @@ B_ROLE="$(role_of "$PAY_B")" case "$B_ROLE" in development|production) ;; *) die "B's manifest names no role this suite knows: '${B_ROLE}'" ;; esac if [[ -n "$FOREIGN" ]]; then F_ROLE="$(role_of "$FOREIGN")" - [[ -n "$F_ROLE" && "$F_ROLE" != "$B_ROLE" && -s "$FOREIGN/manifest.sig" ]] \ - || die "--foreign ${FOREIGN} must be a signed payload of the other role than B's (${B_ROLE})" + [[ -n "$F_ROLE" && -s "$FOREIGN/manifest.sig" ]] \ + || die "--foreign ${FOREIGN} must be a signed stage 06 payload" + if cmp -s "$FOREIGN/manifest.sig" "$PAY_B/manifest.sig"; then + die "--foreign ${FOREIGN} is B's own payload: name one signed by a key B does not trust" + fi elif [[ "$B_ROLE" == production ]]; then - die "a production B must refuse a development build: name one with --foreign DIR" + die "a production B must refuse a build it does not trust: name one with --foreign DIR" fi [[ -z "$VARS_FILE" || -f "$VARS_FILE" ]] || die "--vars-file ${VARS_FILE} is not a file" # Stage 06 publishes B into a channel beside its payload with diff --git a/tools/production-pair.sh b/tools/production-pair.sh index f30f76ee..fa322913 100755 --- a/tools/production-pair.sh +++ b/tools/production-pair.sh @@ -27,6 +27,10 @@ img="${KRYPTIK_WORK}/images" p="${KRYPTIK_WORK}/images-production" "${sudo[@]}" mkdir -p "$p" "${sudo[@]}" mv "$img"/kryptik-1.0.[01][-.]* "$img"/payload-1.0.[01] "$img"/channel-1.0.[01] "$p/" "${sudo[@]}" cp "${m}/kryptik-sb.crt" "$p/" +# The control-disk key stays with the pair: it arms the suites' unattended +# installs of these media and signs nothing else. The three below are gone. +"${sudo[@]}" mkdir -p -m 0700 "$p/keys" +"${sudo[@]}" cp "${m}/kryptik-testctl" "${m}/kryptik-testctl.pub" "$p/keys/" # An OpenSSH key's fifth line holds its seed; the last line of a PEM key's body # ends its private values. diff --git a/tools/tests/acceptance-inputs.sh b/tools/tests/acceptance-inputs.sh index d6149bb1..6b8bbdf4 100755 --- a/tools/tests/acceptance-inputs.sh +++ b/tools/tests/acceptance-inputs.sh @@ -219,5 +219,33 @@ it_media_hashes > "$T/hashes.out" 2>&1; rc=$? [[ "$rc" -ne 0 ]] && grep -q "DOES NOT MATCH ${MEDIA_ISO}.sha256" "$T/hashes.out" \ && ok "a sidecar that disagrees still fails" || bad "a wrong sidecar passed: rc=$rc" +# --- the certificate the medium carries is read out of its ESP ----------------- +sed -n '/^medium_cert() {/,/^}/p' "$ACC" > "$T/cert.sh" +grep -q '^medium_cert()' "$T/cert.sh" || { echo "could not extract medium_cert from $ACC"; exit 1; } +# shellcheck source=/dev/null +. "$T/cert.sh" +if command -v sfdisk >/dev/null && command -v mkfs.vfat >/dev/null && command -v mcopy >/dev/null && command -v mtype >/dev/null; then + openssl req -new -x509 -newkey rsa:2048 -nodes -days 1 -subj "/CN=Kryptik test certificate/" \ + -keyout "$T/k" -out "$T/c" >/dev/null 2>&1 + truncate -s $(( 6 * 1048576 )) "$T/esp.img"; mkfs.vfat -F 12 "$T/esp.img" >/dev/null + mmd -i "$T/esp.img" ::/kryptik; mcopy -i "$T/esp.img" "$T/c" ::/kryptik/kryptik-sb.crt + truncate -s $(( 8 * 1048576 )) "$T/medium.img" + printf 'label: gpt\nunit: sectors\nstart=2048, size=12288, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, name="kryptik-esp"\n' \ + | sfdisk --quiet --wipe always "$T/medium.img" >/dev/null 2>&1 + dd if="$T/esp.img" of="$T/medium.img" bs=512 seek=2048 conv=notrunc status=none + if medium_cert "$T/medium.img" "$T/got.crt" && cmp -s "$T/c" "$T/got.crt"; then + ok "the certificate is read out of the medium's ESP as the medium carries it" + else bad "the medium's certificate was not read back"; fi + mdel -i "$T/esp.img" ::/kryptik/kryptik-sb.crt + dd if="$T/esp.img" of="$T/medium.img" bs=512 seek=2048 conv=notrunc status=none + if ! medium_cert "$T/medium.img" "$T/got2.crt" && [[ ! -e "$T/got2.crt" ]]; then + ok "a medium without the certificate reads as none, and leaves no file" + else bad "a medium without the certificate passed"; fi + : > "$T/plain.img" + if ! medium_cert "$T/plain.img" "$T/got3.crt"; then ok "a file with no partition table reads as none"; else bad "an empty file passed as a medium"; fi +else + echo " (no sfdisk, mkfs.vfat, mcopy or mtype here: the medium's certificate is not read)" +fi + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" [[ "$FAIL" -eq 0 ]] diff --git a/tools/tests/release-keys.sh b/tools/tests/release-keys.sh index 30c172b9..a976db3c 100755 --- a/tools/tests/release-keys.sh +++ b/tools/tests/release-keys.sh @@ -149,7 +149,7 @@ refused "an anchor that lets the release key sign statements" "kryptik-release i make_medium; sed -i '1p' "$M/release-signers" refused "an anchor listing a key twice" "a key is listed twice" make_medium; printf 'someone namespaces="someone" %s\n' "$(cut -d' ' -f1,2 < "$M/kryptik-release.pub")" >> "$M/release-signers" -refused "an anchor with a third principal" "neither kryptik-release nor kryptik-latest" +refused "an anchor with a principal of its own" "neither kryptik-release, kryptik-latest nor kryptik-testctl" make_medium; sed -i "2s|ssh-ed25519 [^ ]*|$(cut -d' ' -f1,2 < "$M/kryptik-release.pub" | sed 's/[\/&|]/\\&/g')|" "$M/release-signers" refused "an anchor with one key for both" "a key is listed twice" make_medium; cp "$M/kryptik-latest.pub" "$M/kryptik-release.pub" diff --git a/tools/tests/suite-lib.sh b/tools/tests/suite-lib.sh index 1d4c94e1..4ec1673c 100755 --- a/tools/tests/suite-lib.sh +++ b/tools/tests/suite-lib.sh @@ -91,7 +91,7 @@ check "an installer that reports rc=0 passes" "$?" "0" check "run-ovmf.sh got the medium, disk, control disk and the suite's arguments" "$(tail -1 "$STUB_ARGS")" \ "--mode smoke --name t-install --log ${BOOTLOG} --usb /medium.img --disk ${DISK} --testctl ${VMDIR}/testctl-t-install.img --timeout 900 --vars clean" check "the control disk arms the install with the preseeded accounts" "$(grep '^mk-testctl' "$STUB_ARGS" | tail -1)" \ - "mk-testctl --out ${VMDIR}/testctl-t-install.img install_target=/dev/vda smoke_poweroff=1 install_wait=5 ${PRESEED[*]}" + "mk-testctl --out ${VMDIR}/testctl-t-install.img --key ${TESTCTL_KEY} install_target=/dev/vda smoke_poweroff=1 install_wait=5 ${PRESEED[*]}" STUB_INSTALLED=1 install_disk t-install /medium.img --vars clean [[ $? -ne 0 ]] && ok "an installer that reports rc=1 fails" || bad "an installer that reports rc=1 fails" install_disk t-install /medium.img --vars clean diff --git a/tools/tests/testctl.sh b/tools/tests/testctl.sh new file mode 100755 index 00000000..2cf74126 --- /dev/null +++ b/tools/tests/testctl.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Tests for the medium's control-disk check (build/service-scripts/testctl.sh) +# and the tool that makes a control disk (tools/image/mk-testctl.sh): a file +# is honoured only with a signature by the kryptik-testctl key the anchor +# lists, in that key's namespace. Offline, with throwaway keys; the disk image +# itself is made only where mtools and sfdisk are. + +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TESTCTL="${ROOT}/build/service-scripts/testctl.sh" +MK="${ROOT}/tools/image/mk-testctl.sh" + +command -v ssh-keygen >/dev/null 2>&1 || { echo "ssh-keygen is not installed here; cannot run this test"; exit 77; } + +PASS=0 +FAIL=0 +green() { printf '\033[32m PASS\033[0m %s\n' "$1"; PASS=$((PASS + 1)); } +red() { printf '\033[31m FAIL\033[0m %s\n' "$1"; FAIL=$((FAIL + 1)); } +W="$(mktemp -d)" +trap 'rm -rf "$W"' EXIT + +# --- keys and an anchor as a medium carries one ---------------------------------------- +for k in kryptik-release kryptik-latest kryptik-testctl stranger; do + ssh-keygen -q -t ed25519 -N '' -C "$k" -f "${W}/${k}" < /dev/null +done +{ + printf 'kryptik-release namespaces="kryptik-release,kryptik-media" %s\n' "$(cut -d' ' -f1,2 "${W}/kryptik-release.pub")" + printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 "${W}/kryptik-latest.pub")" + printf 'kryptik-testctl namespaces="kryptik-testctl" %s\n' "$(cut -d' ' -f1,2 "${W}/kryptik-testctl.pub")" +} > "${W}/release-signers" +printf 'install_target=/dev/vda\nsmoke_poweroff=1\n' > "${W}/kryptik-test.conf" + +# The medium's check, taken as the medium sources it, against this anchor. +TESTCTL_ANCHOR="${W}/release-signers" +# shellcheck source=build/service-scripts/testctl.sh +. "$TESTCTL" + +sign() { # sign KEY NAMESPACE FILE + rm -f "$3.sig" + ssh-keygen -Y sign -f "$1" -n "$2" "$3" < /dev/null > /dev/null 2>&1 +} + +if testctl_signed "${W}/kryptik-test.conf"; then red "an unsigned control file was honoured"; else green "an unsigned control file is refused"; fi +sign "${W}/kryptik-testctl" kryptik-testctl "${W}/kryptik-test.conf" +if testctl_signed "${W}/kryptik-test.conf"; then green "a file signed by the anchor's kryptik-testctl key, in its namespace, is honoured"; else red "the right signature was refused"; fi +sign "${W}/stranger" kryptik-testctl "${W}/kryptik-test.conf" +if testctl_signed "${W}/kryptik-test.conf"; then red "a stranger's signature was honoured"; else green "a signature by a key the anchor does not list is refused"; fi +sign "${W}/kryptik-release" kryptik-testctl "${W}/kryptik-test.conf" +if testctl_signed "${W}/kryptik-test.conf"; then red "the release key's signature armed an install"; else green "the release key cannot sign a control disk: its namespaces are its own"; fi +sign "${W}/kryptik-testctl" kryptik-release "${W}/kryptik-test.conf" +if testctl_signed "${W}/kryptik-test.conf"; then red "a signature in another namespace was honoured"; else green "the control-disk key's signature counts in its own namespace alone"; fi +sign "${W}/kryptik-testctl" kryptik-testctl "${W}/kryptik-test.conf" +printf 'install_target=/dev/sda\n' >> "${W}/kryptik-test.conf" +if testctl_signed "${W}/kryptik-test.conf"; then red "a file changed after signing was honoured"; else green "a file changed after signing is refused"; fi +printf 'unlisted namespaces="kryptik-testctl" %s\n' "$(cut -d' ' -f1,2 "${W}/stranger.pub")" > "${W}/anchor-stranger" +sign "${W}/stranger" kryptik-testctl "${W}/kryptik-test.conf" +if TESTCTL_ANCHOR="${W}/anchor-stranger" testctl_signed "${W}/kryptik-test.conf"; then + red "a key listed under another name was honoured" +else + green "the signer must be listed as kryptik-testctl, not merely listed" +fi + +# --- the tool signs what it packs ------------------------------------------------------ +if command -v sfdisk >/dev/null 2>&1 && command -v mkfs.vfat >/dev/null 2>&1 && command -v mcopy >/dev/null 2>&1 && command -v mtype >/dev/null 2>&1; then + out="$(NO_COLOR=1 bash "$MK" --out "${W}/ctl.img" install_target=/dev/vda 2>&1)"; rc=$? + if [[ "$rc" -ne 0 && "$out" == *"--key is required"* ]]; then green "the tool refuses to make a control disk without the key"; else red "an unsigned control disk was made (exit ${rc}): ${out}"; fi + if NO_COLOR=1 bash "$MK" --out "${W}/ctl.img" --key "${W}/kryptik-testctl" install_target=/dev/vda smoke_poweroff=1 > /dev/null 2>&1; then + # The partition starts at sector 2048; read both files out of it. + dd if="${W}/ctl.img" of="${W}/part.img" bs=512 skip=2048 status=none + mtype -i "${W}/part.img" ::/kryptik-test.conf > "${W}/packed.conf" 2>/dev/null + mtype -i "${W}/part.img" ::/kryptik-test.conf.sig > "${W}/packed.conf.sig" 2>/dev/null + if [[ -s "${W}/packed.conf.sig" ]] && grep -qx 'install_target=/dev/vda' "${W}/packed.conf" && testctl_signed "${W}/packed.conf"; then + green "the disk holds the control file and a signature the medium's check accepts" + else + red "the packed control file or its signature"; sed 's/^/ /' "${W}/packed.conf" + fi + else + red "the tool could not make a signed control disk" + fi +else + echo " (no sfdisk, mkfs.vfat, mcopy or mtype here: the disk image itself is not made)" +fi + +echo +echo "${PASS} passed, ${FAIL} failed" +[[ "$FAIL" -eq 0 ]] diff --git a/tools/throwaway-key-medium.sh b/tools/throwaway-key-medium.sh index 87af58eb..d86f3b5a 100755 --- a/tools/throwaway-key-medium.sh +++ b/tools/throwaway-key-medium.sh @@ -17,12 +17,14 @@ m="${1:?usage: throwaway-key-medium.sh DIR}" mkdir -m 0700 "$m" ssh-keygen -q -t ed25519 -N '' -C kryptik-release -f "${m}/kryptik-release" < /dev/null ssh-keygen -q -t ed25519 -N '' -C kryptik-latest -f "${m}/kryptik-latest" < /dev/null +ssh-keygen -q -t ed25519 -N '' -C kryptik-testctl -f "${m}/kryptik-testctl" < /dev/null { printf 'kryptik-release namespaces="kryptik-release,kryptik-media" %s\n' "$(cut -d' ' -f1,2 "${m}/kryptik-release.pub")" printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 "${m}/kryptik-latest.pub")" + printf 'kryptik-testctl namespaces="kryptik-testctl" %s\n' "$(cut -d' ' -f1,2 "${m}/kryptik-testctl.pub")" } > "${m}/release-signers" openssl req -new -x509 -newkey rsa:3072 -nodes -sha256 -days 7 \ -subj "/CN=Kryptik Secure Boot (throwaway)/" \ -keyout "${m}/kryptik-sb.key" -out "${m}/kryptik-sb.crt" 2>/dev/null -chmod 600 "${m}/kryptik-release" "${m}/kryptik-latest" "${m}/kryptik-sb.key" +chmod 600 "${m}/kryptik-release" "${m}/kryptik-latest" "${m}/kryptik-testctl" "${m}/kryptik-sb.key" ok "a throwaway key medium in ${m}"