diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index f1338f02..86889126 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -5,7 +5,10 @@ inputs: description: Also let the runner start VMs (KVM, and virt-fw-vars for the variable stores). default: 'false' sources: - description: The upstream sources, cached by sources.lock. Either fetches what the cache lacks; 'fetch' also saves the set, on main. + description: The root's upstream sources, cached by sources.lock. Either fetches what the cache lacks; 'fetch' also saves the set, on main. + default: '' + image-sources: + description: The software images' sources too, a cache of their own keyed by build/images/sources.lock; 'fetch' or 'restore' as for sources. default: '' runs: using: composite @@ -35,6 +38,9 @@ runs: sudo udevadm trigger --name-match=kvm ls -la /dev/kvm + # The root's set, keyed by its lock alone. images/, the software images' sources, has a cache of + # its own and is set aside while this one is saved: a browser bump then copies none of the root's + # set, and a root bump none of theirs. The path is the one it always had, so earlier sets restore. - if: inputs.sources != '' id: sources uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 @@ -44,22 +50,53 @@ runs: # A changed lock restores the previous set; every file is still hashed against the lock. restore-keys: sources- - # A cache evicted since the build job is fetched again, not taken as gone. - - if: inputs.sources != '' && steps.sources.outputs.cache-hit != 'true' + # With the signatures, which verify-signatures keeps beside the root's: when an image's pin alone + # moves, only this cache is saved again. + - if: inputs.image-sources != '' + id: image_sources + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: | + /mnt/kryptik/sources/images + /mnt/kryptik/sources/.signatures + key: image-sources-v1-${{ hashFiles('build/images/sources.lock') }} + restore-keys: image-sources-v1- + + # A cache evicted since the build job is fetched again, not taken as gone. With the images' sources + # this runs on two hits as well: the root's set restored may be one saved without the links that + # name each image source in sources/ (main's, or an older set's), and only this pass makes them. + - if: (inputs.sources != '' && steps.sources.outputs.cache-hit != 'true') || inputs.image-sources != '' shell: bash env: - MODE: ${{ inputs.sources }} + IMAGES: ${{ inputs.image-sources }} + SAVES: ${{ (inputs.sources == 'fetch' && steps.sources.outputs.cache-hit != 'true') || (inputs.image-sources == 'fetch' && steps.image_sources.outputs.cache-hit != 'true') }} run: | - make sources + if [[ -n "$IMAGES" ]]; then make sources; else ./tools/fetch-sources.sh --root; fi ./tools/prune-sources.sh - # Fetches the signatures into the saved set for CI's gate; this pass is not the verdict. - [[ "$MODE" != fetch ]] || ./tools/verify-signatures.sh > /dev/null 2>&1 || true + # Fetches the signatures into the set saved below for CI's gate; this pass is not the verdict. + [[ "$SAVES" != true ]] || ./tools/verify-signatures.sh > /dev/null 2>&1 || true # Saved now rather than at the end, so a job that fails later does not refetch the new files. # From main alone, which every branch's run restores: a branch whose lock adds one file would # save the whole set again in its own scope, and such copies pushed main's caches out. + - if: inputs.sources == 'fetch' && steps.sources.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main' + shell: bash + run: if [[ -d /mnt/kryptik/sources/images ]]; then mv /mnt/kryptik/sources/images /mnt/kryptik/images-aside; fi + - if: inputs.sources == 'fetch' && steps.sources.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main' uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: /mnt/kryptik/sources key: sources-v3-${{ hashFiles('sources.lock') }} + + - if: always() && inputs.sources == 'fetch' && steps.sources.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main' + shell: bash + run: if [[ -d /mnt/kryptik/images-aside ]]; then mv /mnt/kryptik/images-aside /mnt/kryptik/sources/images; fi + + - if: inputs.image-sources == 'fetch' && steps.image_sources.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: | + /mnt/kryptik/sources/images + /mnt/kryptik/sources/.signatures + key: image-sources-v1-${{ hashFiles('build/images/sources.lock') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b3085be..64aa97e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,7 +125,9 @@ jobs: sudo mkdir -p /mnt/kryptik && sudo chown "$(id -u):$(id -g)" /mnt/kryptik ./tools/fetch-sources.sh --list - # Restores the last set; `make sources` checks it against the lock and fetches what is missing. + # Restores the last sets; `make sources` checks them against the locks and fetches what is missing. + # The software images' sources, under images/, are a cache of their own (the prepare action's), + # set aside while the root's is saved. - name: Upstream sources (cached by sources.lock) id: sources uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 @@ -135,16 +137,36 @@ jobs: restore-keys: | sources- - # Fetches the signatures into the cached set for the gate below; this pass is not the verdict. + # With the signatures, which verify-signatures keeps beside the root's: when an image's pin alone + # moves, only this cache is saved again. + - name: The software images' sources (cached by build/images/sources.lock) + id: image_sources + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: | + /mnt/kryptik/sources/images + /mnt/kryptik/sources/.signatures + key: image-sources-v1-${{ hashFiles('build/images/sources.lock') }} + restore-keys: | + image-sources-v1- + + # Runs on two hits as well: the root's set restored may be one saved without the links that name + # each image source in sources/ (an older set's), and only this pass makes them. On a miss it + # fetches the signatures into the cached set for the gate below; that pass is not the verdict. - name: Fetch and verify the sources - if: steps.sources.outputs.cache-hit != 'true' + env: + MISSED: ${{ steps.sources.outputs.cache-hit != 'true' || steps.image_sources.outputs.cache-hit != 'true' }} run: | make sources ./tools/prune-sources.sh - ./tools/verify-signatures.sh > /dev/null 2>&1 || true + [[ "$MISSED" != true ]] || ./tools/verify-signatures.sh > /dev/null 2>&1 || true # From main alone, as the Distro jobs save it: a pull request's cache is scoped to its merge # ref and would duplicate the set. + - name: Set the software images' sources aside + if: ${{ github.ref == 'refs/heads/main' && steps.sources.outputs.cache-hit != 'true' }} + run: if [[ -d /mnt/kryptik/sources/images ]]; then mv /mnt/kryptik/sources/images /mnt/kryptik/images-aside; fi + - name: Save the sources if: ${{ github.ref == 'refs/heads/main' && steps.sources.outputs.cache-hit != 'true' }} uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 @@ -152,6 +174,19 @@ jobs: path: /mnt/kryptik/sources key: ${{ steps.sources.outputs.cache-primary-key }} + - name: The software images' sources back in place + if: always() + run: if [[ -d /mnt/kryptik/images-aside ]]; then mv /mnt/kryptik/images-aside /mnt/kryptik/sources/images; fi + + - name: Save the software images' sources + if: ${{ github.event_name != 'pull_request' && steps.image_sources.outputs.cache-hit != 'true' }} + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: | + /mnt/kryptik/sources/images + /mnt/kryptik/sources/.signatures + key: ${{ steps.image_sources.outputs.cache-primary-key }} + # Informational, as mirrors go down; a one-byte ranged GET, since some GNU mirrors refuse HEAD. - name: Every source URL is reachable continue-on-error: true @@ -206,17 +241,22 @@ jobs: - name: Signatures, strict run: ./tools/verify-signatures.sh --strict + # The root's sources in sources.lock, the software images' in build/images/sources.lock. - name: sources.lock covers the manifest run: | fail=0 - while read -r name ver url _; do - file=$(basename "$url") - if ! grep -q " $file\$" sources.lock; then - echo " MISSING FROM LOCK: $file" - fail=1 - fi - done < <(./tools/fetch-sources.sh --list) - [ $fail -eq 0 ] && echo "sources.lock covers every manifest entry" + check() { + while read -r name ver url _; do + file=$(basename "$url") + if ! grep -q " $file\$" "$1"; then + echo " MISSING FROM $1: $file" + fail=1 + fi + done + } + check sources.lock < <(./tools/fetch-sources.sh --list-root) + check build/images/sources.lock < <(./tools/fetch-sources.sh --list | grep -vxF -f <(./tools/fetch-sources.sh --list-root)) + [ $fail -eq 0 ] && echo "sources.lock and build/images/sources.lock cover every manifest entry" exit $fail compartments: diff --git a/.github/workflows/distro.yml b/.github/workflows/distro.yml index 406397ec..951d8fd2 100644 --- a/.github/workflows/distro.yml +++ b/.github/workflows/distro.yml @@ -76,6 +76,7 @@ jobs: - uses: ./.github/actions/prepare with: sources: fetch + image-sources: ${{ github.ref_type == 'tag' && 'fetch' || '' }} # CI's gates once more, so a tag never outruns them. - name: The release's sources are signed, and their provenance holds @@ -120,7 +121,11 @@ jobs: - name: Stage 01, the cross toolchain (unprivileged) timeout-minutes: 200 - run: make toolchain + # The root's sources alone: make's own sources would fetch every image's from upstream too, + # which no stage here reads and whose cache only the images' jobs restore. + run: | + ./tools/fetch-sources.sh --root + make -o sources toolchain - name: Stage 02, the temporary tools (unprivileged) id: stage02 @@ -186,8 +191,8 @@ jobs: retention-days: 7 if-no-files-found: ignore - system: - name: Stages 04-06 (base system, kernel, media) + base: + name: Stages 04-05 (base system, kernel) needs: toolchain runs-on: ubuntu-24.04 timeout-minutes: 360 @@ -330,17 +335,204 @@ jobs: path: /mnt/kryptik/work-after-05.tar.zst key: ${{ steps.progress05.outputs.prefix }}${{ github.run_id }}-${{ github.run_attempt }} - - name: Drop the cache tarball before the media are built + - name: Drop the cache tarball if: always() run: rm -f /mnt/kryptik/work-after-05.tar.zst + # The next jobs' tree, as the cache keeps it, with the logs. Never the module signing key, as anyone + # who can read the repository can read an artifact: the stage 06 job's stage 05 makes a new one. + - name: Pack the stage 04-05 tree for the next jobs + run: | + cd /mnt/kryptik + members=(.stamps sysroot logs) + kernel_tree="build/linux-$(sed -n 's/^V_LINUX=//p' "$GITHUB_WORKSPACE/build/config/versions.env")" + [ -d "work/$kernel_tree" ] && members+=("$kernel_tree") + sudo tar -I 'zstd -T0' -cf work-after-05.tar.zst -C work --anchored \ + --exclude='sysroot/proc/*' --exclude='sysroot/sys/*' --exclude='sysroot/dev/*' \ + --exclude='sysroot/run/*' --exclude='sysroot/kryptik*/*' \ + --exclude="$kernel_tree/certs/signing_key.pem" "${members[@]}" + if sudo tar -I zstd -tf work-after-05.tar.zst | grep -E 'certs/signing_key\.pem$|keys/release/'; then echo "a private key is in the tarball"; exit 1; fi + sudo chown "$(id -u):$(id -g)" work-after-05.tar.zst + ls -la work-after-05.tar.zst + + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: work-after-05 + path: /mnt/kryptik/work-after-05.tar.zst + # A tag's stage 06 job may be re-run after its owner's approval, so it keeps a week. + retention-days: ${{ github.ref_type == 'tag' && 7 || 1 }} + compression-level: 0 + + - name: Stage logs + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: logs-base + path: /mnt/kryptik/work/logs + retention-days: 7 + if-no-files-found: ignore + + # The software images in a job of their own: Firefox alone takes hours, which beside a stage 04 built + # from nothing (a tag's, or a new pin's) and stage 06 would not fit one job's six. + images: + name: Software images (the toolkit, the browser) + needs: [toolchain, base] + runs-on: ubuntu-24.04 + timeout-minutes: 360 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: ./.github/actions/prepare + with: + sources: fetch + image-sources: fetch + + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: work-after-05 + path: /mnt/kryptik + + - name: Unpack the stage 04-05 tree + run: | + cd /mnt/kryptik + sudo tar --zstd -xf work-after-05.tar.zst -C work + rm -f work-after-05.tar.zst + ls work; sudo du -sh work/sysroot + + # The images built over this base system, up to the last step they build against (IMAGES_BASE in + # 04-images.sh), from an earlier run: make images keeps each whose fingerprint still matches its + # inputs and builds the rest. Not for a tag, which builds from nothing. + - name: Which base system the images are built over + id: images_key + if: github.ref_type != 'tag' + run: | + step="$(sed -n 's/^IMAGES_BASE="\([a-z0-9-]*\)"$/\1/p' build/stages/04-images.sh)" + base="$(sudo sed -n 's/^fingerprint: //p' "$KRYPTIK_WORK/.stamps/bs-${step:-none}" | cut -c1-16)" + if [ -n "$base" ]; then echo "prefix=images-v1-${{ needs.toolchain.outputs.ttfp }}-${base}-" >> "$GITHUB_OUTPUT"; fi + + - name: Software images from an earlier run (cached) + id: images_cache + if: steps.images_key.outputs.prefix != '' + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: /mnt/kryptik/images-cache.tar.zst + key: ${{ steps.images_key.outputs.prefix }} + restore-keys: | + ${{ steps.images_key.outputs.prefix }} + + - name: Unpack the cached images + if: steps.images_cache.outputs.cache-matched-key != '' + run: | + cd /mnt/kryptik + sudo tar --zstd -xf images-cache.tar.zst -C work + rm -f images-cache.tar.zst + sudo sed -n 's/^\(name\|fingerprint\): //p' work/image-build/*/record | paste - - || true + + # Stage 06 packs what this builds. The browser takes some three hours or more when its tree is not + # cached, the toolkit some minutes. + - name: Software images (over the base system, in the chroot) + id: images + timeout-minutes: 330 + run: make -o system images + + # Each image's tree and record, never an overlay's upper or work directory; saved when they changed. + - name: Pack the images for their cache + id: images_pack + if: ${{ !cancelled() && steps.images.outcome == 'success' && steps.images_key.outputs.prefix != '' }} + run: | + d="$(cd "$KRYPTIK_WORK" && sudo cat image-build/*/record 2>/dev/null | sha256sum | cut -c1-16)" + key="${{ steps.images_key.outputs.prefix }}${d}-" + case "${{ steps.images_cache.outputs.cache-matched-key }}" in "$key"*) echo "the images are the cached ones"; exit 0 ;; esac + cd /mnt/kryptik + sudo tar -I 'zstd -T0' -cf images-cache.tar.zst -C work --exclude='image-build/*/upper' \ + --exclude='image-build/*/overlay-work' --exclude='image-build/*/sysroot' image-build + sudo chown "$(id -u):$(id -g)" images-cache.tar.zst + ls -la images-cache.tar.zst + echo "key=${key}${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" + + # A cache that cannot be saved costs the next run time, not this one its verdict. + - name: Save the images + if: ${{ !cancelled() && steps.images_pack.outputs.key != '' }} + continue-on-error: true + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: /mnt/kryptik/images-cache.tar.zst + key: ${{ steps.images_pack.outputs.key }} + + - name: Drop the images' tarball + if: always() + run: rm -f /mnt/kryptik/images-cache.tar.zst + + # Each image's tree and record for stage 06, cached or not, and the stamps and logs the build left. + # A layer that does not ship keeps only its record: stage 06 reads no more of it. + - name: Pack the images for the next job + run: | + cd /mnt/kryptik + layers=() + for rec in work/image-build/*/record; do + if [ "$(sudo sed -n 's/^ships: //p' "$rec")" = no ]; then layers+=(--exclude="image-build/$(basename "$(dirname "$rec")")/tree"); fi + done + sudo tar -I 'zstd -T0' -cf images-tree.tar.zst -C work --exclude='image-build/*/upper' \ + --exclude='image-build/*/overlay-work' --exclude='image-build/*/sysroot' "${layers[@]}" image-build .stamps logs + sudo chown "$(id -u):$(id -g)" images-tree.tar.zst + ls -la images-tree.tar.zst + + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: images-tree + path: /mnt/kryptik/images-tree.tar.zst + retention-days: ${{ github.ref_type == 'tag' && 7 || 1 }} + compression-level: 0 + + - name: Stage logs + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: logs-images + path: /mnt/kryptik/work/logs + retention-days: 7 + if-no-files-found: ignore + + system: + name: Stage 06 (the media) + needs: [base, images] + runs-on: ubuntu-24.04 + timeout-minutes: 360 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: ./.github/actions/prepare + with: + sources: restore + + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: work-after-05 + path: /mnt/kryptik + + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: images-tree + path: /mnt/kryptik + + - name: Unpack the stage 04-05 tree and the images + run: | + cd /mnt/kryptik + sudo tar --zstd -xf work-after-05.tar.zst -C work + sudo tar --zstd -xf images-tree.tar.zst -C work + rm -f work-after-05.tar.zst images-tree.tar.zst + ls work; sudo du -sh work/sysroot work/image-build + + # The tree came without its module signing key, so stage 05 builds and signs again with a new one, + # as after a cache restore; stage 06 relinks the kernel from that tree. + - name: Stage 05, keyed afresh + timeout-minutes: 90 + run: make -o system kernel + # The throwaway production pair, then A and B; a production release is only bound here, where upstream build scripts ran as root. - name: Stage 06, the media (production 1.0.0 and 1.0.1, release A and the update B) id: media timeout-minutes: 90 run: | - bins=(KRYPTIK_KRYPTIKD_BIN=/mnt/kryptik/kryptikd-musl KRYPTIK_WLPROXY_BIN=/mnt/kryptik/kryptik-wlproxy-musl) - make production-pair "${bins[@]}" + make production-pair ls -la "$KRYPTIK_WORK/images-production" # A tag's version decides its role; elsewhere the dispatch input does. ROLE=development @@ -355,7 +547,7 @@ jobs: echo "a ${ROLE} release: ${VER_B}, updating from ${VER_A}" echo "role=${ROLE}" >> "$GITHUB_OUTPUT" if [ "$ROLE" = development ]; then - for v in "$VER_A" "$VER_B"; do make media KRYPTIK_VERSION="$v" "${bins[@]}"; done + for v in "$VER_A" "$VER_B"; do make media KRYPTIK_VERSION="$v"; done ls -la "$KRYPTIK_WORK/images" | grep -E 'usb.img|\.iso|payload' exit 0 fi @@ -374,7 +566,7 @@ jobs: sudo rm -rf "$KRYPTIK_WORK/images" sudo find "$KRYPTIK_WORK/.stamps" -maxdepth 1 -name 'img-*' -delete make media KRYPTIK_MEDIA_PHASE=bind KRYPTIK_ROLE=production KRYPTIK_KEYS="$pub" \ - KRYPTIK_VERSION="$v" KRYPTIK_CHANNEL="$channel" "${bins[@]}" + KRYPTIK_VERSION="$v" KRYPTIK_CHANNEL="$channel" sudo mkdir -p "$KRYPTIK_WORK/bound/$v/stamps" sudo mv "$KRYPTIK_WORK/images" "$KRYPTIK_WORK/bound/$v/images" sudo cp -a "$KRYPTIK_WORK/.stamps"/img-* "$KRYPTIK_WORK/bound/$v/stamps/" @@ -559,6 +751,8 @@ jobs: with: vm: 'true' sources: restore + # The inputs suite checks every source against both locks; the other parts read only the root's. + image-sources: ${{ contains(matrix.suites, 'inputs') && 'restore' || '' }} - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: @@ -711,6 +905,7 @@ jobs: - uses: ./.github/actions/prepare with: sources: fetch + image-sources: fetch - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: kryptik-release diff --git a/Makefile b/Makefile index 261627db..a30e46c7 100644 --- a/Makefile +++ b/Makefile @@ -94,7 +94,7 @@ CHROOT_RUN := $(SUDO) env $(CHROOT_ENV) "$(CHROOTD)" .PHONY: help paths check sources lock verify verify-provenance check-pins \ validate-kernel validate-kernel-hardened validate-kernel-boot check-kernel-eol \ resolve-kernel-config check-kernel-hardening \ - toolchain temp-tools sysroot-ready system kernel \ + toolchain temp-tools sysroot-ready system images kernel \ chroot chroot-enter chroot-umount chroot-status \ iso media production-pair ovmf-vars media-smoke-usb media-smoke-iso media-smoke-secureboot \ media-refused-foreign-keys install-test integrity-test update-test \ @@ -114,6 +114,7 @@ help: @echo " make temp-tools stage 02: temporary tools" @echo " (stages 01 and 02 are UNPRIVILEGED - run them as you)" @echo " make system stage 04: hardened base system" + @echo " make images software images over the base system (build/images)" @echo " make kernel stage 05: hardened kernel" @echo " make iso stage 06: verified root image, signed kernels, USB image + ISO" @echo " make media stage 06 only (sysroot and kernel already built)" @@ -279,6 +280,10 @@ sysroot-ready: system: sysroot-ready @$(CHROOT_RUN) run /kryptik/build/stages/04-base-system.sh +# Root for the overlay each image builds through; its rows run in the chroot over it. +images: system + @$(SUDO) env $(CHROOT_ENV) "$(STAGES)"/04-images.sh + # The EOL check needs the network and the chroot has none, so it runs here first. kernel: check-kernel-eol system @$(CHROOT_RUN) run /kryptik/build/stages/05-kernel.sh diff --git a/README.md b/README.md index 5a1b5d73..008698d4 100644 --- a/README.md +++ b/README.md @@ -325,11 +325,13 @@ docs/ Architecture, threat model, decisions, status ## Building The `Distro` workflow (`.github/workflows/distro.yml`) builds and tests -everything on GitHub's runners: stages 01–02, then stages 04–06, then -`make acceptance` under KVM, uploading the acceptance report and the tested -images. A tag `v` builds that version from nothing, tests it and -drafts it on the Releases page; from `v1` on, the build signs with the -release keys once the maintainer approves it. To build locally, on Linux: +everything on GitHub's runners: stages 01–02, stages 04–05, the software +images and stage 06, each a job of its own (a runner gives a job six hours, +and Firefox alone takes several), then `make acceptance` under KVM, +uploading the acceptance report and the tested images. A tag `v` +builds that version from nothing, tests it and drafts it on the Releases +page; from `v1` on, the build signs with the release keys once the +maintainer approves it. To build locally, on Linux: ```sh make check # what the host is missing diff --git a/build/config/hardening-exceptions.txt b/build/config/hardening-exceptions.txt index 8607b16e..f9eed066 100644 --- a/build/config/hardening-exceptions.txt +++ b/build/config/hardening-exceptions.txt @@ -2,3 +2,4 @@ # # what breaks, and what was tried glibc -D_FORTIFY_SOURCE=3 # glibc implements FORTIFY, so --enable-fortify-source=3 applies it to all but its loader, startup and checking code +firefox -fno-delete-null-pointer-checks # under it GCC 14 cannot fold an object's address compared with null in a constant expression, which SpiderMonkey's static_asserts need (SharedIntlData.cpp); CONFIG_DEFAULT_MMAP_MIN_ADDR=65536 keeps the first 64 KiB unmappable, and Mozilla's own builds delete these checks as well diff --git a/build/config/image-programs.txt b/build/config/image-programs.txt index dbe8969e..70899666 100644 --- a/build/config/image-programs.txt +++ b/build/config/image-programs.txt @@ -740,6 +740,7 @@ usr/lib/kryptik/guest-tests/broker-client.py usr/lib/kryptik/guest-tests/camera-check.sh usr/lib/kryptik/guest-tests/gui-check.sh usr/lib/kryptik/guest-tests/icmp-echo.py +usr/lib/kryptik/guest-tests/image-probe.sh usr/lib/kryptik/guest-tests/power-check.sh usr/lib/kryptik/guest-tests/probe-sources.py usr/lib/kryptik/guest-tests/sound-check.sh @@ -1115,6 +1116,7 @@ usr/libexec/kryptik/dhcpcd-hook usr/libexec/kryptik/esp-records.sh usr/libexec/kryptik/eudev-trigger.sh usr/libexec/kryptik/firstboot.sh +usr/libexec/kryptik/image-store.sh usr/libexec/kryptik/installer-run.sh usr/libexec/kryptik/keyboard.sh usr/libexec/kryptik/kryptik-sound diff --git a/build/desktop/dwl-config.h b/build/desktop/dwl-config.h index 371be7be..08be7c95 100644 --- a/build/desktop/dwl-config.h +++ b/build/desktop/dwl-config.h @@ -91,7 +91,7 @@ static const enum libinput_config_tap_button_map button_map = LIBINPUT_CONFIG_TA * chrome's menu, a text program in a zone 0 terminal of its own */ static const char *termcmd[] = { "kryptik-launch", "--ask", "work", "--", "havoc", NULL }; static const char *personalcmd[] = { "kryptik-launch", "--ask", "personal", "--", "havoc", NULL }; -static const char *untrustedcmd[] = { "kryptik-launch", "--ask", "untrusted", "--", "havoc", "lynx", NULL }; +static const char *untrustedcmd[] = { "kryptik-launch", "--ask", "untrusted", "--", "firefox", NULL }; static const char *menucmd[] = { "havoc", "/usr/bin/kryptik-chrome", "--menu", NULL }; /* The screen lock, opened by the login password (docs/design/laptop.md). */ static const char *lockcmd[] = { "kryptik-lock", NULL }; diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index a8c5ad25..08c58517 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -46,6 +46,7 @@ # GT WIFI-TYPE N TEXT type TEXT and Enter (letters and digits) # GT KEY-MENU-OPEN press Alt+p (a menu window for an open network) # GT OPEN-TYPE N TEXT type TEXT and Enter (letters and digits) +# GT KEY-VIEW-BROWSER, GT KEY-BROWSER-BACK press Alt+3 (a tag nothing is on), then Alt+1 # GT KEY-VT-ADMIN, GT KEY-VT-DESKTOP press Ctrl+Alt+F2, then Ctrl+Alt+F1 # GT KEY-MENU-AFTER-VT press Alt+p # GT END @@ -1516,6 +1517,39 @@ else fail "chrome-wifi-scan" "kryptik-launch --wifi-scan: $(tr '\n' ' ' <<<"$scan_said" | cut -c1-200)" fi +# --- the browser, in a zone that carries it --------------------------------------------- +# untrusted names the browser image, which the installer kept from the medium: Firefox opens a +# page of the zone's own, and its window is drawn as untrusted's. It starts on a tag nothing is +# on: mapped beside the launcher, which has the keyboard, the window would wait to be picked, +# and no key pressed at a set time can wait out a start that takes Firefox many seconds. +echo "GT KEY-VIEW-BROWSER" +wait_for 10 grep -qx 'zone=-' "$RT/kryptik/focus" || info "browser: a window still shows after Alt+3: $(tr '\n' ' ' < "$RT/kryptik/focus")" +mark firefox untrusted +launch_plain untrusted "sh -c 'printf \"kryptik-page

kryptik

\" > /tmp/page.html && exec firefox --new-instance file:///tmp/page.html'" > "$LOG/launch-firefox.out" 2>&1 & +ff_page() { grep -q '^title=.*kryptik-page' "$RT/kryptik/focus" && grep -qx 'zone=untrusted' "$RT/kryptik/focus"; } +ff_broke="" +if wait_for 120 ff_page; then + pass "browser-window" "Firefox from the browser image opened a page in untrusted: $(grep '^title=' "$RT/kryptik/focus")" + # Its idle startup tasks and its startup cache's first write come later, the write at most + # 60 s after the cache's last entry: a call the zone's filter kills there ends Firefox after + # its window has shown. + sleep 90 + if ff_page; then + pass "browser-stays-up" "Firefox's window is still untrusted's 90 s after its page showed" + else + ff_broke="browser-stays-up" + fail "browser-stays-up" "focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null); $(since_mark firefox untrusted | tail -5 | tr '\n' ' '); kernel: $(dmesg 2>/dev/null | grep -E 'type=1326|seccomp' | tail -2 | tr '\n' ' ')" + fi +else + # A start that shows nothing prints nothing in the zone either. The host prints FAIL lines + # alone, so this one carries what the proxy refused, what the zone's filter killed, and + # whether Firefox still runs and where it waits. + ff_broke="browser-window" + fail "browser-window" "focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null); zone: $(since_mark firefox untrusted | tail -5 | tr '\n' ' '); proxy: $(tail -4 "$plog" 2>/dev/null | tr '\n' ' '); kernel: $(dmesg 2>/dev/null | grep -E 'type=1326|seccomp|segfault|traps:' | tail -4 | tr '\n' ' '); firefox: $(ps -eo pid=,stat=,wchan:24=,etime=,args= 2>/dev/null | grep '[f]irefox' | head -8 | cut -c1-120 | tr '\n' ';'); launch: $(tail -3 "$LOG/launch-firefox.out" 2>/dev/null | tr '\n' ' ')" +fi +stop_zone untrusted +echo "GT KEY-BROWSER-BACK" + # --- the administration login, a console away --------------------------------------------- # Ctrl+Alt+F2 puts tty2's login in front, and Ctrl+Alt+F1 brings the desktop back, which answers # its clients and takes keys as before. Last, so a desktop that did not come back ends no round. @@ -1543,6 +1577,27 @@ else fi pkill -n -u "$USER_NAME" -f 'havoc /usr/bin/kryptik-chrome --menu' 2>/dev/null +# --- what untrusted's filter refuses Firefox, after a browser check failed ------------------ +# A kill's audit line names one call. Here the filter notifies instead: Firefox runs as the user +# in zone 0 under `kryptikd seccomp-trace --zone untrusted`, in a mount and pid namespace of its +# own with untrusted's /usr set bound over /usr and no render or sound node, so one round names +# every call the filter refuses. A soft refusal gets the errno a zone gets, a kill ENOSYS, and +# Firefox goes on. After the last key step: the window it maps could take a key meant for a check. +if [[ -n "$ff_broke" ]]; then + ffset="$(awk '$1 == "kryptik-usr" && $2 ~ /browser-/ { print $2 }' /proc/self/mounts | tail -1)" + ff_calls="" + if [[ -n "$ffset" ]]; then + ff_calls="$(FFSET="$ffset" FFUSER="$USER_NAME" FFRT="$RT" timeout -k 5 120 \ + unshare -m -p -f --mount-proc --kill-child sh -c ' + mount --bind "$FFSET" /usr || exit 1 + for d in /dev/dri /dev/snd; do [ -d "$d" ] && mount -t tmpfs -o mode=0755 none "$d"; done + exec su -s /bin/bash "$FFUSER" -c "h=\$(mktemp -d) && cd \$h && printf \"kryptik-page

kryptik

\" > page.html && HOME=\$h XDG_RUNTIME_DIR=$FFRT WAYLAND_DISPLAY=wayland-0 timeout -k 5 60 /usr/bin/kryptikd seccomp-trace --zones /usr/lib/kryptik/zones --zone untrusted -- firefox --new-instance file://\$h/page.html"' 2>&1 \ + | sed -n 's/^.*KRYPTIK_SECCOMP_DENIED //p' | sort | uniq -c | sort -rn \ + | awk '{ n = $1; $1 = ""; printf "%s x%d;", $0, n }')" + fi + fail "browser-window-trace" "${ff_broke} failed; untrusted's filter, notifying instead of killing, refused Firefox in zone 0 over ${ffset:-no browser /usr set}:${ff_calls:- nothing}" +fi + # --- teardown ------------------------------------------------------------------------------ # The runtime tmpfs does not survive power-off: copy the session log and the # last focus record to the state partition (p4, log/kryptik/) for a post-mortem. diff --git a/build/guest-tests/image-probe.sh b/build/guest-tests/image-probe.sh new file mode 100755 index 00000000..13adfe60 --- /dev/null +++ b/build/guest-tests/image-probe.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# For the update suite, as root in the guest: a zone granted the probe image's call runs +# /usr/bin/kryptik-probe from the image the running root's list names, wherever that list came from. +# Prints one line: "IMAGE-PROBE ", or "IMAGE-PROBE FAILED ". +set -u +d=/root/zt/image-probe +mkdir -p "$d/policy" +printf 'allow-syscall mlockall\n' > "$d/policy/probe.seccomp" +cat > "$d/upimg.toml" <<'EOF' +[zone] +name = "upimg" +description = "software image check" +[network] +mode = "none" +[storage] +mode = "ephemeral" +size = "64M" +[policy] +seccomp = "policy/probe.seccomp" +[software] +images = "probe" +[identity] +uid_base = 4128768 +[ui] +border_color = "#0a1b5c" +EOF +out="$(timeout -k 5 60 /usr/bin/kryptikd run upimg --zones "$d" --rootfs /var/lib/kryptik/zones -- /usr/bin/kryptik-probe 2>&1)" +line="$(grep -m1 '^kryptik-probe 1.0, from the probe image: ' <<<"$out")" +if [[ -n "$line" ]]; then + echo "IMAGE-PROBE $line" +else + echo "IMAGE-PROBE FAILED $(tail -3 <<<"$out" | tr '\n' ' ')" +fi diff --git a/build/guest-tests/zones-check.sh b/build/guest-tests/zones-check.sh index d67164e4..3f1b8bef 100755 --- a/build/guest-tests/zones-check.sh +++ b/build/guest-tests/zones-check.sh @@ -494,9 +494,17 @@ grep -q PERSONAL-UP "$LOG/personal-bg.out" && pass "encrypted-zone-start" "perso # with the storage checks). It must find that launcher's command line, or # finding no passphrase says nothing. The [e] and [s] keep a grep's own # command line from matching. -pp_seen=0; pp_leak=0 +# leaks PATTERN: the files under /run/kryptik and the command lines that hold +# it. The signed images a zone stacks are mounted under /run/kryptik/images and +# /run/kryptik/usr (images.rs) and hold only what was signed, a browser's whole +# tree among it, so they are not searched. +leaks() { + { find /run/kryptik \( -path /run/kryptik/images -o -path /run/kryptik/usr \) -prune -o -type f -print0 2>/dev/null + printf '%s\0' /proc/[0-9]*/cmdline; } | xargs -0r grep -ls -- "$1" 2>/dev/null +} +pp_seen=0 grep -qs 'passphrase-fil[e]' /proc/[0-9]*/cmdline && pp_seen=1 -grep -rqs 'personal-pas[s]' /run/kryptik /proc/[0-9]*/cmdline && pp_leak=1 +pp_leak="$(leaks 'personal-pas[s]')" # An echo that gets no answer shows separation only while personal holds the # address that was tried, and untrusted has a path: it reaches the bridge. per_init="$(cut -d' ' -f1 /run/kryptik/zones/personal/init.pid 2>/dev/null)" @@ -1497,27 +1505,28 @@ else fi # --- zones: resource limits and lifecycle ----------------------------------- -STORM='import os, time +# Threads, which pids.max counts as it counts processes: as many forked Pythons as the browser's +# limit would fill the VM's memory, which untrusted's memory_max does not bound below. Daemon +# threads, so every one ends as the interpreter exits after its line. +STORM='import threading, time +threading.stack_size(65536) n = 0 -for i in range(3000): +for i in range(10000): try: - p = os.fork() - except BlockingIOError: + threading.Thread(target=time.sleep, args=(300,), daemon=True).start() + except RuntimeError: break - if p == 0: - time.sleep(300); os._exit(0) n += 1 -print("FORKED=%d" % n) +print("STARTED=%d" % n) print("LIMIT-SURVIVED")' -# Python, as bash retries a failed fork for about 15 s and would not reach pids.max in time. zrun untrusted 60 -- /usr/bin/python3 -c "$STORM" if [[ "$ZOUT" == *LIMIT-SURVIVED* ]]; then - forked="$(grep -o 'FORKED=[0-9]*' "$LOG/untrusted.out" | cut -d= -f2)" + started="$(grep -o 'STARTED=[0-9]*' "$LOG/untrusted.out" | cut -d= -f2)" limit="$(sed -n 's/^pids_max *= *\([0-9]*\).*/\1/p' "$Z/untrusted.toml")" # At most the limit, and at least half of it, or the check proves nothing. - [[ -n "$forked" && "$forked" -le "${limit:-1024}" && "$forked" -ge $(( ${limit:-1024} / 2 )) ]] && pass "pids-limit" "bounded at pids_max=$limit (forked $forked before EAGAIN), zone survived" || fail "pids-limit" "forked=$forked limit=$limit" + [[ -n "$started" && "$started" -le "${limit:-1024}" && "$started" -ge $(( ${limit:-1024} / 2 )) ]] && pass "pids-limit" "bounded at pids_max=$limit (started $started threads before EAGAIN), zone survived" || fail "pids-limit" "started=$started limit=$limit" else - fail "pids-limit" "the zone did not survive the fork storm: $(tail -2 "$LOG/untrusted.err" | tr '\n' ' ')" + fail "pids-limit" "the zone did not survive the thread storm: $(tail -2 "$LOG/untrusted.err" | tr '\n' ' ')" fi # dd's own status and words: a tmpfs that took all 3000 MiB is not bounded. zrun untrusted 60 -- sh -c 'out=$(dd if=/dev/zero of=$HOME/big bs=1M count=3000 2>&1); echo "DD-RC=$?"; echo "$out" | grep -o "No space left on device" | head -1; rm -f $HOME/big; echo TMPFS-SURVIVED' @@ -1660,10 +1669,11 @@ grep -qE 'VAULT-PING rc=2 ' <<<"$ZOUT" && pass "vault-ping" "ping in an offline [[ "$ZOUT" == *VAULT-ISOLATED* && "$ZOUT" == *VAULT-WROTE* && "$ZOUT" == *LINKS=0* ]] && pass "vault-offline" "vault has loopback only, no path to the bridge, and keeps data" || fail "vault-offline" "$(tr '\n' ' ' <<<"$ZOUT") $(tail -1 "$LOG/vault.err")" # No passphrase on any command line or in the registry: while personal ran # (above), and now that every zone that took one has stopped. +pp_late="$(leaks 'personal-pas[s]\|vault-pas[s]\|dev-pas[s]')" if [[ "$pp_seen" != 1 ]]; then fail "no-passphrase-leak" "the search did not see personal's launcher while it ran, so it proves nothing" -elif [[ "$pp_leak" = 1 ]] || grep -rqs 'personal-pas[s]\|vault-pas[s]\|dev-pas[s]' /run/kryptik /proc/[0-9]*/cmdline; then - fail "no-passphrase-leak" "a passphrase appeared in the registry or a command line" +elif [[ -n "$pp_leak$pp_late" ]]; then + fail "no-passphrase-leak" "a passphrase appeared in the registry or a command line: $(printf '%s\n' $pp_leak $pp_late | sort -u | tr '\n' ' ')" else pass "no-passphrase-leak" "no passphrase in /run/kryptik or any command line, while personal ran and after the zones stopped" fi @@ -1731,6 +1741,135 @@ else fail "microcode-notice" "at boot: ${mc_boot:-none}; after the kernel's line: ${mc_told:-none}" fi +# --- software images over a zone's /usr (images.rs) ------------------------- +# The list stage 06 signed for this root, the probe image it names and its two +# controls, from the disk zones-test attached, kept under the names images.rs +# reads; then zones of this check's own: one granted the probe's call, one not, +# and one that names no image. +IMGS=/var/lib/kryptik/images; IZ=/root/zt/image-zones +# The label tools/image/zones-test.sh gives the disk, within ext4's 16 bytes. +img_dev="$(blkid -L kryptik-zt-img 2>/dev/null)" +mkdir -p /run/zt-images "$IZ/policy" +if [[ -z "$img_dev" ]] || ! mount -o ro "$img_dev" /run/zt-images; then + fail "image-store" "no kryptik-zt-img disk holds the signed images: ${img_dev:-none attached}" +else + D=/run/zt-images + this_root="$(sed -n 's/.*sha256 \([0-9a-f]\{64\}\) .*/\1/p' /proc/cmdline)" + img_base="$(sed -n 's/^base: //p' "$D/images.manifest")" + probe_hash="$(sed -n 's/^image: probe .* root-hash=\([0-9a-f]\{64\}\) .*/\1/p' "$D/images.manifest")" + keep_list() { # keep_list DIR FILE: DIR/FILE and its signature, as the list kept for this root + install -D -m 0644 "$1/$2" "$IMGS/$img_base.manifest" && install -m 0644 "$1/$2.sig" "$IMGS/$img_base.manifest.sig" + } + if [[ -n "$this_root" && "$img_base" == "$this_root" && -n "$probe_hash" ]] && keep_list "$D" images.manifest \ + && install -D -m 0644 "$D/probe.img" "$IMGS/probe/$probe_hash.img"; then + pass "image-store" "the list for this root (${img_base:0:12}...) and the probe image (${probe_hash:0:12}...) kept" + else + fail "image-store" "list base ${img_base:-none}, probe ${probe_hash:-none}; this root ${this_root:-unknown}" + fi + izone() { # izone NAME UID_BASE COLOUR [IMAGES] [POLICY] + { + printf '[zone]\nname = "%s"\ndescription = "software image check"\n' "$1" + printf '[network]\nmode = "none"\n[storage]\nmode = "ephemeral"\nsize = "64M"\n' + [[ -z "${5:-}" ]] || printf '[policy]\nseccomp = "%s"\n' "$5" + [[ -z "${4:-}" ]] || printf '[software]\nimages = "%s"\n' "$4" + printf '[identity]\nuid_base = %s\n[ui]\nborder_color = "%s"\n' "$2" "$3" + } > "$IZ/$1.toml" + } + printf 'allow-syscall mlockall\n' > "$IZ/policy/imgok.seccomp" + izone imgok 3932160 '#0a1b2c' probe policy/imgok.seccomp + izone imgbare 3997696 '#0a1b3c' probe + izone imgnone 4063232 '#0a1b4c' + irun() { # irun ZONE CMD...: sets ZRC, ZOUT and ZERR; IRUN_SECS bounds it (60 s) + local zone="$1"; shift + timeout -k 5 "${IRUN_SECS:-60}" "$KD" run "$zone" --zones "$IZ" --rootfs "$R" -- "$@" > "$LOG/$zone.out" 2> "$LOG/$zone.err" + ZRC=$?; ZOUT="$(cat "$LOG/$zone.out")"; ZERR="$(cat "$LOG/$zone.err")" + } + irun imgok /usr/bin/kryptik-probe + if [[ "$ZRC" -eq 0 && "$ZOUT" == "kryptik-probe 1.0, from the probe image: /usr/bin/kryptik-probe (mlockall: "* ]]; then + pass "image-runs" "a zone granted the probe's call runs it from the image over its /usr: $ZOUT" + else + fail "image-runs" "rc ${ZRC}: ${ZOUT:-no output}; $(tail -3 <<<"$ZERR" | tr '\n' ' ')" + fi + irun imgbare /usr/bin/kryptik-probe + if [[ "$ZRC" -ne 0 && -z "$ZOUT" && "$ZERR" == *'needs `allow-syscall mlockall`'* ]]; then + pass "image-needs-refused" "a zone whose policy does not grant the image's call is refused before it starts" + else + fail "image-needs-refused" "rc ${ZRC}: ${ZOUT:-no output}; $(tail -2 <<<"$ZERR" | tr '\n' ' ')" + fi + irun imgnone sh -c 'test -e /usr/bin/kryptik-probe && echo PRESENT || echo ABSENT' + [[ "$ZOUT" == ABSENT ]] && pass "image-hidden" "a zone that names no image does not see the probe" \ + || fail "image-hidden" "rc ${ZRC}: ${ZOUT:-no output}; $(tail -2 <<<"$ZERR" | tr '\n' ' ')" + expl="$("$KD" explain imgok --zones "$IZ" 2>&1)" + [[ "$expl" == *"/usr (with the images probe over it, signed for this root and opened with dm-verity)"* ]] \ + && pass "image-explained" "explain names the image over the zone's /usr" \ + || fail "image-explained" "$(grep -m1 '^root ' <<<"$expl")" + # The controls stage 06 signed: a list in the release manifests' namespace, and one for another root. + keep_list "$D/controls/release-ns" manifest && irun imgok /usr/bin/kryptik-probe + if [[ "$ZRC" -ne 0 && "$ZERR" == *"the image list for this root is refused"* ]]; then + pass "image-list-namespace" "a list the release key signed in kryptik-release is refused" + else + fail "image-list-namespace" "rc ${ZRC}: ${ZOUT:-no output}; $(tail -2 <<<"$ZERR" | tr '\n' ' ')" + fi + keep_list "$D/controls/other-base" manifest && irun imgok /usr/bin/kryptik-probe + if [[ "$ZRC" -ne 0 && "$ZERR" == *"is for another root"* ]]; then + pass "image-list-other-base" "a signed list for another root is refused" + else + fail "image-list-other-base" "rc ${ZRC}: ${ZOUT:-no output}; $(tail -2 <<<"$ZERR" | tr '\n' ' ')" + fi + keep_list "$D" images.manifest + # A byte of the probe program flipped in the stored image: with the cache dropped, dm-verity + # turns the read into an I/O error in the zone, and the image is refused from then on. + blk="$(debugfs -R 'bmap /usr/bin/kryptik-probe 0' "$IMGS/probe/$probe_hash.img" 2>/dev/null | tail -1)" + if [[ "$blk" =~ ^[0-9]+$ ]] && printf '\xa5' | dd of="$IMGS/probe/$probe_hash.img" bs=1 seek=$(( blk * 4096 + 64 )) conv=notrunc,fsync status=none; then + echo 3 > /proc/sys/vm/drop_caches + irun imgok /usr/bin/kryptik-probe + rc1="$ZRC"; said1="$ZOUT $ZERR" + irun imgok /usr/bin/kryptik-probe + # EIO in either C library's words: kryptikd is static musl ("I/O error"), the image's + # programs are glibc's ("Input/output error"). + if [[ "$rc1" -ne 0 && ( "$said1" == *"Input/output error"* || "$said1" == *"I/O error"* ) \ + && "$ZRC" -ne 0 && "$ZERR" == *"read back other than signed"* ]]; then + pass "image-corrupt" "a flipped block reads as an I/O error in the zone, and the next start is refused" + else + fail "image-corrupt" "first rc ${rc1}: $(tail -2 <<<"$said1" | tr '\n' ' '); then rc ${ZRC}: $(tail -2 <<<"$ZERR" | tr '\n' ' ')" + fi + else + fail "image-corrupt" "could not find or flip the probe's first block (${blk:-none})" + fi + # The toolkit image over a zone's /usr: its fontconfig finds the root's fonts, every library GTK + # loads resolves in the zone, with no display to open, and GTK's settings schemas were compiled + # at install, without which its file chooser aborts. + tk_hash="$(sed -n 's/^image: toolkit .* root-hash=\([0-9a-f]\{64\}\) .*/\1/p' "$D/images.manifest")" + if [[ -n "$tk_hash" ]] && install -D -m 0644 "$D/toolkit.img" "$IMGS/toolkit/$tk_hash.img"; then + izone imgtk 4194304 '#0a1b6c' toolkit + irun imgtk sh -c 'fc-match sans; gtk-query-immodules-3.0 | head -1; gsettings list-schemas | grep -x org.gtk.Settings.FileChooser' + if [[ "$ZRC" -eq 0 && "$ZOUT" == *"DejaVu Sans"* && "$ZOUT" == *"Input Method Modules file"* && "$ZOUT" == *"org.gtk.Settings.FileChooser"* ]]; then + pass "image-toolkit" "a zone naming the toolkit loads GTK, finds its fonts and reads its compiled schemas: $(head -1 <<<"$ZOUT")" + else + fail "image-toolkit" "rc ${ZRC}: ${ZOUT:-no output}; $(tail -3 <<<"$ZERR" | tr '\n' ' ')" + fi + else + fail "image-toolkit" "the list names no toolkit image, or it could not be kept (${tk_hash:-none})" + fi + # The browser image over the toolkit's, which a zone naming it must name too: Firefox draws a + # page of the zone's own with no display, through its content process, its sandbox left on. + ff_hash="$(sed -n 's/^image: browser .* root-hash=\([0-9a-f]\{64\}\) .*/\1/p' "$D/images.manifest")" + if [[ -n "$ff_hash" && -n "$tk_hash" ]] && install -D -m 0644 "$D/browser.img" "$IMGS/browser/$ff_hash.img"; then + # Firefox's content sandbox installs a filter of its own, which the image's needs line asks for. + printf 'allow-syscall seccomp\n' > "$IZ/policy/imgff.seccomp" + izone imgff 4259840 '#1b0a6c' "browser toolkit" policy/imgff.seccomp + IRUN_SECS=180 irun imgff sh -c 'printf "

kryptik

" > /tmp/page.html && cd /tmp && HOME=/tmp firefox --headless --screenshot /tmp/shot.png file:///tmp/page.html && test -s /tmp/shot.png && echo SHOT-TAKEN' + if [[ "$ZRC" -eq 0 && "$ZOUT" == *"SHOT-TAKEN"* ]]; then + pass "image-browser" "a zone naming the browser and the toolkit draws a page with Firefox, headless" + else + fail "image-browser" "rc ${ZRC}: $(tail -3 <<<"$ZOUT" | tr '\n' ' '); $(tail -5 <<<"$ZERR" | tr '\n' ' ')" + fi + else + fail "image-browser" "the list names no browser image, or it could not be kept (${ff_hash:-none})" + fi + umount /run/zt-images +fi + echo "ZT SUMMARY passed=$PASS failed=$FAIL" echo "ZT END" [[ "$FAIL" -eq 0 ]] diff --git a/build/images/browser.accepted b/build/images/browser.accepted new file mode 100644 index 00000000..c24e9269 --- /dev/null +++ b/build/images/browser.accepted @@ -0,0 +1,15 @@ +# Findings of tools/check-artifact-hardening.sh the browser image keeps, in the form of +# build/config/artifact-accepted.txt. Stage 06 audits the image against this list alone. + +# No CET note: the linker marks a library for IBT and SHSTK only when every object in it is marked. +NO-CET usr/lib/firefox/libxul.so # its Rust code: stable rustc marks nothing for CET, nor does the std it links +NO-CET usr/lib/firefox/libnspr4.so # NSPR's hand-written x86-64 assembly (os_Linux_x86_64.s) carries no CET note +NO-CET usr/lib/firefox/libfreeblpriv3.so # freebl's hand-written x86-64 assembly (arcfour-amd64-gas.s, mpi_amd64_common.S) carries no CET note +NO-CET usr/lib/firefox/libgkcodecs.so # libvpx's and libaom's NASM assembly, whose x86inc.asm writes no CET note + +# No canary anywhere in the object's code. -fstack-protector-strong (in Firefox's own hardening +# flags and stage 04's CFLAGS) guards only functions with a local array or an address-taken +# local; these have none. +NO-SSP usr/lib/firefox/libmozgtk.so # one function, which only names GTK for the linker +NO-SSP usr/lib/firefox/libmozwayland.so # stubs of libwayland-client's functions, each returning at once +NO-SSP usr/lib/firefox/libplc4.so # NSPR's string, base64, option and error helpers diff --git a/build/images/browser.needs b/build/images/browser.needs new file mode 100644 index 00000000..98496ab9 --- /dev/null +++ b/build/images/browser.needs @@ -0,0 +1 @@ +allow-syscall seccomp diff --git a/build/images/browser.programs b/build/images/browser.programs new file mode 100644 index 00000000..01fbbaaf --- /dev/null +++ b/build/images/browser.programs @@ -0,0 +1,9 @@ +# The browser image's executable files as it ships, its .strip list's left out, as +# build/config/image-programs.txt names the root's: from Firefox 153's packaging manifest, which +# for a GTK build on Linux lists no plugin-container and no ClearKey module. +usr/lib/firefox/firefox +usr/lib/firefox/firefox-bin +usr/lib/firefox/glxtest +usr/lib/firefox/vaapitest +usr/lib/firefox/vulkantest +usr/lib/firefox/*.so diff --git a/build/images/browser.sh b/build/images/browser.sh new file mode 100644 index 00000000..bcbe2bcf --- /dev/null +++ b/build/images/browser.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# The browser image: Firefox ESR for Wayland alone, over the toolkit image, built with the rust, +# clang, cbindgen, nasm and node layers, which it does not carry (docs/design/browser-and-graphics.md). +# Its own copies of NSS, NSPR, libvpx, dav1d and opus stay bundled. Its PDF viewer is the document +# viewer. Pins in build/images/versions.env. +# shellcheck disable=SC2034 # read by build/stages/04-images.sh +image_version="${V_FIREFOX}esr" +# shellcheck disable=SC2034 +image_over=(toolkit rust clang cbindgen nasm node) +# shellcheck disable=SC2034 +image_rows=( + "firefox" "s_browser_firefox" + "licences" "s_browser_licences" +) + +# Stage 04's GCC and hardening flags; libclang only for bindgen. With no network: Firefox's own vendored +# crates and Python packages, and no toolchain bootstrap. No D-Bus, which no zone has, no updater +# (updates are the image's), no crash reporter, no EME, no jemalloc (hardened_malloc is preloaded). Sound +# through ALSA, whose default device in a zone is zone 0's mixer; Web MIDI links ALSA's sequencer, which +# reaches no device, as the kernel builds none. The first build keeps no wasm sandbox for its libraries. +# No debug symbols: stage 06 strips them anyway, and linking libxul with them would need more than the +# runner has. +s_browser_firefox() { + local src llvm="/usr/lib/llvm-${V_LLVM%%.*}" gccdir + # bindgen's libclang finds GCC's C++ headers only under triples it knows, and this GCC's is + # x86_64-kryptik-linux-gnu: it is named, and a header parsed here first, not an hour into the build. + gccdir="$(dirname "$(gcc -print-libgcc-file-name)")" + echo '#include ' | "${llvm}/bin/clang" -x c++ -fsyntax-only --gcc-install-dir="$gccdir" - + src="$(unpack "firefox-${V_FIREFOX}esr.source.tar.xz" "firefox-${V_FIREFOX}")" + cd "$src" + # What a GCC 14 build of this release needs (build/patches/firefox-*/README.md). + apply_repo_patches "firefox-${V_FIREFOX}" + # The build's Glean step caches what it parses through Python's sqlite3, and the base system's + # Python has none, as zone 0 has no SQLite: the module is built from that Python's own source + # and Firefox's copy of SQLite, for this build alone (browser.strip keeps it out of the image). + local py pysrc + py="$(python3 -c 'import platform; print(platform.python_version())')" + pysrc="$(unpack "Python-${py}.tar.xz" "Python-${py}")" + gcc -O2 -fPIC -shared -DSQLITE_OMIT_LOAD_EXTENSION \ + -I"$(python3 -c 'import sysconfig; print(sysconfig.get_path("include"))')" -I"${src}/third_party/sqlite3/src" \ + "${pysrc}"/Modules/_sqlite/*.c "${src}/third_party/sqlite3/src/sqlite3.c" \ + -o "$(python3 -c 'import sysconfig; print(sysconfig.get_path("platstdlib") + "/lib-dynload/_sqlite3" + sysconfig.get_config_var("EXT_SUFFIX"))')" + rm -rf "$pysrc" + python3 -c 'import sqlite3; sqlite3.connect(":memory:").execute("create table t (x)")' + # Configure requires unzip, which only packaging runs (make_unzip.py: `unzip ARCHIVE`, into the + # current directory) and the base system does not have: Python's zipfile does that, here alone. + # And llvm-objdump, which it finds by asking the compiler when that is clang: GCC's, it is named. + # And Node.js, which the build runs for the devtools debugger's modules whatever --disable-nodejs + # says: not on the PATH, it is named, and configure stops at once if it is wrong. + cat > kryptik-unzip <<'EOF' +#!/usr/bin/env python3 +import sys, zipfile +args = [a for a in sys.argv[1:] if a not in ("-q", "-o", "-qo", "-oq")] +dest = "." +if len(args) == 3 and args[1] == "-d": + dest = args.pop(2) + args.pop(1) +if len(args) != 1: + sys.exit("kryptik-unzip: only ARCHIVE [-d DIR]: " + " ".join(sys.argv[1:])) +zipfile.ZipFile(args[0]).extractall(dest) +EOF + chmod 0755 kryptik-unzip + cat > mozconfig < /usr/lib/firefox/distribution/policies.json <<'EOF' +{ + "policies": { + "DisableTelemetry": true, + "DisableFirefoxStudies": true, + "DisableAppUpdate": true, + "DontCheckDefaultBrowser": true, + "OverrideFirstRunPage": "", + "OverridePostUpdatePage": "", + "NoDefaultBookmarks": true, + "UserMessaging": { + "ExtensionRecommendations": false, + "FeatureRecommendations": false, + "UrlbarInterventions": false, + "SkipOnboarding": true, + "MoreFromMozilla": false, + "FirefoxLabs": false + }, + "FirefoxHome": { + "SponsoredTopSites": false, + "SponsoredStories": false + } + } +} +EOF +} + +s_browser_licences() { + echo "$(source_licences < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list-image browser)) licence files" +} diff --git a/build/images/browser.strip b/build/images/browser.strip new file mode 100644 index 00000000..41e58a12 --- /dev/null +++ b/build/images/browser.strip @@ -0,0 +1,5 @@ +# What the browser image never ships to a zone (stage 06's pack_image): the telemetry ping sender, +# which the policies' DisableTelemetry leaves nothing to send. +usr/lib/firefox/pingsender +# Python's sqlite3 module, which the build's Glean step alone needs (browser.sh). +usr/lib/python3*/lib-dynload/_sqlite3* diff --git a/build/images/cbindgen.sh b/build/images/cbindgen.sh new file mode 100644 index 00000000..b5e42cd3 --- /dev/null +++ b/build/images/cbindgen.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# cbindgen, which writes the C++ headers of Firefox's Rust code, built over the rust layer with no +# network: from the crates its own Cargo.lock names, which tools/fetch-sources.sh fetched and held to +# that lock's hashes. A layer that only builds use; no zone gets it (docs/design/browser-and-graphics.md). +# shellcheck disable=SC2034 # read by build/stages/04-images.sh +image_version="${V_CBINDGEN}" +# shellcheck disable=SC2034 +image_ships=no +# shellcheck disable=SC2034 +image_over=(rust) +# shellcheck disable=SC2034 +image_rows=( + "cbindgen" "s_cbindgen" +) + +s_cbindgen() { + local top crate name + top="$(mktemp -d "${BUILDDIR}/cbindgen.XXXXXX")" + tar -xzf "${KRYPTIK_SOURCES}/cbindgen-${V_CBINDGEN}.crate" -C "$top" + # Each crate as cargo's vendored source: unpacked, beside the hash its lock gives, which cargo checks. + mkdir -p "${top}/vendor" + for crate in "${KRYPTIK_SOURCES}"/images/crates/*.crate; do + name="$(basename "$crate" .crate)" + tar -xzf "$crate" -C "${top}/vendor" + printf '{"files":{},"package":"%s"}\n' "$(sha256sum "$crate" | cut -d' ' -f1)" > "${top}/vendor/${name}/.cargo-checksum.json" + done + mkdir -p "${top}/cbindgen-${V_CBINDGEN}/.cargo" + printf '[source.crates-io]\nreplace-with = "vendored"\n\n[source.vendored]\ndirectory = "%s"\n' "${top}/vendor" \ + > "${top}/cbindgen-${V_CBINDGEN}/.cargo/config.toml" + (cd "${top}/cbindgen-${V_CBINDGEN}" && CARGO_HOME="${top}/cargo-home" cargo build --release --offline --locked) + install -D -m 0755 "${top}/cbindgen-${V_CBINDGEN}/target/release/cbindgen" /usr/bin/cbindgen + rm -rf "$top" + cbindgen --version +} diff --git a/build/images/clang.sh b/build/images/clang.sh new file mode 100644 index 00000000..ec79ac1a --- /dev/null +++ b/build/images/clang.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# LLVM's release build, for the libclang bindgen loads to read Firefox's C++ headers: the library, +# clang's builtin headers, and clang, which names where they are; and llvm-objdump, which Firefox's +# configure requires and asks clang for. A layer that only builds use; no zone gets it +# (docs/design/browser-and-graphics.md). +# shellcheck disable=SC2034 # read by build/stages/04-images.sh +image_version="${V_LLVM}" +# shellcheck disable=SC2034 +image_ships=no +# shellcheck disable=SC2034 +image_rows=( + "libclang" "s_clang_libclang" +) + +# Some 6 GB unpacked: only what bindgen and Firefox's configure read, under /usr/lib/llvm-MAJOR. +s_clang_libclang() { + local top="LLVM-${V_LLVM}-Linux-X64" dir="/usr/lib/llvm-${V_LLVM%%.*}" + mkdir -p "$dir" + # LLVM compresses with a 1 GiB window, past zstd's default limit of 128 MiB. On standard input, + # as zstd skips a file named by a link, and sources/ names an image's sources by links. + zstd -d --long=31 -c < "${KRYPTIK_SOURCES}/LLVM-${V_LLVM}-Linux-X64.tar.zst" \ + | tar --no-same-owner -xf - -C "$dir" --strip-components=1 \ + --wildcards "${top}/bin/clang" "${top}/bin/clang-[0-9]*" "${top}/bin/llvm-objdump" \ + "${top}/lib/libclang.so*" "${top}/lib/clang/*/include/*" + "${dir}/bin/clang" --version + [[ -x "$("${dir}/bin/clang" --print-prog-name=llvm-objdump)" ]] + "${dir}/bin/llvm-objdump" --version +} diff --git a/build/images/nasm.sh b/build/images/nasm.sh new file mode 100644 index 00000000..4cd30063 --- /dev/null +++ b/build/images/nasm.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# nasm, for the assembly of Firefox's AV1 decoder (dav1d). A layer that only builds use; no zone gets +# it (docs/design/browser-and-graphics.md). +# shellcheck disable=SC2034 # read by build/stages/04-images.sh +image_version="${V_NASM}" +# shellcheck disable=SC2034 +image_ships=no +# shellcheck disable=SC2034 +image_rows=( + "nasm" "s_nasm" +) + +s_nasm() { + native_build "nasm-${V_NASM}.tar.xz" "nasm-${V_NASM}" + nasm -v +} diff --git a/build/images/node.sh b/build/images/node.sh new file mode 100644 index 00000000..628b4afe --- /dev/null +++ b/build/images/node.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Node.js's release build, which Firefox's build runs to generate its devtools debugger's modules. A +# layer that only builds use; no zone gets it (docs/design/browser-and-graphics.md). +# shellcheck disable=SC2034 # read by build/stages/04-images.sh +image_version="${V_NODE}" +# shellcheck disable=SC2034 +image_ships=no +# shellcheck disable=SC2034 +image_rows=( + "node" "s_node" +) + +# The program alone, under /usr/lib/node-MAJOR: the build needs neither npm nor the headers. +s_node() { + local top="node-v${V_NODE}-linux-x64" dir="/usr/lib/node-${V_NODE%%.*}" + mkdir -p "$dir" + tar --no-same-owner -xJf "${KRYPTIK_SOURCES}/${top}.tar.xz" -C "$dir" --strip-components=1 "${top}/bin/node" + # A script, not --version, which returns before the engine starts. + "${dir}/bin/node" -e 'console.log(process.version)' +} diff --git a/build/images/probe.needs b/build/images/probe.needs new file mode 100644 index 00000000..cc478b66 --- /dev/null +++ b/build/images/probe.needs @@ -0,0 +1,2 @@ +# The policy lines a zone that names the probe image must have (images.manifest's needs lines). +allow-syscall mlockall diff --git a/build/images/probe.programs b/build/images/probe.programs new file mode 100644 index 00000000..e01edf00 --- /dev/null +++ b/build/images/probe.programs @@ -0,0 +1,2 @@ +# The probe image's executable files, as build/config/image-programs.txt names the root's. +usr/bin/kryptik-probe diff --git a/build/images/probe.sh b/build/images/probe.sh new file mode 100644 index 00000000..3942a43b --- /dev/null +++ b/build/images/probe.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# The probe image: one small program built over the base system, which the zones suite stacks on a +# zone's /usr and runs there. It proves the image stage end to end and ships to no one. +# shellcheck disable=SC2034 # read by build/stages/04-images.sh +image_version="1.0" +# shellcheck disable=SC2034 +image_rows=( + "kryptik-probe" "s_image_probe" +) + +s_image_probe() { + local dir; dir="$(mktemp -d "${BUILDDIR}/probe.XXXXXX")" + # shellcheck disable=SC2086 # the hardening flags are word lists + gcc ${CFLAGS} ${LDFLAGS} -o "${dir}/kryptik-probe" "${KRYPTIK_ROOT}/build/images/probe/kryptik-probe.c" + install -D -m 0755 "${dir}/kryptik-probe" /usr/bin/kryptik-probe + rm -rf "$dir" +} diff --git a/build/images/probe/kryptik-probe.c b/build/images/probe/kryptik-probe.c new file mode 100644 index 00000000..1ded7fcd --- /dev/null +++ b/build/images/probe/kryptik-probe.c @@ -0,0 +1,23 @@ +/* kryptik-probe: a zone that names the probe image runs this from the image's /usr; it says where. It + also locks its memory, a call outside the zone filter's base set, so the image needs a policy line. */ +#include +#include +#include +#include +#include +#include + +int main(void) +{ + char path[PATH_MAX]; + ssize_t n = readlink("/proc/self/exe", path, sizeof path - 1); + + if (n < 0) { + perror("kryptik-probe: /proc/self/exe"); + return 1; + } + path[n] = '\0'; + int locked = mlockall(MCL_CURRENT) == 0; + printf("kryptik-probe 1.0, from the probe image: %s (mlockall: %s)\n", path, locked ? "ok" : strerror(errno)); + return 0; +} diff --git a/build/images/rust.sh b/build/images/rust.sh new file mode 100644 index 00000000..73b764eb --- /dev/null +++ b/build/images/rust.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# The Rust toolchain the browser is built with: rustc, cargo and the standard library for +# x86_64-unknown-linux-gnu, the very tarballs build/config/rust.lock pins for kryptikd. A layer that +# only builds use; no zone gets it (docs/design/browser-and-graphics.md). +# shellcheck disable=SC2034 # read by build/stages/04-images.sh +image_version="${V_RUST}" +# shellcheck disable=SC2034 +image_ships=no +# shellcheck disable=SC2034 +image_rows=( + "rust" "s_rust_toolchain" +) + +# Each component's own installer, under /usr. +s_rust_toolchain() { + local src + for src in "$(unpack "rustc-${V_RUST}-x86_64-unknown-linux-gnu.tar.xz" "rustc-${V_RUST}-x86_64-unknown-linux-gnu")" \ + "$(unpack "cargo-${V_RUST}-x86_64-unknown-linux-gnu.tar.xz" "cargo-${V_RUST}-x86_64-unknown-linux-gnu")" \ + "$(unpack "rust-std-${V_RUST}-x86_64-unknown-linux-gnu.tar.xz" "rust-std-${V_RUST}-x86_64-unknown-linux-gnu")"; do + "${src}/install.sh" --prefix=/usr --disable-ldconfig + done + rustc --version + cargo --version +} diff --git a/build/images/sources.lock b/build/images/sources.lock new file mode 100644 index 00000000..c7b30d00 --- /dev/null +++ b/build/images/sources.lock @@ -0,0 +1,23 @@ +14d2f701eb68fb799001bdea6231048f3990690fa2f406d563555ff8f744daba LLVM-23.1.3-Linux-X64.tar.zst +cca6b6a86e4389d89b370288761ce2385ddeceb10c4412da78689bc30c88af2d at-spi2-core-2.62.1.tar.xz +1c767308174337a74694da0f3ec069c271452163a1ef4540964c50c301f157d4 cairo-1.18.6.tar.xz +ea1de9f9e23107d97ee2b41a72c552f34064a593da503789218387aee59f3ba4 cargo-1.98.1-x86_64-unknown-linux-gnu.tar.xz +2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20 cbindgen-0.29.4.crate +28714a7d4f9b38c7aafbe38022de7775a3d9cb3c1ebf19a567c26eb761e3410b firefox-153.5.0esr.source.tar.xz +4f7b554a38cdf78c033f666c8871f3749e14a094f65a07f630c91ed0b43d35e3 fontconfig-2.18.3.tar.xz +93cd47eb719c325107f2d3cf087d06299e944c3b48a40b55537302304dea1dd5 freetype-2.15.0.tar.xz +6949dcde27d41cebad1fd741fcafc36d55a1020d2d872d4a6eb3914caabbada2 fribidi-1.0.17.tar.xz +919f529512961a12e81cd4b4b466a48c3933469e7f9a310c6513cd4fb252ba3c gdk-pixbuf-2.44.8.tar.xz +93c941aa17d5eb1d53fe838365f29a8b4e539c222a256d974ec8f30fc413e396 glib-2.90.1.tar.xz +80931fa472a77b9a164f6740e3c0b444fac6770054632d35a7ff9d679e5e7b9f gtk-3.24.52.tar.xz +d07a007327277708a2a73ae437887cdbaf282937f6d03ca5467723e9099af586 harfbuzz-14.6.0.tar.xz +db0e50a80aa3bf64bb45cbca5cf9f75efd9348cf2ac690b907435238c3cf81d7 hicolor-icon-theme-0.18.tar.xz +a7ced37f4102b745ac86d6a70a9da399cc139ff168ba6b8002b4d8d43c900c15 libepoxy-1.5.10.tar.gz +2b6e15b06aafb4c0b6e2348124808cbd9b291c647299eaaba2e3202f51ff2f3d libglvnd-v1.7.0.tar.gz +d80dd2a38a37f803cb9b6ac7b14bd6e74ddc3b654780a8380bdf93523fdb4389 libpng-1.6.59.tar.xz +87336eba53b4acfe917424ab5d500d2b0054d9f5148d35c2273ccf2cfb712f0d nasm-3.02.tar.xz +cc2f601cee82d9a5a233302fe307abc600727035fd7d2c3a3571755989e95008 node-v24.22.0-linux-x64.tar.xz +342385b6ca3b7c73455d7c80a13b7dbe4489e00bc3bd4c5bd6ed4dce421e374a pango-1.58.2.tar.xz +53c156e1ba416a20da8e65395daa132da0d80e76910424caca3fcdae7831d384 pcre2-10.49.tar.bz2 +fa3ff450172a16c026944030230c5069947af93c728d9179971d44e5e0cfb561 rust-std-1.98.1-x86_64-unknown-linux-gnu.tar.xz +e974f036b28565f37c0f3bd92ddefa809bee16c04f9dcf07b9ed96e05aaaf7c4 rustc-1.98.1-x86_64-unknown-linux-gnu.tar.xz diff --git a/build/images/toolkit.accepted b/build/images/toolkit.accepted new file mode 100644 index 00000000..94c1027c --- /dev/null +++ b/build/images/toolkit.accepted @@ -0,0 +1,12 @@ +# Findings of tools/check-artifact-hardening.sh the toolkit image keeps, in the form of +# build/config/artifact-accepted.txt. Stage 06 audits the image against this list alone. + +# No canary anywhere in the object's code. -fstack-protector-strong (in CFLAGS, and GCC's default +# here) guards only functions with a local array or an address-taken local; these have none. +NO-SSP usr/bin/fc-list # fontconfig's small tools +NO-SSP usr/bin/fc-pattern # the same +NO-SSP usr/bin/fc-query # the same +NO-SSP usr/bin/fc-scan # the same +NO-SSP usr/bin/gobject-query # GLib's, the same +NO-SSP usr/lib/libcairo-gobject.so.* # the GType registrations of cairo's types +NO-SSP usr/lib/libgthread-2.0.so.* # a stub kept for old programs, g_thread_init alone diff --git a/build/images/toolkit.programs b/build/images/toolkit.programs new file mode 100644 index 00000000..d74558ef --- /dev/null +++ b/build/images/toolkit.programs @@ -0,0 +1,53 @@ +# The toolkit image's executable files as it ships, its .strip list's left out, as build/config/image-programs.txt names the root's. +usr/bin/fc-cache +usr/bin/fc-cat +usr/bin/fc-conflist +usr/bin/fc-genconf +usr/bin/fc-list +usr/bin/fc-match +usr/bin/fc-pattern +usr/bin/fc-query +usr/bin/fc-scan +usr/bin/fc-validate +usr/bin/gapplication +usr/bin/gdbus +usr/bin/gio +usr/bin/gio-querymodules +usr/bin/glib-compile-schemas +usr/bin/gobject-query +usr/bin/gresource +usr/bin/gsettings +usr/bin/gtk-launch +usr/bin/gtk-query-immodules-3.0 +usr/bin/gtk-query-settings +usr/bin/gtk-update-icon-cache +usr/bin/gtk3-demo +usr/bin/gtk3-demo-application +usr/bin/gtk3-icon-browser +usr/bin/gtk3-widget-factory +usr/libexec/gio-launch-desktop +usr/lib/gtk-3.0/3.0.0/printbackends/libprintbackend-file.so +usr/lib/libatk-1.0.so.0.* +usr/lib/libcairo-gobject.so.2.* +usr/lib/libcairo.so.2.* +usr/lib/libepoxy.so.0.* +usr/lib/libfontconfig.so.1.* +usr/lib/libfreetype.so.6.* +usr/lib/libfribidi.so.0.* +usr/lib/libgailutil-3.so.0.* +usr/lib/libgdk-3.so.0.* +usr/lib/libgdk_pixbuf-2.0.so.0.* +usr/lib/libgio-2.0.so.0.* +usr/lib/libgirepository-2.0.so.0.* +usr/lib/libglib-2.0.so.0.* +usr/lib/libgmodule-2.0.so.0.* +usr/lib/libgobject-2.0.so.0.* +usr/lib/libgthread-2.0.so.0.* +usr/lib/libgtk-3.so.0.* +usr/lib/libharfbuzz.so.0.* +usr/lib/libpango-1.0.so.0.* +usr/lib/libpangocairo-1.0.so.0.* +usr/lib/libpangoft2-1.0.so.0.* +usr/lib/libpcre2-8.so.0.* +usr/lib/libpcre2-posix.so.3.* +usr/lib/libpng16.so.16.* diff --git a/build/images/toolkit.sh b/build/images/toolkit.sh new file mode 100644 index 00000000..3dc35c24 --- /dev/null +++ b/build/images/toolkit.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# The toolkit image: GTK 3 for Wayland alone, without X11, D-Bus or GObject introspection, and the +# libraries under it (docs/design/browser-and-graphics.md). The browser image is built over it; its +# demos are the first GUI programs a zone can run. Sources pinned in build/images/versions.env. +# shellcheck disable=SC2034 # read by build/stages/04-images.sh +image_version="${V_GTK3}" +# shellcheck disable=SC2034 +image_rows=( + "pcre2" "s_tk_pcre2" + "glib" "s_tk_glib" + "libpng" "s_tk_libpng" + "freetype" "s_tk_freetype" + "harfbuzz" "s_tk_harfbuzz" + "fontconfig" "s_tk_fontconfig" + "fribidi" "s_tk_fribidi" + "cairo" "s_tk_cairo" + "pango" "s_tk_pango" + "gdk-pixbuf" "s_tk_gdk_pixbuf" + "egl-headers" "s_tk_egl_headers" + "libepoxy" "s_tk_libepoxy" + "atk" "s_tk_atk" + "hicolor" "s_tk_hicolor" + "gtk3" "s_tk_gtk3" + "licences" "s_tk_licences" +) + +# Only what GLib needs: the 8-bit library, without the JIT, which writes code into memory it runs. +s_tk_pcre2() { + native_build "pcre2-${V_PCRE2}.tar.bz2" "pcre2-${V_PCRE2}" --disable-static --enable-unicode \ + --disable-jit --disable-pcre2-16 --disable-pcre2-32 --disable-pcre2grep-libz \ + --disable-pcre2grep-libbz2 --disable-pcre2test-libreadline + rm -f /usr/bin/pcre2grep /usr/bin/pcre2test /usr/bin/pcre2-config /usr/lib/libpcre2-*.la +} + +s_tk_glib() { + meson_build "glib-${V_GLIB}.tar.xz" "glib-${V_GLIB}" \ + -Dselinux=disabled -Dlibmount=disabled -Dlibelf=disabled -Dsysprof=disabled \ + -Dintrospection=disabled -Dman-pages=disabled -Ddocumentation=false -Dtests=false \ + -Dinstalled_tests=false -Ddtrace=disabled -Dsystemtap=disabled -Dglib_debug=disabled +} + +s_tk_libpng() { + native_build "libpng-${V_LIBPNG}.tar.xz" "libpng-${V_LIBPNG}" --disable-static + rm -f /usr/bin/pngfix /usr/bin/png-fix-itxt /usr/bin/libpng-config /usr/bin/libpng16-config \ + /usr/lib/libpng*.la +} + +# Without HarfBuzz, which is built on it, and without brotli and bzip2, which no shipped font needs. +s_tk_freetype() { + meson_build "freetype-${V_FREETYPE}.tar.xz" "freetype-${V_FREETYPE}" \ + -Dharfbuzz=disabled -Dbrotli=disabled -Dbzip2=disabled -Dpng=enabled -Dzlib=system -Dtests=disabled +} + +# The shaping library alone: no subsetter, rasteriser, vector or GPU paths, and no utilities. +s_tk_harfbuzz() { + meson_build "harfbuzz-${V_HARFBUZZ}.tar.xz" "harfbuzz-${V_HARFBUZZ}" \ + -Dglib=enabled -Dfreetype=enabled -Dgobject=disabled -Dcairo=disabled -Dchafa=disabled \ + -Dicu=disabled -Dpng=disabled -Dzlib=disabled -Draster=disabled -Dvector=disabled \ + -Dgpu=disabled -Dgpu_demo=disabled -Dsubset=disabled -Dutilities=disabled \ + -Dtests=disabled -Dintrospection=disabled -Ddocs=disabled -Dbenchmark=disabled +} + +# Its configuration under /usr/etc, as an image adds nothing outside /usr. No system cache is made: a +# zone's fontconfig keeps one in the user's home. +s_tk_fontconfig() { + meson_build "fontconfig-${V_FONTCONFIG}.tar.xz" "fontconfig-${V_FONTCONFIG}" --sysconfdir=/usr/etc \ + -Dtools=enabled -Dcache-build=disabled -Ddoc=disabled -Dtests=disabled -Dnls=disabled \ + -Dxml-backend=expat -Dfontations=disabled + rmdir /var/cache/fontconfig +} + +s_tk_fribidi() { + meson_build "fribidi-${V_FRIBIDI}.tar.xz" "fribidi-${V_FRIBIDI}" -Ddocs=false -Dbin=false -Dtests=false +} + +# PDF and PostScript surfaces for GTK's print-to-file; no X11, and no tracer or script interpreter, +# which zlib would otherwise bring. +s_tk_cairo() { + meson_build "cairo-${V_CAIRO}.tar.xz" "cairo-${V_CAIRO}" \ + -Dxlib=disabled -Dxcb=disabled -Dquartz=disabled -Ddwrite=disabled -Dtee=disabled \ + -Dpng=enabled -Dzlib=enabled -Dfreetype=enabled -Dfontconfig=enabled -Dglib=enabled \ + -Dspectre=disabled -Dsymbol-lookup=disabled -Dlzo=disabled -Dtests=disabled + rm -rf /usr/lib/cairo /usr/bin/cairo-trace + rm -f /usr/lib/libcairo-script-interpreter.so* /usr/lib/pkgconfig/cairo-script-interpreter.pc \ + /usr/include/cairo/cairo-script-interpreter.h +} + +s_tk_pango() { + meson_build "pango-${V_PANGO}.tar.xz" "pango-${V_PANGO}" \ + -Dintrospection=disabled -Dbuild-testsuite=false -Dbuild-examples=false -Dfontconfig=enabled \ + -Dfreetype=enabled -Dcairo=enabled -Dlibthai=disabled -Dxft=disabled -Dsysprof=disabled + rm -f /usr/bin/pango-view /usr/bin/pango-list /usr/bin/pango-segmentation +} + +# PNG alone, built in: GTK's theme and icons are PNG, and every other loader is a parser more. +s_tk_gdk_pixbuf() { + meson_build "gdk-pixbuf-${V_GDK_PIXBUF}.tar.xz" "gdk-pixbuf-${V_GDK_PIXBUF}" \ + -Dpng=enabled -Djpeg=disabled -Dtiff=disabled -Dgif=disabled -Dothers=disabled \ + -Dglycin=disabled -Dandroid=disabled -Dbuiltin_loaders=png -Dgio_sniffing=false \ + -Dthumbnailer=disabled -Dintrospection=disabled -Dman=false -Dtests=false -Dinstalled_tests=false +} + +# The Khronos EGL headers that libepoxy and GTK's Wayland backend compile against, and nothing else of +# libglvnd: no EGL library comes with them, as a zone has no GPU. Its README holds the licences. +s_tk_egl_headers() { + local src; src="$(unpack "libglvnd-v${V_LIBGLVND}.tar.gz" "libglvnd-v${V_LIBGLVND}")" + install -D -m 0644 -t /usr/include/EGL "${src}"/include/EGL/{egl,eglext,eglplatform}.h + install -D -m 0644 -t /usr/include/KHR "${src}/include/KHR/khrplatform.h" + install -D -m 0644 "${src}/README.md" /usr/share/licenses/libglvnd/README.md +} + +# EGL only, loaded when a program asks for GL; software zones never do. +s_tk_libepoxy() { + meson_build "libepoxy-${V_LIBEPOXY}.tar.gz" "libepoxy-${V_LIBEPOXY}" \ + -Dglx=no -Degl=yes -Dx11=false -Dtests=false -Ddocs=false +} + +# ATK alone: the accessibility bridge talks D-Bus, which no zone has. +s_tk_atk() { + local src; src="$(unpack "at-spi2-core-${V_AT_SPI2_CORE}.tar.xz" "at-spi2-core-${V_AT_SPI2_CORE}")" + cd "$src" + apply_repo_patches "at-spi2-core-${V_AT_SPI2_CORE}" + meson setup build --prefix=/usr --buildtype=plain --wrap-mode=nodownload \ + -Datk_only=true -Dintrospection=disabled -Ddocs=false -Dx11=disabled \ + -Dgtk2_atk_adaptor=false -Duse_systemd=false + ninja -C build + ninja -C build install +} + +s_tk_hicolor() { + meson_build "hicolor-icon-theme-${V_HICOLOR_ICON_THEME}.tar.xz" "hicolor-icon-theme-${V_HICOLOR_ICON_THEME}" +} + +# Wayland alone; input methods built in, so no module cache is read; printing to a file only. +s_tk_gtk3() { + meson_build "gtk-${V_GTK3}.tar.xz" "gtk-${V_GTK3}" --sysconfdir=/usr/etc \ + -Dx11_backend=false -Dwayland_backend=true -Dbroadway_backend=false -Dxinerama=no \ + -Dcloudproviders=false -Dprofiler=false -Dtracker3=false -Dcolord=no -Dprint_backends=file \ + -Dintrospection=false -Dgtk_doc=false -Dman=false -Ddemos=true -Dexamples=false -Dtests=false \ + -Dinstalled_tests=false -Dbuiltin_immodules=all + # Property names for interface builders: no program in a zone shows them. + rm -f /usr/share/locale/*/LC_MESSAGES/gtk30-properties.mo +} + +# GLib keeps its texts the REUSE way, in LICENSES/, with no licence file at its top. +s_tk_licences() { + echo "$(source_licences '^[^/]+/LICENSES/[^/]+$' < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list-image toolkit)) licence files" +} diff --git a/build/images/toolkit.strip b/build/images/toolkit.strip new file mode 100644 index 00000000..c08770c3 --- /dev/null +++ b/build/images/toolkit.strip @@ -0,0 +1,26 @@ +# What the toolkit image keeps for the images built over it, the browser's, and never ships to a zone: +# headers and pkg-config files, code generators and test tools, documentation (stage 06's pack_image). +usr/include +usr/lib/glib-2.0/include +usr/lib/pkgconfig +usr/share/aclocal +usr/share/gettext/its +usr/share/glib-2.0/codegen +usr/share/glib-2.0/gdb +usr/share/gdb +usr/share/doc +usr/bin/gdbus-codegen +usr/bin/gdk-pixbuf-csource +usr/bin/gdk-pixbuf-pixdata +usr/bin/gdk-pixbuf-query-loaders +usr/bin/gi-compile-repository +usr/bin/gi-decompile-typelib +usr/bin/gi-inspect-typelib +usr/bin/glib-compile-resources +usr/bin/glib-genmarshal +usr/bin/glib-gettextize +usr/bin/glib-mkenums +usr/bin/gtester +usr/bin/gtester-report +usr/bin/gtk-builder-tool +usr/bin/gtk-encode-symbolic-svg diff --git a/build/images/versions.env b/build/images/versions.env new file mode 100644 index 00000000..39a15e67 --- /dev/null +++ b/build/images/versions.env @@ -0,0 +1,41 @@ +# Pins of the software images' sources (build/images), apart from the root's in +# build/config/versions.env: they never reach the root, so a change here is no +# input of stage 04 and leaves its cache standing. Their checksums are in +# build/images/sources.lock; tools/fetch-sources.sh lists them per image. + +MIRROR_GNOME="https://download.gnome.org/sources" +# Savannah's own copy: the redirector can send a new release to a mirror that lacks it yet. +MIRROR_SAVANNAH_MASTER="https://download-mirror.savannah.gnu.org/releases" +MIRROR_ICON_THEME="https://icon-theme.freedesktop.org/releases" + +# --- toolkit: GTK 3 for Wayland alone, without X11 or D-Bus --- +V_PCRE2=10.49 +V_GLIB=2.90.1 +V_LIBPNG=1.6.59 +V_FREETYPE=2.15.0 +V_HARFBUZZ=14.6.0 +V_FONTCONFIG=2.18.3 +V_FRIBIDI=1.0.17 +V_CAIRO=1.18.6 +V_PANGO=1.58.2 +V_GDK_PIXBUF=2.44.8 +# Its Khronos EGL headers alone, which libepoxy and GTK compile against; the root has no EGL. +V_LIBGLVND=1.7.0 +V_LIBEPOXY=1.5.10 +# ATK, which GTK 3 requires, is built from here without the D-Bus bridge. +V_AT_SPI2_CORE=2.62.1 +V_HICOLOR_ICON_THEME=0.18 +V_GTK3=3.24.52 + +# --- browser: Firefox ESR, over the toolkit and build-only layers no zone gets --- +# The ESR after 140, which ends once the two stop overlapping. Mozilla's key is taken from the KEY file +# beside this release (tools/key-provenance.tsv), so a bump changes both. +V_FIREFOX=153.5.0 +# The compiler kryptikd is built with: build/config/rust.lock holds the same three tarballs. +V_RUST=1.98.1 +# LLVM's release build, for the libclang bindgen loads. +V_LLVM=23.1.3 +V_CBINDGEN=0.29.4 +V_NASM=3.02 +# Node.js's release build, which Firefox's build runs for its devtools: an LTS line. +V_NODE=24.22.0 diff --git a/build/lib/common.sh b/build/lib/common.sh index 5bdb144c..ad138614 100644 --- a/build/lib/common.sh +++ b/build/lib/common.sh @@ -9,6 +9,9 @@ KRYPTIK_WORK="${KRYPTIK_WORK:-${KRYPTIK_ROOT}/build/work}" KRYPTIK_OUT="${KRYPTIK_OUT:-${KRYPTIK_ROOT}/out}" # shellcheck disable=SC2034 # consumed by tools/fetch-sources.sh KRYPTIK_LOCK="${KRYPTIK_ROOT}/sources.lock" +# The software images' sources, locked apart from the root's: no stage 04 input names them. +# shellcheck disable=SC2034 +KRYPTIK_IMAGES_LOCK="${KRYPTIK_ROOT}/build/images/sources.lock" if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m' @@ -152,6 +155,18 @@ LFS stages that need privilege escalate explicitly and narrowly." load_config() { # shellcheck source=/dev/null source "${KRYPTIK_ROOT}/build/config/versions.env" + # The software images' pins, apart for the same reason as their lock. + # shellcheck source=/dev/null + if [[ -f "${KRYPTIK_ROOT}/build/images/versions.env" ]]; then source "${KRYPTIK_ROOT}/build/images/versions.env"; fi +} + +# lock_entry FILE: FILE's hash in sources.lock or the images' lock, or failure. +lock_entry() { + local l + for l in "$KRYPTIK_LOCK" "$KRYPTIK_IMAGES_LOCK"; do + [[ -f "$l" ]] && awk -v f="$1" '$2 == f { print $1; found=1; exit } END { exit !found }' "$l" && return 0 + done + return 1 } load_hardening() { diff --git a/build/lib/recipes.sh b/build/lib/recipes.sh new file mode 100644 index 00000000..facc4937 --- /dev/null +++ b/build/lib/recipes.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# The hardening flags each step builds with, the helpers its recipe calls, and the recipes themselves: +# sourced by stage 04 and the image stage, which build recipes the same way. + +# --- hardening exceptions --------------------------------------------------- + +# The flags to drop for a package, from hardening-exceptions.txt. +exception_flags_for() { + local pkg="$1" f="${KRYPTIK_ROOT}/build/config/hardening-exceptions.txt" + [[ -f "$f" ]] || return 0 + awk -v p="$pkg" '!/^[[:space:]]*#/ && $1 == p { print $2 }' "$f" +} + +# Hardening minus the package's exceptions; a -final row takes its package's. +set_flags_for() { + local pkg="$1" drop + export CFLAGS="${KRYPTIK_OPT} ${KRYPTIK_CFLAGS_HARDENING}" + export CXXFLAGS="${KRYPTIK_OPT} ${KRYPTIK_CFLAGS_HARDENING} ${KRYPTIK_CXXFLAGS_HARDENING}" + export LDFLAGS="${KRYPTIK_LDFLAGS_HARDENING}" + + while read -r drop; do + [[ -z "$drop" ]] && continue + warn "${pkg}: dropping ${drop} (justified exception)" + CFLAGS="${CFLAGS//${drop}/}" + CXXFLAGS="${CXXFLAGS//${drop}/}" + LDFLAGS="${LDFLAGS//${drop}/}" + done < <(exception_flags_for "${pkg%-final}") + + export CFLAGS CXXFLAGS LDFLAGS +} + +# --- step machinery --------------------------------------------------------- + +# The shared step() calls this after printing the tail of a failed log. +step_failure_hint() { + # Two locals: one `local` expands all its words before assigning any. + local name="$1" + local logfile="${LOGS}/${STAMP_PREFIX}${name}.log" + + # The error can be thousands of lines above the tail; show likely causes. + if [[ -f "$logfile" ]]; then + local hits + hits="$(grep -nE '^(make(\[[0-9]+\])?: \*\*\*|.*: \*\*\* )|\[ERROR\]|undefined (symbol|reference)|No such file or directory|Permission denied|command not found|configure: error|fatal error|cannot find -l|ModuleNotFoundError|ImportError|^[A-Za-z_.]*Error:|Could not build' \ + "$logfile" 2>/dev/null | tail -15)" + if [[ -n "$hits" ]]; then + err "" + err "Lines in ${logfile} that look like the actual cause:" + printf '%s\n' "$hits" | sed 's/^/ /' >&2 + fi + fi + + err "" + err "Check the actual error before assuming it is the hardening flags." + err "The first stage 04 failure looked like one and was not - it was a" + err "missing native glibc and no generated locales." + err "" + err "If it IS a hardening incompatibility, add an entry to" + err "build/config/hardening-exceptions.txt WITH a justification, so" + err "only that flag is dropped and only for that package." +} + +# Native build: no --host, as this runs on the target. +native_build() { + local tarball="$1" dirname="$2"; shift 2 + local src; src="$(unpack "$tarball" "$dirname")" + cd "$src" + ./configure --prefix=/usr "$@" + make + make install +} + +# meson packages; the Wayland stack is meson-only. --buildtype=plain leaves the +# flags to the hardening CFLAGS (release adds -O3 and -DNDEBUG), and +# --wrap-mode=nodownload keeps a subproject from fetching unlocked sources. +meson_build() { + local tarball="$1" dirname="$2"; shift 2 + local src; src="$(unpack "$tarball" "$dirname")" + cd "$src" + meson setup build --prefix=/usr --buildtype=plain --wrap-mode=nodownload "$@" + ninja -C build + ninja -C build install +} + +# cmake only generates json-c's build files (cryptsetup needs json-c for LUKS2 +# headers) and stays out of the image. The chroot runs Kitware's binary, pinned +# in sources.lock and never installed, instead of a long source build; where it +# cannot run, s_cmake builds from source with its bundled libraries. Unpacked +# on demand: the build tree is cleared between runs, and a resumed json-c step +# must not rely on a skipped cmake step. +prebuilt_cmake() { + local dir="${BUILDDIR}/cmake-${V_CMAKE}-linux-x86_64" + if [[ ! -x "${dir}/bin/cmake" ]]; then + rm -rf "$dir" + tar -xf "${KRYPTIK_SOURCES}/cmake-${V_CMAKE}-linux-x86_64.tar.gz" -C "$BUILDDIR" + fi + [[ -x "${dir}/bin/cmake" ]] || return 1 + "${dir}/bin/cmake" --version > /dev/null 2>&1 || return 1 + printf '%s' "${dir}/bin/cmake" +} + +# --- recipes ---------------------------------------------------------------- +# One file per step under build/recipes, sourced here; each stage lists the order it builds +# them in. A step's fingerprint is its function's text, wherever it lives. +for recipe in "$(dirname "${BASH_SOURCE[0]}")/../recipes/"*.sh; do + # shellcheck source=/dev/null + source "$recipe" +done diff --git a/build/lib/release-keys.sh b/build/lib/release-keys.sh index 6093babe..53d9a437 100644 --- a/build/lib/release-keys.sh +++ b/build/lib/release-keys.sh @@ -46,7 +46,7 @@ dev_keys() { # 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-release namespaces="kryptik-release,kryptik-media,kryptik-image" %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" @@ -72,7 +72,7 @@ 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, the statement key, in kryptik-latest, and +# kryptik-release, kryptik-media and kryptik-image, 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() { @@ -82,7 +82,7 @@ probe_anchor() { ssh-keygen -q -t ed25519 -N "" -f "$t/foreign" < /dev/null printf 'probe\n' > "$t/probe" for key in "$RELEASE_KEY" "$LATEST_KEY" "$tkey" "$t/foreign"; do - for ns in kryptik-release kryptik-media kryptik-latest kryptik-testctl; do + for ns in kryptik-release kryptik-media kryptik-image kryptik-latest kryptik-testctl; do case "$ns" in kryptik-latest) who=kryptik-latest ;; kryptik-testctl) who=kryptik-testctl ;; @@ -90,7 +90,7 @@ probe_anchor() { esac want=refused case "$key:$ns" in - "$RELEASE_KEY:kryptik-release"|"$RELEASE_KEY:kryptik-media"|"$LATEST_KEY:kryptik-latest"|"$tkey:kryptik-testctl") want=accepted ;; + "$RELEASE_KEY:kryptik-release"|"$RELEASE_KEY:kryptik-media"|"$RELEASE_KEY:kryptik-image"|"$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. @@ -178,8 +178,8 @@ private_ok() { [[ "$owner" == 0 || "$owner" == "$who" ]] || die "$1 belongs to uid ${owner}, not to root or the user running the build (${who})" } -# kryptik-release, held to kryptik-release (manifests) and kryptik-media (the -# media's checksums), kryptik-latest, held to kryptik-latest alone, and +# kryptik-release, held to kryptik-release (manifests), kryptik-media (the +# media's checksums) and kryptik-image (image lists), 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 @@ -191,7 +191,7 @@ anchor_ok() { while read -r p ns t k _; do case "$p" in ""|"#"*) continue ;; esac case "$p" in - kryptik-release) want='namespaces="kryptik-release,kryptik-media"' ;; + kryptik-release) want='namespaces="kryptik-release,kryptik-media,kryptik-image"' ;; kryptik-latest) want='namespaces="kryptik-latest"' ;; kryptik-testctl) want='namespaces="kryptik-testctl"' ;; *) die "${a}: ${p} is neither kryptik-release, kryptik-latest nor kryptik-testctl" ;; diff --git a/build/patches/at-spi2-core-2.62.1/0001-Build-ATK-alone-without-libxml2.patch b/build/patches/at-spi2-core-2.62.1/0001-Build-ATK-alone-without-libxml2.patch new file mode 100644 index 00000000..40f0d3b5 --- /dev/null +++ b/build/patches/at-spi2-core-2.62.1/0001-Build-ATK-alone-without-libxml2.patch @@ -0,0 +1,21 @@ +Subject: [PATCH] Build ATK alone without libxml2 + +Only the accessibility bridge's tests use libxml2, and an atk_only build +makes neither the bridge nor its tests, so it need not require it. +--- + meson.build | 3 ++- + 1 file changed, 2 insertions(+), 1 deletion(-) + +diff --git a/meson.build b/meson.build +--- a/meson.build ++++ b/meson.build +@@ -151,7 +151,8 @@ + endif + endif + gmodule_dep = dependency('gmodule-2.0', version: gmodule_req_version) +-libxml_dep = dependency('libxml-2.0', version: libxml_req_version) ++# Only the bridge's tests use it, and atk_only builds neither. ++libxml_dep = dependency('libxml-2.0', version: libxml_req_version, required: not get_option('atk_only')) + + x11_deps = [] + x11_dep = dependency('x11', required: get_option('x11')) diff --git a/build/patches/at-spi2-core-2.62.1/README.md b/build/patches/at-spi2-core-2.62.1/README.md new file mode 100644 index 00000000..d9b09e4a --- /dev/null +++ b/build/patches/at-spi2-core-2.62.1/README.md @@ -0,0 +1,12 @@ +# at-spi2-core 2.62.1: ATK alone, without libxml2 + +Applied by `s_tk_atk` in the toolkit image (`build/images/toolkit.sh`) +through `apply_repo_patches`; `SHA256SUMS` is verified before anything is +applied. + +GTK 3 requires ATK, which now ships in at-spi2-core. Its `atk_only` option +builds ATK without the D-Bus accessibility bridge, which the toolkit leaves +out, but `meson.build` still requires libxml2 at the top level, though only +the bridge's tests use it. The patch makes it optional in an `atk_only` +build, so the image carries no XML parser for nothing. Delete this directory +when the pin moves to a release whose `meson.build` does the same. diff --git a/build/patches/at-spi2-core-2.62.1/SHA256SUMS b/build/patches/at-spi2-core-2.62.1/SHA256SUMS new file mode 100644 index 00000000..4321f4ac --- /dev/null +++ b/build/patches/at-spi2-core-2.62.1/SHA256SUMS @@ -0,0 +1 @@ +e702461395dc2f7dc0ff8380f63c4089ae60ed2ab654009d5866da6982b73439 0001-Build-ATK-alone-without-libxml2.patch diff --git a/build/patches/firefox-153.5.0/0001-features-h-unwrapped-for-fortify.patch b/build/patches/firefox-153.5.0/0001-features-h-unwrapped-for-fortify.patch new file mode 100644 index 00000000..bbfaff12 --- /dev/null +++ b/build/patches/firefox-153.5.0/0001-features-h-unwrapped-for-fortify.patch @@ -0,0 +1,10 @@ +--- a/config/system-headers.mozbuild ++++ b/config/system-headers.mozbuild +@@ -222,7 +222,6 @@ system_headers = [ + "exe386.h", + "extras.h", + "fcntl.h", +- "features.h", + "fenv.h", + "ffi.h", + "fibdef.h", diff --git a/build/patches/firefox-153.5.0/0002-ffvpx-gcc-incompatible-pointer-types.patch b/build/patches/firefox-153.5.0/0002-ffvpx-gcc-incompatible-pointer-types.patch new file mode 100644 index 00000000..1b371c25 --- /dev/null +++ b/build/patches/firefox-153.5.0/0002-ffvpx-gcc-incompatible-pointer-types.patch @@ -0,0 +1,15 @@ +--- a/media/ffvpx/ffvpxcommon.mozbuild ++++ b/media/ffvpx/ffvpxcommon.mozbuild +@@ -35,11 +35,11 @@ + '-Wno-unused-function', + # XXX This does not seem to have any effect on some versions of GCC. + '-Wno-deprecated-declarations', ++ '-Wno-incompatible-pointer-types', + ] + if CONFIG['CC_TYPE'] in ('clang', 'clang-cl'): + CFLAGS += [ + '-Wno-absolute-value', +- '-Wno-incompatible-pointer-types', + '-Wno-string-conversion', + '-Wno-visibility', + ] diff --git a/build/patches/firefox-153.5.0/0003-cstdint-includes.patch b/build/patches/firefox-153.5.0/0003-cstdint-includes.patch new file mode 100644 index 00000000..7ff91480 --- /dev/null +++ b/build/patches/firefox-153.5.0/0003-cstdint-includes.patch @@ -0,0 +1,21 @@ +--- a/ipc/glue/SharedMemoryHandle.h ++++ b/ipc/glue/SharedMemoryHandle.h +@@ -5,6 +5,7 @@ + #ifndef mozilla_ipc_SharedMemoryHandle_h + #define mozilla_ipc_SharedMemoryHandle_h + ++#include + #include + + #include "mozilla/UniquePtrExtensions.h" +--- a/modules/woff2/include/woff2/output.h ++++ b/modules/woff2/include/woff2/output.h +@@ -10,6 +10,7 @@ + #define WOFF2_WOFF2_OUT_H_ + + #include ++#include + #include + #include + #include + diff --git a/build/patches/firefox-153.5.0/0004-remote-needs-dbus-or-x11.patch b/build/patches/firefox-153.5.0/0004-remote-needs-dbus-or-x11.patch new file mode 100644 index 00000000..5d10e18e --- /dev/null +++ b/build/patches/firefox-153.5.0/0004-remote-needs-dbus-or-x11.patch @@ -0,0 +1,46 @@ +--- a/toolkit/components/remote/moz.build ++++ b/toolkit/components/remote/moz.build +@@ -36,7 +36,7 @@ + "RemoteUtils.h", + ] + CXXFLAGS += CONFIG["MOZ_DBUS_CFLAGS"] +- else: ++ elif CONFIG["MOZ_X11"]: + SOURCES += [ + "nsGTKRemoteServer.cpp", + "nsXRemoteClient.cpp", +--- a/toolkit/components/remote/nsRemoteService.cpp ++++ b/toolkit/components/remote/nsRemoteService.cpp +@@ -7,7 +7,7 @@ + # ifdef MOZ_ENABLE_DBUS + # include "nsDBusRemoteServer.h" + # include "nsDBusRemoteClient.h" +-# else ++# elif defined(MOZ_X11) + # include "nsGTKRemoteServer.h" + # include "nsXRemoteClient.h" + # endif +@@ -195,8 +195,10 @@ + #ifdef MOZ_WIDGET_GTK + # if defined(MOZ_ENABLE_DBUS) + client = MakeUnique(mStartupToken); ++# elif defined(MOZ_X11) ++ client = MakeUnique(mStartupToken); + # else +- client = MakeUnique(mStartupToken); ++ return NS_ERROR_NOT_AVAILABLE; + # endif + #elif defined(XP_WIN) + client = MakeUnique(); +@@ -257,8 +259,10 @@ + #ifdef MOZ_WIDGET_GTK + # if defined(MOZ_ENABLE_DBUS) + mRemoteServer = MakeUnique(); ++# elif defined(MOZ_X11) ++ mRemoteServer = MakeUnique(); + # else +- mRemoteServer = MakeUnique(); ++ return; + # endif + #elif defined(XP_WIN) + mRemoteServer = MakeUnique(); diff --git a/build/patches/firefox-153.5.0/README.md b/build/patches/firefox-153.5.0/README.md new file mode 100644 index 00000000..84977a34 --- /dev/null +++ b/build/patches/firefox-153.5.0/README.md @@ -0,0 +1,26 @@ +# Firefox 153.5.0 ESR: building with GCC 14 + +Applied by `s_browser_firefox` in the browser image through `apply_repo_patches`; +`SHA256SUMS` is verified before anything is applied. + +Mozilla builds Firefox with clang; Kryptik builds it with stage 04's GCC 14, as +every other program is built. The first three are the changes Gentoo's Firefox +153 patch set carries for a GCC build, each a few lines; the fourth is Kryptik's: + +- `0001`: `features.h` leaves the list of system headers Firefox wraps. Wrapped, + glibc's `_FORTIFY_SOURCE` checks do not take effect, which stage 06's audit + would report as objects without FORTIFY. +- `0002`: ffvpx, Firefox's copy of FFmpeg, passes pointers of mismatched types in + places, which GCC 14 makes an error by default; the warning was already + silenced for clang, and is for GCC too. +- `0003`: two headers use fixed-width integer types without including + ``, which newer libstdc++ headers no longer bring in on the way. +- `0004`: without D-Bus, the GTK build takes the X11 remote server and client + (`nsGTKRemoteServer`, `nsXRemoteClient`), which include `gdk/gdkx.h`, and the + toolkit image's GTK has no X11 backend. They are built with X11 only, and a + build with neither gets no remote at all, as on a platform without one: a + second `firefox` with the same profile is not handed to the first, and says + that Firefox is already running. + +The flag stage 04 adds that this build cannot take, `-fno-delete-null-pointer-checks`, +is dropped for Firefox alone in `build/config/hardening-exceptions.txt`. diff --git a/build/patches/firefox-153.5.0/SHA256SUMS b/build/patches/firefox-153.5.0/SHA256SUMS new file mode 100644 index 00000000..915dce98 --- /dev/null +++ b/build/patches/firefox-153.5.0/SHA256SUMS @@ -0,0 +1,4 @@ +25e3af89f5cd1cb9fb97492b8c254c1d122c1f2bd83003af59577288098cce02 0001-features-h-unwrapped-for-fortify.patch +b9bc94714ef801b3450dddfba1a35a7139ed48c9a958378dbda9016a4403ee14 0002-ffvpx-gcc-incompatible-pointer-types.patch +692345659a387cda546bf9aa2a8b19cefce60f3545634607e6a35d526145fd90 0003-cstdint-includes.patch +d68bb973e17677582d8988053518549d8261fe20934b732c12a4138bf9ec8dfb 0004-remote-needs-dbus-or-x11.patch diff --git a/build/recipes/boot-check.sh b/build/recipes/boot-check.sh index 04010f6d..3a75154a 100644 --- a/build/recipes/boot-check.sh +++ b/build/recipes/boot-check.sh @@ -87,6 +87,7 @@ s_boot_check() { chk "zone policies" /usr/lib/kryptik/zones/policy/work.seccomp chk "device helper" /usr/libexec/kryptik/devices.sh x chk "ESP record reader" /usr/libexec/kryptik/esp-records.sh + chk "image store" /usr/libexec/kryptik/image-store.sh chk "boot scripts" /usr/libexec/kryptik/sysinit.sh x chk "test control helper" /usr/libexec/kryptik/testctl.sh chk "keyboard helper" /usr/libexec/kryptik/keyboard.sh diff --git a/build/recipes/licences.sh b/build/recipes/licences.sh index 1400e30c..de53fa63 100644 --- a/build/recipes/licences.sh +++ b/build/recipes/licences.sh @@ -1,9 +1,12 @@ #!/usr/bin/env bash -# Every source's licence files, from its own tarball, under /usr/share/licenses//. -s_licences() { +# Each listed source's licence files, from its own tarball, under /usr/share/licenses//; +# prints how many. The root's sources for stage 04, an image's for that image; ERE adds members. +# shellcheck disable=SC2120 # an image passes the ERE, from its own definition +source_licences() { # source_licences [ERE] < tools/fetch-sources.sh --list-root|--list-image NAME # Also the microcode's lowercase license, firmware's WHENCE and the kernel's LICENSES/. local more_re='^[^/]+/(license|WHENCE|LICENSES/(preferred|exceptions)/[^/]+)$' + [[ -z "${1:-}" ]] || more_re="${more_re}|${1}" local name url f m dir tmp n=0 members while read -r name _ url _; do [[ -n "$name" ]] || continue @@ -24,7 +27,13 @@ s_licences() { fi done rm -rf "$tmp" - done < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list) + done + echo "$n" +} + +s_licences() { + local f n members + n="$(source_licences < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list-root))" # Texts no tarball carries: the CA bundle's MPL, and Rust std, libc and musl in static binaries. for f in "${KRYPTIK_ROOT}"/build/licences/*/*; do diff --git a/build/service-scripts/image-store.sh b/build/service-scripts/image-store.sh new file mode 100755 index 00000000..0c25d8f4 --- /dev/null +++ b/build/service-scripts/image-store.sh @@ -0,0 +1,73 @@ +#!/bin/sh +# Software images on the state partition (docs/design/software-delivery.md), for kryptik-update (a +# release's payload) and kryptik-install (the medium): each list kept under the root hash it is built +# for, each image it names under its own root hash. Sourced; the caller defines say, die and uncache. + +# images_keep LIST SIG STORE BASE READER: keep in STORE the images LIST names, for the root BASE, which +# LIST must be built for. READER NAME BYTES writes image NAME's bytes to stdout, and never more than +# BYTES and one, so a source that changed since it was checked cannot fill the state partition. Each +# copy is checked against the size and hash LIST signs before it takes its name, none takes its name +# until every one has checked, a refusal takes the copies already made with it, and LIST and SIG are +# kept last. kryptikd checks LIST's signature at every use. +images_keep() { + _l="$1"; _s="$2"; _store="$3"; _base="$4"; _read="$5" + [ "$(head -1 "$_l")" = KRYPTIK-IMAGES-1 ] || die "the image list is not a KRYPTIK-IMAGES-1" + [ "$(sed -n 's/^base: //p' "$_l")" = "$_base" ] || die "the image list is for another root than the one it comes with" + mkdir -p "$_store" && chmod 0700 "$_store" || die "cannot make $_store" + _staged="" + while read -r _ _name _ _bytes _sha _ _hash _; do + [ -n "$_name" ] || continue + _bytes="${_bytes#bytes=}"; _sha="${_sha#sha256=}"; _hash="${_hash#root-hash=}" + case "$_name" in ''|*[!a-z0-9-]*) _images_fail "the image list names '$_name'" ;; esac + case "$_bytes" in ''|*[!0-9]*) _images_fail "image $_name: its size in the list is not a number" ;; esac + case "$_hash$_sha" in *[!0-9a-f]*) _images_fail "image $_name: its hashes in the list are not hex" ;; esac + [ "${#_hash}" = 64 ] && [ "${#_sha}" = 64 ] || _images_fail "image $_name: its hashes in the list are not 64 digits" + _f="$_store/$_name/$_hash.img" + if [ -f "$_f" ] && [ "$(stat -c %s "$_f")" = "$_bytes" ] && [ "$(sha256sum "$_f" | cut -c1-64)" = "$_sha" ]; then + say "image $_name: already kept" + continue + fi + mkdir -p "$_store/$_name" || _images_fail "cannot make $_store/$_name" + "$_read" "$_name" "$_bytes" > "$_f.new" \ + || { rm -f "$_f.new"; _images_fail "image $_name could not be read, or written into the store"; } + sync -f "$_f.new" + uncache "$_f.new" + if [ "$(stat -c %s "$_f.new")" != "$_bytes" ] || [ "$(sha256sum "$_f.new" | cut -c1-64)" != "$_sha" ]; then + rm -f "$_f.new" + _images_fail "image $_name is not the bytes its list signs" + fi + _staged="$_staged $_f" + say "image $_name: kept and checked ($_bytes bytes)" + done </dev/null | sed -n 's/^image: \([a-z0-9-]*\) .* root-hash=\([0-9a-f]*\) .*/\1\/\2.img/p')" + for _f in "$_store"/*/*.img "$_store"/*/*.img.new; do + [ -f "$_f" ] || continue + printf '%s\n' "$_wanted" | grep -Fxq -- "${_f#"$_store"/}" || rm -f "$_f" + done +} diff --git a/build/service-scripts/medium-root.sh b/build/service-scripts/medium-root.sh index df506c65..64db95c1 100755 --- a/build/service-scripts/medium-root.sh +++ b/build/service-scripts/medium-root.sh @@ -10,6 +10,9 @@ # V_HASH V_SALT # kernel_names_root EFI the kernel's command line carries V_HASH # root_verifies DEV DEV holds the root V_HASH names +# medium_images_ok DIR OUT the image list in DIR is signed, for V_HASH, and +# every image it names is in DIR at its size; the +# list and signature as checked are put in OUT decimal_field() { # decimal_field NAME VALUE: a bounded decimal, or die case "$2" in ''|*[!0-9]*) die "root.json: $1 is not a number" ;; esac @@ -59,6 +62,16 @@ kernel_names_root() { # kernel_names_root EFI [ -f "$1" ] && grep -a -q -F "$V_HASH" "$1" } +# The software images a medium carries, before anything is written: a list +# signed as one, for the root the signed command line names, naming only images +# DIR holds at the sizes it signs (kryptikd check-images). Each image's hash is +# checked as it is copied (image-store.sh). The list kept is the one checked, from +# OUT: a medium read a second time could answer with other bytes. +medium_images_ok() { # medium_images_ok DIR OUT + _said="$(kryptikd check-images "$1" "$V_HASH" "$2" 2>&1)" \ + || die "the medium's software images are refused: ${_said#kryptikd: check-images: }" +} + # The record can lie about itself; the signed kernel's root hash cannot. root_verifies() { # root_verifies DEV veritysetup verify --no-superblock --hash=sha256 --data-block-size=4096 --hash-block-size=4096 \ diff --git a/build/stages/04-base-system.sh b/build/stages/04-base-system.sh index d155931f..40acf06a 100755 --- a/build/stages/04-base-system.sh +++ b/build/stages/04-base-system.sh @@ -52,109 +52,8 @@ tree_digest() { done | LC_ALL=C sort -z | xargs -0r sha256sum | sed "s| ${KRYPTIK_ROOT}/| |" | sha256_of_stdin } -# --- hardening exceptions --------------------------------------------------- - -# The flags to drop for a package, from hardening-exceptions.txt. -exception_flags_for() { - local pkg="$1" f="${KRYPTIK_ROOT}/build/config/hardening-exceptions.txt" - [[ -f "$f" ]] || return 0 - awk -v p="$pkg" '!/^[[:space:]]*#/ && $1 == p { print $2 }' "$f" -} - -# Hardening minus the package's exceptions; a -final row takes its package's. -set_flags_for() { - local pkg="$1" drop - export CFLAGS="${KRYPTIK_OPT} ${KRYPTIK_CFLAGS_HARDENING}" - export CXXFLAGS="${KRYPTIK_OPT} ${KRYPTIK_CFLAGS_HARDENING} ${KRYPTIK_CXXFLAGS_HARDENING}" - export LDFLAGS="${KRYPTIK_LDFLAGS_HARDENING}" - - while read -r drop; do - [[ -z "$drop" ]] && continue - warn "${pkg}: dropping ${drop} (justified exception)" - CFLAGS="${CFLAGS//${drop}/}" - CXXFLAGS="${CXXFLAGS//${drop}/}" - LDFLAGS="${LDFLAGS//${drop}/}" - done < <(exception_flags_for "${pkg%-final}") - - export CFLAGS CXXFLAGS LDFLAGS -} - -# --- step machinery --------------------------------------------------------- - -# The shared step() calls this after printing the tail of a failed log. -step_failure_hint() { - # Two locals: one `local` expands all its words before assigning any. - local name="$1" - local logfile="${LOGS}/${STAMP_PREFIX}${name}.log" - - # The error can be thousands of lines above the tail; show likely causes. - if [[ -f "$logfile" ]]; then - local hits - hits="$(grep -nE '^(make(\[[0-9]+\])?: \*\*\*|.*: \*\*\* )|\[ERROR\]|undefined (symbol|reference)|No such file or directory|Permission denied|command not found|configure: error|fatal error|cannot find -l|ModuleNotFoundError|ImportError|^[A-Za-z_.]*Error:|Could not build' \ - "$logfile" 2>/dev/null | tail -15)" - if [[ -n "$hits" ]]; then - err "" - err "Lines in ${logfile} that look like the actual cause:" - printf '%s\n' "$hits" | sed 's/^/ /' >&2 - fi - fi - - err "" - err "Check the actual error before assuming it is the hardening flags." - err "The first stage 04 failure looked like one and was not - it was a" - err "missing native glibc and no generated locales." - err "" - err "If it IS a hardening incompatibility, add an entry to" - err "build/config/hardening-exceptions.txt WITH a justification, so" - err "only that flag is dropped and only for that package." -} - -# Native build: no --host, as this runs on the target. -native_build() { - local tarball="$1" dirname="$2"; shift 2 - local src; src="$(unpack "$tarball" "$dirname")" - cd "$src" - ./configure --prefix=/usr "$@" - make - make install -} - -# meson packages; the Wayland stack is meson-only. --buildtype=plain leaves the -# flags to the hardening CFLAGS (release adds -O3 and -DNDEBUG), and -# --wrap-mode=nodownload keeps a subproject from fetching unlocked sources. -meson_build() { - local tarball="$1" dirname="$2"; shift 2 - local src; src="$(unpack "$tarball" "$dirname")" - cd "$src" - meson setup build --prefix=/usr --buildtype=plain --wrap-mode=nodownload "$@" - ninja -C build - ninja -C build install -} - -# cmake only generates json-c's build files (cryptsetup needs json-c for LUKS2 -# headers) and stays out of the image. The chroot runs Kitware's binary, pinned -# in sources.lock and never installed, instead of a long source build; where it -# cannot run, s_cmake builds from source with its bundled libraries. Unpacked -# on demand: the build tree is cleared between runs, and a resumed json-c step -# must not rely on a skipped cmake step. -prebuilt_cmake() { - local dir="${BUILDDIR}/cmake-${V_CMAKE}-linux-x86_64" - if [[ ! -x "${dir}/bin/cmake" ]]; then - rm -rf "$dir" - tar -xf "${KRYPTIK_SOURCES}/cmake-${V_CMAKE}-linux-x86_64.tar.gz" -C "$BUILDDIR" - fi - [[ -x "${dir}/bin/cmake" ]] || return 1 - "${dir}/bin/cmake" --version > /dev/null 2>&1 || return 1 - printf '%s' "${dir}/bin/cmake" -} - -# --- recipes ---------------------------------------------------------------- -# One file per step under build/recipes, sourced here. The build order is the -# list below; a step's fingerprint is its function's text, wherever it lives. -for recipe in "$(dirname "${BASH_SOURCE[0]}")/../recipes/"*.sh; do - # shellcheck source=/dev/null - source "$recipe" -done +# --- recipes, and the flags and helpers they build with (shared with the image stage) --- +source "$(dirname "${BASH_SOURCE[0]}")/../lib/recipes.sh" # --- build order: by dependency, not alphabetical ---------------------------- PACKAGES=( @@ -281,6 +180,10 @@ PACKAGES=( "hwdata" "s_hwdata" "libdisplay-info" "meson_build libdisplay-info-${V_LIBDISPLAY_INFO}.tar.xz libdisplay-info-${V_LIBDISPLAY_INFO}" "wlroots" "s_wlroots" + # Software images build against the base system up to here (IMAGES_BASE in 04-images.sh). What + # follows is zone 0's own, which no image links or runs while it builds, so changing it rebuilds + # no image. A library, header or pkg-config file goes above this line: one added below rebuilds + # every image all the same, as the images' inputs list those the sysroot has. # dwl's three inputs are digests, so editing any of them rebuilds it. "dwl" "s_dwl $(sha256_of "${KRYPTIK_ROOT}/build/desktop/dwl-config.h" 2>/dev/null || echo none) $(sha256_of "${KRYPTIK_ROOT}/build/desktop/zone-colours.h" 2>/dev/null || echo none) $(sha256_of "${KRYPTIK_ROOT}/tools/desktop/dwl-zone-borders.py" 2>/dev/null || echo none)" "havoc" "s_havoc" diff --git a/build/stages/04-images.sh b/build/stages/04-images.sh new file mode 100755 index 00000000..2a9b44c0 --- /dev/null +++ b/build/stages/04-images.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# Software images (docs/design/software-delivery.md): each image's recipes built in the chroot over the +# finished base system, through an overlay whose upper directory keeps what they install. Stage 06 +# packs each tree as it packs the root. +# usage: make images (as root, for the overlay; or 04-images.sh NAME... for some of build/images) +# 04-images.sh --inside NAME in the chroot: one image's rows, as stage 04 runs its own +# +# A definition sets image_version and image_rows, and may set image_over, the images it is built over: +# their trees lie under it, above the sysroot, while its rows run. image_ships=no makes a layer that +# only images built over it use, a toolchain, which stage 06 never packs. + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/common.sh" +load_config + +DEFS="${KRYPTIK_ROOT}/build/images" +# The last stage 04 step an image builds against. The base system up to it is in every image's +# inputs; the steps after it, zone 0's own programs and applications, which no image links or runs +# while it builds, are not, so changing them rebuilds no image. +IMAGES_BASE="wlroots" +SYSROOT="${KRYPTIK_WORK}/sysroot" +CHROOTD="${KRYPTIK_ROOT}/build/stages/03-chroot-prep.sh" + +# --- on the host: an overlay per image, its rows run inside it --------------- + +# image_setting NAME VAR: VAR in NAME's definition, an array's words joined by spaces. +image_setting() { + # shellcheck source=/dev/null + ( source "${DEFS}/${1}.sh"; declare -n v="$2"; printf '%s' "${v[*]:-}" ) +} + +# Each row's recipe as stage 04 fingerprints a step: its code and every helper it reaches, wherever +# that is defined, its arguments, the sources it names and the V_* it reads. +image_rows_of() { # image_rows_of NAME + ( + # shellcheck source=/dev/null + source "${KRYPTIK_ROOT}/build/lib/recipes.sh" + # shellcheck source=/dev/null + source "${DEFS}/${1}.sh" + # shellcheck disable=SC2154 # image_rows comes from the image's definition + for ((i = 0; i < ${#image_rows[@]}; i += 2)); do + # shellcheck disable=SC2086 # a recipe is a word-split command, as the chroot runs it + printf 'row %s %s\n' "${image_rows[i]}" "$(recipe_fingerprint ${image_rows[i+1]})" + done + ) +} + +# The names of what a build can find in the sysroot: headers, libraries and pkg-config files. A +# configure check or a meson feature left on auto finds one a step past IMAGES_BASE added as surely +# as one before it, so adding or removing one rebuilds every image; the base stamp covers the rest. +link_surface() { + ( cd "$SYSROOT" && find usr/include usr/lib usr/share/pkgconfig \( -type f -o -type l \) \ + \( -path 'usr/include/*' -o -name '*.so' -o -name '*.so.*' -o -name '*.a' -o -name '*.pc' \) 2>/dev/null || true ) \ + | LC_ALL=C sort | sha256_of_stdin +} + +# What an image is built from: its definition and files, the shared recipe helpers and the flags, +# its pins and the manifest rows its licences are read by, its rows' recipes, the base system up to +# IMAGES_BASE and the sysroot's headers and libraries, and what the images it is built over are +# built from, so a cached image needs none of them built to be found current. CHAIN is the images it +# is being read under. +image_inputs() { # image_inputs NAME [CHAIN] + local f dep h chain="${2:-} ${1}" + for f in "${DEFS}/${1}.sh" "${DEFS}/${1}"/* "${KRYPTIK_ROOT}/build/lib/recipes.sh" \ + "${BASH_SOURCE[0]}" "${KRYPTIK_ROOT}/tools/check-image-tree.sh" "${KRYPTIK_ROOT}/build/config/hardening.env" \ + "${KRYPTIK_ROOT}/build/config/hardening-exceptions.txt" "${DEFS}/versions.env" "${DEFS}/sources.lock"; do + if [[ -f "$f" ]]; then printf '%s %s\n' "${f#"${KRYPTIK_ROOT}/"}" "$(sha256_of "$f")"; fi + done + printf 'sources %s\n' "$("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list-image "$1" | sha256_of_stdin)" + image_rows_of "$1" + printf 'base %s %s\n' "$IMAGES_BASE" "$(_stamp_read "${KRYPTIK_WORK}/.stamps/bs-${IMAGES_BASE}")" + printf 'link surface %s\n' "$(link_surface)" + for dep in $(image_setting "$1" image_over); do + [[ " ${chain} " != *" ${dep} "* ]] || die "image ${dep} is built over itself:${chain} ${dep}" + [[ -f "${DEFS}/${dep}.sh" ]] || die "image ${1} is built over ${dep}, which has no ${DEFS}/${dep}.sh" + h="$(image_inputs "$dep" "$chain" | sha256_of_stdin)" + printf 'over %s %s\n' "$dep" "$h" + done +} + +# The image that is being built, so an exit takes its mounts down with it. +MOUNTED="" +unmount_image() { # unmount_image TOP + env KRYPTIK_WORK="$1" "$CHROOTD" umount > /dev/null 2>&1 || true + if mountpoint -q "$1/sysroot"; then umount "$1/sysroot" || umount -l "$1/sysroot"; fi + # Not grep -q: under pipefail its early exit could fail findmnt and hide a mount. + if findmnt -rn -o TARGET | grep -F "$1/" >&2; then die "still mounted under $1 (above): nothing there is removed"; fi +} + +build_image() { # build_image NAME + local name="$1" top="${KRYPTIK_WORK}/image-build/${1}" version want d ships dep lowers="" + local -a over + [[ "$name" =~ ^[a-z0-9-]{1,32}$ ]] || die "image name ${name}: a-z, 0-9 and -, at most 32" + [[ -f "${DEFS}/${name}.sh" ]] || die "no image ${name}: nothing at ${DEFS}/${name}.sh" + version="$(image_setting "$name" image_version)" + [[ "$version" =~ ^[!-~]{1,64}$ ]] || die "image ${name}: version '${version}' is not 1-64 printable characters without a space" + ships="$(image_setting "$name" image_ships)"; ships="${ships:-yes}" + [[ "$ships" == yes || "$ships" == no ]] || die "image ${name}: image_ships is yes or no, not '${ships}'" + read -ra over <<< "$(image_setting "$name" image_over)" + want="$(image_inputs "$name" | sha256_of_stdin)" + if [[ -d "${top}/tree/usr" ]] && grep -qx "fingerprint: ${want}" "${top}/record" 2>/dev/null; then + # The root past IMAGES_BASE can change under a kept tree, which still may hold no file of the root's. + "${KRYPTIK_ROOT}/tools/check-image-tree.sh" "$SYSROOT" "${top}/tree" + dim " skip ${name} ${version} (already built, inputs unchanged)" + return 0 + fi + # What it is built over comes first; the nearest layer is named first, as overlayfs takes it. + for dep in "${over[@]}"; do + build_image "$dep" + lowers+="${KRYPTIK_WORK}/image-build/${dep}/tree:" + done + log "image ${name} ${version}" + unmount_image "$top" + rm -rf "$top" + # A fresh upper directory: no row may skip on a stamp from the last one. + rm -f "${KRYPTIK_WORK}/.stamps/image-${name}-"* + mkdir -p "${top}/upper" "${top}/overlay-work" "${top}/sysroot" + # Stage 03 takes the chroot's root from KRYPTIK_WORK, so the overlay is that work tree's sysroot, + # and the real work tree's stamps, logs, build and images are bound into it as usual. They are + # made here: the images job's work tree is stage 05's hand-off, which has no images directory. + for d in .stamps logs build images; do + mkdir -p "${KRYPTIK_WORK}/${d}" + ln -s "${KRYPTIK_WORK}/${d}" "${top}/${d}" + done + MOUNTED="$top" + mount -t overlay overlay -o "lowerdir=${lowers}${SYSROOT},upperdir=${top}/upper,workdir=${top}/overlay-work" "${top}/sysroot" + env KRYPTIK_WORK="$top" KRYPTIK_KRYPTIKD_BIN= KRYPTIK_WLPROXY_BIN= \ + "$CHROOTD" run /kryptik/build/stages/04-images.sh --inside "$name" + unmount_image "$top" + MOUNTED="" + "${KRYPTIK_ROOT}/tools/check-image-tree.sh" "${lowers}${SYSROOT}" "${top}/upper" + # Without extended attributes: overlayfs keeps its own on the upper directory. + mkdir -p "${top}/tree" + rsync -aH --numeric-ids "${top}/upper/usr" "${top}/tree/" + rm -rf "${top}/upper" "${top}/overlay-work" + printf 'name: %s\nversion: %s\nships: %s\nover: %s\nfingerprint: %s\n' \ + "$name" "$version" "$ships" "${over[*]}" "$want" > "${top}/record" + ok "image ${name} ${version}: $(find "${top}/tree" ! -type d | wc -l) files and links" +} + +build_images() { # build_images [NAME...] + [[ "$EUID" -eq 0 ]] || die "the image stage mounts an overlay over the sysroot: run it as root (make images)" + require_outside_chroot "the image stage" + [[ -f "${KRYPTIK_WORK}/.stamps/bs-licences" ]] || die "stage 04 has not finished: make system first" + [[ -f "${KRYPTIK_WORK}/.stamps/bs-${IMAGES_BASE}" ]] || die "stage 04 has no step ${IMAGES_BASE}, the last one images build against (IMAGES_BASE)" + trap '[[ -z "$MOUNTED" ]] || unmount_image "$MOUNTED"' EXIT + local names=("$@") d n + if [[ "${#names[@]}" -eq 0 ]]; then + for d in "${DEFS}"/*.sh; do names+=("$(basename "$d" .sh)"); done + fi + log "Kryptik software images: ${names[*]}" + for n in "${names[@]}"; do + build_image "$n" + done + ok "software images built: ${names[*]}" + dim "Next: make media packs each one beside the root (stage 06)" +} + +if [[ "${1:-}" != --inside ]]; then + build_images "$@" + exit 0 +fi + +# --- in the chroot: one image's rows, as stage 04 runs its own --------------- + +name="${2:?--inside needs an image name}" +require_inside_chroot "the image stage" +load_hardening +validate_hardening_exceptions +stage_contract "${BASH_SOURCE[0]}" "image-${name}-" gcc +# shellcheck disable=SC2034 # consumed by step() and the recipes +STAMPS="${KRYPTIK_WORK}/.stamps" LOGS="${KRYPTIK_WORK}/logs" BUILDDIR="${KRYPTIK_WORK}/build" +KRYPTIK_JOBS="${KRYPTIK_JOBS:-$(kryptik_default_jobs)}" +# As stage 04 builds: gnulib refuses root without this, and every make runs in parallel. +export FORCE_UNSAFE_CONFIGURE=1 MAKEFLAGS="-j${KRYPTIK_JOBS}" +# The root's index of info manuals is the root's; automake leaves it alone. And a Python tool the +# build runs leaves no bytecode for the root's modules in the image. +export AM_UPDATE_INFO_DIR=no PYTHONDONTWRITEBYTECODE=1 +umask 022 +source "${KRYPTIK_ROOT}/build/lib/recipes.sh" +# shellcheck source=/dev/null +source "${DEFS}/${name}.sh" +# Stage 04 finished: the rows run over the whole base system. Whether an image is built again at all +# is image_inputs' call, on the host. +stage_depends_on "bs-" licences +# shellcheck disable=SC2154 # image_rows comes from the image's definition +for ((i = 0; i < ${#image_rows[@]}; i += 2)); do + # shellcheck disable=SC2086 # a recipe is a word-split command, as in stage 04 + step "${image_rows[i]}" ${image_rows[i+1]} +done diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index 89cb0e5b..7ca361a4 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -48,6 +48,8 @@ KRYPTIK_VERSION="${KRYPTIK_VERSION:-0.1.$(date +%Y%m%d).$(git -c safe.directory= COMMIT="$(git -c safe.directory="$TOP" -C "$TOP" describe --always --dirty --abbrev=40 2>/dev/null || echo unknown)" ESP_MIB=512 export KRYPTIK_VERSION +# This release's software images, beside its payload and channel: two releases' media can share IMG. +IMAGES="${IMG}/images-${KRYPTIK_VERSION}" # Where the image's net zone asks for releases (docs/design/update-channel.md). # Empty, the image names no channel and fetches nothing. KRYPTIK_CHANNEL="${KRYPTIK_CHANNEL:-}" @@ -328,18 +330,167 @@ EOF cat "${IMG}/root.json" } -# make_ext4 TREE IMAGE BYTES UUID: the sysroot's mke2fs (1.47.1), which clamps every time it +# make_ext4 TREE IMAGE BYTES UUID [LABEL]: the sysroot's mke2fs (1.47.1), which clamps every time it # writes to SOURCE_DATE_EPOCH, in the chroot; TREE and IMAGE are under the work tree's images/. +# Blocks of 4096 bytes, the size dm-verity reads every image in: ext4 refuses a smaller block on +# that device (EINVAL), and mke2fs.conf gives a file system under 512 MiB blocks of 1024. make_ext4() { rm -f "$2"; truncate -s "$3" "$2" env KRYPTIK_ROOT="$KRYPTIK_ROOT" KRYPTIK_WORK="$KRYPTIK_WORK" KRYPTIK_SOURCES="$KRYPTIK_SOURCES" NO_COLOR=1 \ - "$CHROOTD" run /usr/sbin/mke2fs -q -F -t ext4 -L kryptik-root -O '^has_journal' -U "$4" \ + "$CHROOTD" run /usr/sbin/mke2fs -q -F -t ext4 -b 4096 -L "${5:-kryptik-root}" -O '^has_journal' -U "$4" \ -E "root_owner=0:0,hash_seed=$4" -d "/kryptik-work${1#"$KRYPTIK_WORK"}" "/kryptik-work${2#"$KRYPTIK_WORK"}" \ > /dev/null } root_json() { sed -n "s/^ \"$1\": \"\{0,1\}\([^\",]*\)\"\{0,1\},\{0,1\}\$/\1/p" "${IMG}/root.json"; } +# pack_image NAME VERSION TREE BASE: IMG/images/NAME.img as the root is packed (ext4, then its dm-verity +# tree at data-blocks*4096), and its line of images.manifest on stdout. +pack_image() { + local name="$1" version="$2" tree="$3" base="$4" + local stage; stage="$(mktemp -d "${IMG}/image-stage.XXXXXX")" + chmod 0755 "$stage" + rsync -aH --numeric-ids "${tree}/" "${stage}/" + # What the image keeps for those built over it and never ships (build/images/NAME.strip, a glob + # a line under its root): an entry that matches nothing is stale, as in its program list. + local strip_list="${KRYPTIK_ROOT}/build/images/${name}.strip" glob gone=() + if [[ -f "$strip_list" ]]; then + while IFS= read -r glob || [[ -n "$glob" ]]; do + [[ -n "$glob" && "$glob" != \#* ]] || continue + [[ "$glob" != /* && "$glob" != *..* ]] || { echo "FAIL: image ${name}: ${glob} is not a path under its root" >&2; return 1; } + mapfile -t gone < <(compgen -G "${stage}/${glob}") + [[ "${#gone[@]}" -gt 0 ]] || { echo "FAIL: image ${name}: ${glob} in ${name}.strip names nothing" >&2; return 1; } + rm -rf -- "${gone[@]}" + done < "$strip_list" + fi + strip_root "$stage" >&2 + if find "$stage" -perm /6000 -print | grep . >&2; then echo "FAIL: a setuid or setgid file in image ${name}" >&2; return 1; fi + # Against the image's own accepted findings, each with its reason, or none: the root's list + # names the root's objects, and --strict refuses an entry that matches nothing. + local accepted="${KRYPTIK_ROOT}/build/images/${name}.accepted" + if [[ ! -f "$accepted" ]]; then accepted="${stage}.accepted"; : > "$accepted"; fi + # Every check runs before the step fails, so one run shows each finding. + local failed=0 + "${KRYPTIK_ROOT}/tools/check-artifact-hardening.sh" --strict --accepted "$accepted" "$stage" >&2 || failed=1 + "${KRYPTIK_ROOT}/tools/check-image-programs.sh" "$stage" --list "${KRYPTIK_ROOT}/build/images/${name}.programs" >&2 || failed=1 + "${KRYPTIK_ROOT}/tools/check-image-licences.sh" --image "$name" "$stage" >&2 || failed=1 + [[ "$failed" -eq 0 ]] || { echo "FAIL: image ${name} did not pass the checks above" >&2; return 1; } + local bytes; bytes="$(du -sb "$stage" | cut -f1)" + local fs_bytes=$(( bytes + bytes / 8 + 16 * 1024 * 1024 )) + fs_bytes=$(( (fs_bytes + 4095) / 4096 * 4096 )) + local img="${IMAGES}/${name}.img" id + # The UUID and directory hash seed, from what the image is and the root it is built for. + id="$(printf 'kryptik-image %s %s %s' "$name" "$version" "$base" | sha256_of_stdin)" + id="${id:0:8}-${id:8:4}-${id:12:4}-${id:16:4}-${id:20:12}" + make_ext4 "$stage" "$img" "$fs_bytes" "$id" "kryptik-${name}" + if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then + make_ext4 "$stage" "${img}.again" "$fs_bytes" "$id" "kryptik-${name}" + cmp -s "$img" "${img}.again" || { echo "FAIL: two mke2fs runs over image ${name} wrote different images" >&2; return 1; } + rm -f "${img}.again" + fi + [[ "$(dumpe2fs -h "$img" 2>/dev/null | sed -n 's/^Block size: *//p')" == 4096 ]] \ + || { echo "FAIL: image ${name} is not of 4096-byte blocks, which dm-verity reads it in" >&2; return 1; } + local data_blocks=$(( fs_bytes / 4096 )) + local salt; salt="$(sha256_of "$img")" + local total=$(( fs_bytes + fs_bytes / 64 + 4 * 1024 * 1024 )) + truncate -s $(( (total + 4095) / 4096 * 4096 )) "$img" + veritysetup format --no-superblock --hash=sha256 --data-block-size=4096 --hash-block-size=4096 \ + --data-blocks="$data_blocks" --hash-offset="$fs_bytes" --salt="$salt" \ + --root-hash-file="${stage}.hash" "$img" "$img" >&2 + local root_hash; root_hash="$(tr -d '\n' < "${stage}.hash")" + [[ ${#root_hash} -eq 64 ]] || { echo "image ${name}: bad root hash ${root_hash}" >&2; return 1; } + veritysetup verify --no-superblock --hash=sha256 --data-block-size=4096 --hash-block-size=4096 \ + --data-blocks="$data_blocks" --hash-offset="$fs_bytes" --salt="$salt" "$img" "$img" "$root_hash" >&2 + rm -rf "$stage" "${stage}.hash" "${stage}.accepted" + printf 'image: %s %s bytes=%s sha256=%s data-blocks=%s root-hash=%s salt=%s\n' \ + "$name" "$version" "$(stat -c %s "$img")" "$(sha256_of "$img")" "$data_blocks" "$root_hash" "$salt" +} + +# Each software image the image stage built (build/stages/04-images.sh), in IMAGES with +# images.manifest naming them for this root: the release, the root's hash as their base, an image line +# each by name, a requires line for each built over others that ship, then each image's policy lines +# (build/images/NAME.needs). sign-images signs it. A layer that does not ship, a toolchain, is left out. +# With no image built there is no list, as kryptikd refuses one that names none. +s_images() { + local version="$1" base; base="$(root_json root_hash)" + local rec name ver line dep reqs needs=() images=() requires=() + rm -rf "$IMAGES"; mkdir -p "$IMAGES" + # By name in byte order, the list's own, which its requires and needs lines follow. Not by path: + # there "probe-x/record" comes before "probe/record", as "-" sorts below "/". + for name in $(for rec in "${KRYPTIK_WORK}"/image-build/*/record; do basename "${rec%/record}"; done | LC_ALL=C sort); do + rec="${KRYPTIK_WORK}/image-build/${name}/record" + [[ -f "$rec" ]] || continue + name="$(sed -n 's/^name: //p' "$rec")"; ver="$(sed -n 's/^version: //p' "$rec")" + if [[ "$(sed -n 's/^ships: //p' "$rec")" == no ]]; then + echo "--- image ${name} ${ver}: a layer others are built over, not shipped ---" + continue + fi + echo "--- image ${name} ${ver} ---" + images+=("$(pack_image "$name" "$ver" "$(dirname "$rec")/tree" "$base")") + reqs="" + for dep in $(sed -n 's/^over: //p' "$rec"); do + [[ "$(sed -n 's/^ships: //p' "${KRYPTIK_WORK}/image-build/${dep}/record")" == no ]] || reqs+=" ${dep}" + done + [[ -z "$reqs" ]] || requires+=("requires: ${name}${reqs}") + if [[ -f "${KRYPTIK_ROOT}/build/images/${name}.needs" ]]; then + while IFS= read -r line; do + [[ -z "$line" || "$line" == \#* ]] || needs+=("needs: ${name} ${line}") + done < "${KRYPTIK_ROOT}/build/images/${name}.needs" + fi + done + if [[ "${#images[@]}" -eq 0 ]]; then + rmdir "$IMAGES" + echo "no software image built (make images): no images.manifest" + return 0 + fi + { + printf 'KRYPTIK-IMAGES-1\nrelease: %s\nbase: %s\n' "$version" "$base" + printf '%s\n' "${images[@]}" | LC_ALL=C sort + if [[ "${#requires[@]}" -gt 0 ]]; then printf '%s\n' "${requires[@]}"; fi + if [[ "${#needs[@]}" -gt 0 ]]; then printf '%s\n' "${needs[@]}"; fi + } > "${IMAGES}/images.manifest" + echo "--- images.manifest ---" + cat "${IMAGES}/images.manifest" +} + +# images.manifest signed by the release key in kryptik-image, and checked as the guest checks it. A +# development build also signs the two lists the guest must refuse: release-ns, the same list in the +# release manifests' namespace, and other-base, a list for another root. +s_sign_images() { + echo "inputs digest: $1" + local dir="$IMAGES" m="${IMAGES}/images.manifest" + [[ -f "$m" ]] || { echo "no images.manifest: no software image to sign"; return 0; } + rm -rf "${m}.sig" "${dir}/controls" + # As s_sums: a security key asks on stderr to be touched, and the terminal, when there is one, gets it. + local to=/dev/null; { : > /dev/tty; } 2>/dev/null && to=/dev/tty + ssh-keygen -Y sign -f "$RELEASE_KEY" -n kryptik-image "$m" >/dev/null 2>"$to" \ + || { echo "could not sign ${m}"; return 1; } + ssh-keygen -Y verify -f "$ANCHOR" -I kryptik-release -n kryptik-image -s "${m}.sig" < "$m" \ + || { echo "FAIL: the image's anchor refuses the image list's signature"; return 1; } + if ssh-keygen -Y verify -f "$ANCHOR" -I kryptik-release -n kryptik-release -s "${m}.sig" < "$m" >/dev/null 2>&1; then + echo "FAIL: the image list's signature passes for a release manifest's"; return 1 + fi + if [[ "$ROLE" == development ]]; then + mkdir -p "${dir}/controls/release-ns" "${dir}/controls/other-base" + cp "$m" "${dir}/controls/release-ns/manifest" + ssh-keygen -Y sign -f "$RELEASE_KEY" -n kryptik-release "${dir}/controls/release-ns/manifest" >/dev/null 2>&1 \ + || { echo "could not sign the release-ns control"; return 1; } + sed "s/^base: .*/base: $(printf '0%.0s' {1..64})/" "$m" > "${dir}/controls/other-base/manifest" + ssh-keygen -Y sign -f "$RELEASE_KEY" -n kryptik-image "${dir}/controls/other-base/manifest" >/dev/null 2>&1 \ + || { echo "could not sign the other-base control"; return 1; } + fi + find "$dir" -type f | LC_ALL=C sort +} + +# What s_images reads: each built image's record, the image definitions, and the root it is built for. +images_inputs() { + local f + for f in "${KRYPTIK_WORK}"/image-build/*/record "${KRYPTIK_ROOT}"/build/images/*.* "${KRYPTIK_ROOT}/tools/check-artifact-hardening.sh" \ + "${KRYPTIK_ROOT}/tools/check-image-programs.sh" "${KRYPTIK_ROOT}/build/config/artifact-accepted.txt" "${IMG}/root.json"; do + if [[ -f "$f" ]]; then printf '%s %s\n' "$f" "$(sha256_of "$f")"; fi + done +} + verity_table() { # verity_table : the root image's dm-verity table printf '0 %s verity 1 %s %s 4096 4096 %s %s sha256 %s %s 1 panic_on_corruption' \ "$(root_json data_sectors)" "$1" "$1" "$(root_json data_blocks)" \ @@ -429,39 +580,76 @@ s_esp() { echo "--- ESP (usb) ---"; mdir -i "${IMG}/esp-usb.img" -/ :: | grep -vE '^\s*$' } -make_gpt_image() { # make_gpt_image OUT ESP-IMG ROOT-IMG ROOT-LABEL - local out="$1" esp="$2" root="$3" label="$4" - local esp_bytes root_bytes +# The medium's copy of this release's software images: the list, its signature and each image, not +# the controls a development build signs for the suites. Fails when the build made no list. +medium_images() { # medium_images DIR + [[ -f "${IMAGES}/images.manifest" ]] || return 1 + rm -rf "$1"; mkdir -p "$1"; chmod 0755 "$1" + install -m 0644 "${IMAGES}"/images.manifest* "${IMAGES}"/*.img "$1/" +} + +# make_images_fs DIR OUT: DIR as the FAT32 of the USB's kryptik-images partition, as the ESP is made. +# Whoever can write the stick can craft this filesystem, and the installer mounts it: FAT is the +# parser the ESP already needs, where ext4's is far larger. +make_images_fs() { + local f + for f in "$1"/*; do + (( $(stat -c %s "$f") < 4 * 1024 * 1024 * 1024 )) || { echo "$(basename "$f") is 4 GiB or more, past what FAT32 holds"; return 1; } + done + local bytes; bytes="$(du -sb "$1" | cut -f1)" + local mib=$(( (bytes + bytes / 16) / 1048576 + 16 )) + (( mib >= 64 )) || mib=64 + local id; id="$(printf 'kryptik-images %s %s' "$KRYPTIK_VERSION" "$(root_json root_hash)" | sha256_of_stdin)" + rm -f "$2" + truncate -s $(( mib * 1048576 )) "$2" + mkfs.vfat -F 32 -n KRYPTIKIMG -i "${id:0:8}" "$2" >/dev/null + mcopy -i "$2" "$1"/* ::/ +} + +make_gpt_image() { # make_gpt_image OUT ESP-IMG ROOT-IMG ROOT-LABEL [IMAGES-FS] + local out="$1" esp="$2" root="$3" label="$4" imgfs="${5:-}" + local esp_bytes root_bytes images_sectors=0 esp_bytes="$(stat -c %s "$esp")"; root_bytes="$(stat -c %s "$root")" local esp_sectors=$(( esp_bytes / 512 )) local root_sectors=$(( (root_bytes + 1048575) / 1048576 * 2048 )) + [[ -z "$imgfs" ]] || images_sectors=$(( ($(stat -c %s "$imgfs") + 1048575) / 1048576 * 2048 )) local esp_start=2048 local root_start=$(( esp_start + esp_sectors )) - local total=$(( root_start + root_sectors + 2048 + 34 )) + local images_start=$(( root_start + root_sectors )) + local total=$(( images_start + images_sectors + 2048 + 34 )) + local table="label: gpt +unit: sectors +start=${esp_start}, size=${esp_sectors}, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, name=\"kryptik-esp\" +start=${root_start}, size=${root_sectors}, type=4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709, name=\"${label}\"" + # The release's software images, after the root: the installer copies them into the store. + [[ -z "$imgfs" ]] || table+=" +start=${images_start}, size=${images_sectors}, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, name=\"kryptik-images\"" rm -f "$out" truncate -s $(( total * 512 )) "$out" - sfdisk --quiet --wipe always "$out" < "${out}.sha256" echo "usb image: $(stat -c %s "$out") bytes ($(du -h "$out" | cut -f1) on disk)" cat "${out}.sha256" } -make_iso() { # make_iso OUT ESP-IMG - local out="$1" esp="$2" +make_iso() { # make_iso OUT ESP-IMG [IMAGES-DIR] + local out="$1" esp="$2" imgdir="${3:-}" local t; t="$(mktemp -d)" cp "$SB_CERT" "$t/kryptik-sb.crt" cp "${IMG}/root.json" "$t/" @@ -472,6 +660,12 @@ appended partition (see root.json); the ISO kernel names its offset. To boot from a USB stick use the USB image instead: this ISO's kernel looks for /dev/sr0. EOF + local graft=(esp.img="$esp" kryptik-sb.crt="$t/kryptik-sb.crt" root.json="$t/root.json" README.txt="$t/README.txt") + if [[ -n "$imgdir" ]]; then + printf '%s\n' "The software images under images/ are named by images.manifest, which the" \ + "release key signs (images.manifest.sig); the installer copies them." >> "$t/README.txt" + graft+=(images="$imgdir") + fi rm -f "$out" # Linux filesystem data, as a GUID: xorriso takes an MBR type byte or a # GUID, not sfdisk's 0x8300 shorthand. @@ -479,8 +673,7 @@ EOF xorriso -as mkisofs -quiet -o "$out" -iso-level 3 -V KRYPTIK -J -R \ -e esp.img -no-emul-boot -isohybrid-gpt-basdat \ -append_partition 2 0FC63DAF-8483-4772-8E79-3D69D8477DE4 "${IMG}/kryptik-root.img" -appended_part_as_gpt \ - -graft-points esp.img="$esp" kryptik-sb.crt="$t/kryptik-sb.crt" root.json="$t/root.json" README.txt="$t/README.txt" \ - 2>&1 | grep -vE '^\s*$' + -graft-points "${graft[@]}" 2>&1 | grep -vE '^\s*$' rc="${PIPESTATUS[0]}" rm -rf "$t" [[ "$rc" -eq 0 ]] || { echo "xorriso exited ${rc}"; return 1; } @@ -494,7 +687,13 @@ s_bind_iso() { echo "inputs digest: $1" echo "--- pass 1: layout (a placeholder ESP of the final size) ---" make_esp "${IMG}/esp-iso.img" slot-a .efi - make_iso "${IMG}/layout.iso" "${IMG}/esp-iso.img" + local imgdir="" + if medium_images "${IMG}/iso-images"; then + imgdir="${IMG}/iso-images" + # Signed at sign time: one 2048-byte block holds the signature, as it holds this. + [[ -f "${imgdir}/images.manifest.sig" ]] || printf 'placeholder\n' > "${imgdir}/images.manifest.sig" + fi + make_iso "${IMG}/layout.iso" "${IMG}/esp-iso.img" ${imgdir:+"$imgdir"} local start; start="$(part2_start "${IMG}/layout.iso")" [[ -n "$start" ]] || { echo "could not read the appended partition's start"; sfdisk -d "${IMG}/layout.iso"; return 1; } echo "appended root partition starts at sector ${start}" @@ -508,7 +707,7 @@ s_bind_iso() { "$total_sectors" "$start" "$(verity_table /dev/dm-0)" "$COMMON_ARGS" > "${IMG}/cmdlines/media-iso.txt" printf '%s\n' "$start" > "${IMG}/iso-root-start" cat "${IMG}/cmdlines/media-iso.txt" - rm -f "${IMG}/layout.iso" "${IMG}/esp-iso.img" + rm -rf "${IMG}/layout.iso" "${IMG}/esp-iso.img" "${IMG}/iso-images" echo "--- bind the ISO kernel ---" bind_in_chroot media-iso } @@ -521,7 +720,13 @@ s_iso() { sign_one media-iso echo "--- pass 2: the real ESP and ISO ---" make_esp "${IMG}/esp-iso.img" media-iso - make_iso "$iso" "${IMG}/esp-iso.img" + local imgdir="" + if medium_images "${IMG}/iso-images"; then + imgdir="${IMG}/iso-images" + [[ -f "${imgdir}/images.manifest.sig" ]] || { echo "images.manifest is not signed"; return 1; } + fi + make_iso "$iso" "${IMG}/esp-iso.img" ${imgdir:+"$imgdir"} + rm -rf "${IMG}/iso-images" local start2; start2="$(part2_start "$iso")" [[ "$start2" == "$start" ]] || { echo "FAIL: the root partition moved from where the ISO kernel was bound (${start} -> ${start2})"; return 1; } echo "ok: root partition start unchanged at ${start2}" @@ -573,9 +778,20 @@ s_payload() { cp "${IMG}/kernels/slot-a.signed.efi" "$out/kryptik-a.efi" cp "${IMG}/kernels/slot-b.signed.efi" "$out/kryptik-b.efi" cp "${IMG}/root.json" "$out/" + # The release's software images, signed for this root and each compressed for the trip as the + # root image is: apply decompresses them into the state partition's store. + local images=() img + if [[ -f "${IMAGES}/images.manifest.sig" ]]; then + cp "${IMAGES}/images.manifest" "${IMAGES}/images.manifest.sig" "$out/" + images=(images.manifest images.manifest.sig) + for img in "${IMAGES}"/*.img; do + zstd -q -T0 -19 "$img" -o "$out/$(basename "$img").zst" || { echo "could not compress ${img}"; return 1; } + images+=("$(basename "$img").zst") + done + fi "${KRYPTIK_ROOT}/tools/release-manifest.sh" create --out "$out/manifest" --name kryptik \ --version "$KRYPTIK_VERSION" --role "$ROLE" --root "$out" \ - kryptik-root.img.zst kryptik-a.efi kryptik-b.efi root.json + kryptik-root.img.zst kryptik-a.efi kryptik-b.efi root.json "${images[@]}" "${KRYPTIK_ROOT}/tools/release-manifest.sh" sign --key "$RELEASE_KEY" "$out/manifest" # Verify as the guest will: the image's anchor, --exact. "${KRYPTIK_ROOT}/tools/release-manifest.sh" verify --signers "$ANCHOR" --principal kryptik-release \ @@ -694,25 +910,36 @@ check_bound() { grep -q -a -F -e "$(root_json root_hash)" "${IMG}/kernels/${v}.efi" || die "${v}.efi is not bound to this root" done ok "the bound root trusts ${ANCHOR}, and its four kernels are bound to it" + if [[ -f "${IMAGES}/images.manifest" ]]; then + [[ "$(sed -n 's/^base: //p' "${IMAGES}/images.manifest")" == "$(root_json root_hash)" ]] || die "images.manifest is for another root" + local name sha + while read -r _ name _ _ sha _; do + sha="${sha#sha256=}" + [[ "$(sha256_of "${IMAGES}/${name}.img")" == "$sha" ]] || die "image ${name} changed since images.manifest named it" + done < <(grep '^image: ' "${IMAGES}/images.manifest") + ok "the bound images are the ones images.manifest names" + fi } # --- run -------------------------------------------------------------------- [[ "$PHASE" != sign ]] || check_bound bound rootfs s_rootfs "$KRYPTIK_VERSION" "$(cat "${KRYPTIK_ROOT}/build/config/image-excludes.txt" "${KRYPTIK_ROOT}/build/config/setuid-allowlist.txt" "${KRYPTIK_ROOT}/build/config/capability-allowlist.txt" "${KRYPTIK_ROOT}/tools/audit-setuid.sh" "${KRYPTIK_ROOT}/build/config/artifact-accepted.txt" "${KRYPTIK_ROOT}/tools/check-artifact-hardening.sh" "${KRYPTIK_ROOT}/build/config/image-programs.txt" "${KRYPTIK_ROOT}/tools/check-image-programs.sh" | sha256_of_stdin)" "$KRYPTIK_CHANNEL" "$ROLE" "$(_hash_file "$ANCHOR")" +bound images s_images "$KRYPTIK_VERSION" "$(images_inputs | sha256_of_stdin)" bound cmdlines s_cmdlines "$(_hash_file "${IMG}/root.json")" bound bind-kernels s_bind_kernels "$(cat "${IMG}"/cmdlines/{slot-a,slot-b,media-usb}.txt | sha256_of_stdin)" -bound bind-iso s_bind_iso "$(cat "${IMG}/root.json" "$SB_CERT" | sha256_of_stdin)" +bound bind-iso s_bind_iso "$(cat "${IMG}/root.json" "$SB_CERT" | sha256_of_stdin)$(_hash_file "${IMAGES}/images.manifest")" if [[ "$PHASE" == bind ]]; then echo ok "Stage 06 bound ${KRYPTIK_VERSION} in ${IMG}: KRYPTIK_MEDIA_PHASE=sign signs it" exit 0 fi +step sign-images s_sign_images "$(_hash_file "${IMAGES}/images.manifest")$(_hash_file "$ANCHOR")" step sign-kernels s_sign_kernels "$(cat "${IMG}"/kernels/{slot-a,slot-b,media-usb}.efi | sha256_of_stdin)$(_hash_file "$SB_CERT")" step esp s_esp "$(cat "${IMG}"/kernels/{slot-a,slot-b,media-usb}.signed.efi "${IMG}/root.json" | sha256_of_stdin)" -step usb s_usb "$(cat "${IMG}/esp-usb.img" | sha256_of_stdin)$(root_json sha256)" -step iso s_iso "$(cat "${IMG}"/kernels/{slot-a,slot-b}.signed.efi "${IMG}/kernels/media-iso.efi" "${IMG}/root.json" "${IMG}/iso-root-start" | sha256_of_stdin)" +step usb s_usb "$(cat "${IMG}/esp-usb.img" | sha256_of_stdin)$(root_json sha256)$(_hash_file "${IMAGES}/images.manifest")$(_hash_file "${IMAGES}/images.manifest.sig")" +step iso s_iso "$(cat "${IMG}"/kernels/{slot-a,slot-b}.signed.efi "${IMG}/kernels/media-iso.efi" "${IMG}/root.json" "${IMG}/iso-root-start" | sha256_of_stdin)$(_hash_file "${IMAGES}/images.manifest")$(_hash_file "${IMAGES}/images.manifest.sig")" step sums s_sums "$(cat "${IMG}/kryptik-${KRYPTIK_VERSION}"{-usb.img,.iso}.sha256 | sha256_of_stdin)$(_hash_file "$ANCHOR")" -step payload s_payload "$(cat "${IMG}"/kernels/{slot-a,slot-b}.signed.efi "${IMG}/root.json" "${KRYPTIK_ROOT}"/tools/release-{manifest,channel}.sh | sha256_of_stdin)" "${LATEST_KEY:+with a statement key}" +step payload s_payload "$(cat "${IMG}"/kernels/{slot-a,slot-b}.signed.efi "${IMG}/root.json" "${KRYPTIK_ROOT}"/tools/release-{manifest,channel}.sh | sha256_of_stdin)$(_hash_file "${IMAGES}/images.manifest.sig")" "${LATEST_KEY:+with a statement key}" step export s_export "$(cat "${IMG}"/kryptik-*.sha256 "${IMG}/payload-${KRYPTIK_VERSION}/manifest" "${IMG}/kryptik-${KRYPTIK_VERSION}.SHA256SUMS.sig" "$ANCHOR" | sha256_of_stdin)" echo ok "Stage 06 finished: ${KRYPTIK_OUT}/kryptik-${KRYPTIK_VERSION}" diff --git a/compartments/kryptikd/src/images.rs b/compartments/kryptikd/src/images.rs new file mode 100644 index 00000000..29ce35db --- /dev/null +++ b/compartments/kryptikd/src/images.rs @@ -0,0 +1,447 @@ +//! Software images over a zone's /usr (docs/design/software-delivery.md). A list the release key +//! signs in `kryptik-image` names each image's dm-verity root hash for one root. kryptikd opens +//! the images a zone file names, over that root alone, and the zone binds one read-only overlay +//! of them over zone 0's /usr. Every start checks the list again, as nobody. + +use std::fs; +use std::io::Read as _; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; + +use crate::policy::{self, Policy}; +use crate::zone::Zone; + +pub const MAGIC: &str = "KRYPTIK-IMAGES-1"; +/// Images and the lists that name them, on the state partition, root's alone. +pub const STORE: &str = "/var/lib/kryptik/images"; +/// Byte limits for the list and its signature, as for a release manifest. +const LIST_MAX: u64 = 64 * 1024; +const SIG_MAX: u64 = 8 * 1024; +/// Where each opened image is mounted, and where each set of them is stacked over /usr. +const MOUNTS: &str = "/run/kryptik/images"; +const SETS: &str = "/run/kryptik/usr"; +/// One start at a time opens images, so two never race for one mapping. +const LOCK: &str = "/run/kryptik/images.lock"; +/// The largest image taken: the state partition holds every zone's data beside it. +const IMAGE_MAX: u64 = 16 << 30; +const BLOCK: u64 = 4096; + +/// One image, as the signed list gives it. +#[derive(Debug, Clone, PartialEq)] +pub struct Image { + pub name: String, + pub version: String, + /// The whole file: the data, then its hash tree. + pub bytes: u64, + pub sha256: String, + pub data_blocks: u64, + pub root_hash: String, + pub salt: String, +} + +/// The signed list of images built over one root. +#[derive(Debug, Clone, PartialEq)] +pub struct Manifest { + pub release: String, + /// The root hash of the root the images were built over. + pub base: String, + pub images: Vec, + /// The images each image is built over (`requires:`), which a zone naming it must name too. + pub requires: Vec<(String, Vec)>, + /// Each image's policy-file lines: what a zone's policy must grant before it gets the image. + pub needs: Vec<(String, String)>, +} + +/// An image's name, as a zone file and the store's paths use it. +pub fn valid_name(s: &str) -> bool { + (1..=32).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +fn hex64(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +fn version_ok(s: &str) -> bool { + (1..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_graphic()) +} + +/// Digits alone, with no leading zero. +fn number(s: &str) -> Option { + if s.is_empty() || (s.len() > 1 && s.starts_with('0')) || !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse().ok() +} + +/// The list, line by line as stage 06 writes it; anything else is refused, not skipped. +pub fn parse(text: &str) -> Result { + let body = text.strip_suffix('\n').ok_or("the image list does not end with a newline")?; + let mut lines = body.split('\n'); + if lines.next() != Some(MAGIC) { + return Err(format!("the image list is not a {MAGIC}")); + } + let field = |line: Option<&str>, key: &str| -> Result { + line.and_then(|l| l.strip_prefix(key)) + .and_then(|l| l.strip_prefix(": ")) + .map(str::to_string) + .ok_or_else(|| format!("the image list has no {key} line where one belongs")) + }; + let release = field(lines.next(), "release")?; + if !version_ok(&release) { + return Err(format!("the image list's release {release:?} is not a version")); + } + let base = field(lines.next(), "base")?; + if !hex64(&base) { + return Err("the image list's base is not a root hash".into()); + } + let mut images: Vec = Vec::new(); + let mut requires: Vec<(String, Vec)> = Vec::new(); + let mut needs: Vec<(String, String)> = Vec::new(); + for line in lines { + if let Some(rest) = line.strip_prefix("image: ") { + if !requires.is_empty() || !needs.is_empty() { + return Err("an image line follows the requires or needs lines".into()); + } + let img = image_line(rest)?; + // Sorted, which also keeps one line per image. + if images.last().is_some_and(|last| last.name >= img.name) { + return Err(format!("image {} is out of order or listed twice", img.name)); + } + images.push(img); + } else if let Some(rest) = line.strip_prefix("requires: ") { + if !needs.is_empty() { + return Err("a requires line follows the needs lines".into()); + } + let mut words = rest.split(' '); + let name = words.next().unwrap_or_default(); + let reqs: Vec = words.map(str::to_string).collect(); + let at = images + .iter() + .position(|i| i.name == name) + .ok_or_else(|| format!("a requires line names {name:?}, which the list does not hold"))?; + if reqs.is_empty() { + return Err(format!("the requires line for {name} names no image")); + } + for (k, r) in reqs.iter().enumerate() { + if r == name || !images.iter().any(|i| &i.name == r) || reqs[..k].contains(r) { + return Err(format!("image {name} requires {r:?}, which is not another image the list holds")); + } + } + // In image order, one line per image. + let last = requires.last().and_then(|(n, _)| images.iter().position(|i| &i.name == n)); + if last.is_some_and(|l| l >= at) { + return Err(format!("the requires line for {name} is out of order or repeated")); + } + requires.push((name.to_string(), reqs)); + } else if let Some(rest) = line.strip_prefix("needs: ") { + let (name, what) = rest.split_once(' ').ok_or_else(|| format!("{line:?} names no policy line"))?; + let at = images + .iter() + .position(|i| i.name == name) + .ok_or_else(|| format!("a needs line names {name:?}, which the list does not hold"))?; + let last = needs.last().and_then(|(n, _)| images.iter().position(|i| &i.name == n)); + if last.is_some_and(|l| l > at) { + return Err(format!("the needs lines for {name} are not with their image's")); + } + policy::parse(what, "the image list").map_err(|e| e.to_string())?; + needs.push((name.to_string(), what.to_string())); + } else { + return Err(format!("unexpected line {line:?} in the image list")); + } + } + if images.is_empty() { + return Err("the image list names no image".into()); + } + Ok(Manifest { release, base, images, requires, needs }) +} + +fn image_line(rest: &str) -> Result { + let bad = || format!("{rest:?} is not `NAME VERSION bytes= sha256= data-blocks= root-hash= salt=`"); + let words: Vec<&str> = rest.split(' ').collect(); + let &[name, version, bytes, sha256, data_blocks, root_hash, salt] = words.as_slice() else { + return Err(bad()); + }; + if !valid_name(name) || !version_ok(version) { + return Err(bad()); + } + let value = |w: &str, key: &str| w.strip_prefix(key).and_then(|v| v.strip_prefix('=')).map(str::to_string); + let img = Image { + name: name.to_string(), + version: version.to_string(), + bytes: value(bytes, "bytes").as_deref().and_then(number).ok_or_else(bad)?, + sha256: value(sha256, "sha256").filter(|h| hex64(h)).ok_or_else(bad)?, + data_blocks: value(data_blocks, "data-blocks").as_deref().and_then(number).ok_or_else(bad)?, + root_hash: value(root_hash, "root-hash").filter(|h| hex64(h)).ok_or_else(bad)?, + salt: value(salt, "salt").filter(|h| hex64(h)).ok_or_else(bad)?, + }; + // The data, then its hash tree, in whole blocks, and nothing past the cap. + let data = img.data_blocks.checked_mul(BLOCK).filter(|d| *d > 0); + if !data.is_some_and(|d| img.bytes > d) || img.bytes % BLOCK != 0 || img.bytes > IMAGE_MAX { + return Err(format!( + "image {}: {} bytes cannot hold {} data blocks and their hash tree", + img.name, img.bytes, img.data_blocks + )); + } + Ok(img) +} + +/// The running root's dm-verity root hash, from the signed kernel's command line: the word after +/// `sha256` in the verity table of `dm-mod.create`'s root device. +pub fn root_hash_from(cmdline: &str) -> Option { + const KEY: &str = "dm-mod.create=\""; + let start = cmdline.find(KEY)? + KEY.len(); + let end = start + cmdline[start..].find('"')?; + let table = cmdline[start..end].split(';').find(|d| d.starts_with("kroot,"))?; + let mut words = table.split_whitespace().skip_while(|w| *w != "verity"); + words.find(|w| *w == "sha256")?; + words.next().filter(|h| hex64(h)).map(str::to_string) +} + +/// Whether the zone's policy grants every line the list says `image` needs: an image widens +/// nothing, and a line the base policy already allows needs no grant. +pub fn needs_met(m: &Manifest, image: &str, granted: Option<&Policy>) -> Result<(), String> { + fn holds(want: &[String], have: Option<&Vec>) -> bool { + want.iter().all(|w| have.is_some_and(|h| h.contains(w))) + } + for (_, line) in m.needs.iter().filter(|(n, _)| n == image) { + let need = policy::parse(line, "the image list").map_err(|e| e.to_string())?; + let ok = holds(&need.extra_syscall_names, granted.map(|p| &p.extra_syscall_names)) + && holds(&need.socket_names, granted.map(|p| &p.socket_names)) + && holds(&need.netlink_names, granted.map(|p| &p.netlink_names)) + && holds(&need.keep_cap_names, granted.map(|p| &p.keep_cap_names)); + if !ok { + return Err(format!("image {image:?} needs `{line}`, which the zone's policy file does not grant")); + } + } + Ok(()) +} + +/// Whether a zone that names `named` also names every image each of them is built over. +pub fn requires_met(m: &Manifest, named: &[String]) -> Result<(), String> { + for (image, reqs) in m.requires.iter().filter(|(i, _)| named.contains(i)) { + if let Some(r) = reqs.iter().find(|r| !named.contains(r)) { + return Err(format!("image {image:?} is built over {r:?}, which the zone does not name")); + } + } + Ok(()) +} + +/// Where a set of images is stacked: one directory per set, whichever zone asks for it. +pub fn set_dir(images: &[&Image]) -> String { + let parts: Vec = images.iter().map(|i| format!("{}-{}", i.name, &i.root_hash[..16])).collect(); + format!("{SETS}/{}", parts.join("+")) +} + +/// Open the images `zone` names over the root this kernel runs and stack them over /usr, in name +/// order: the directory the zone binds as its /usr. +pub fn prepare(zone: &Zone, granted: Option<&Policy>) -> Result { + if unsafe { libc::geteuid() } != 0 { + return Err("only a root launch opens software images".into()); + } + let _one = crate::update::lock(Path::new(LOCK))?; + let cmdline = fs::read_to_string("/proc/cmdline").map_err(|e| format!("/proc/cmdline: {e}"))?; + let base = root_hash_from(&cmdline) + .ok_or("this kernel's command line names no verity root, so no image can be matched to it")?; + let m = verified_list(&base)?; + let chosen: Vec<&Image> = m.images.iter().filter(|i| zone.images.contains(&i.name)).collect(); + for name in &zone.images { + if !chosen.iter().any(|i| &i.name == name) { + return Err(format!("the image list for this root ({}) holds no image {name:?}", m.release)); + } + needs_met(&m, name, granted)?; + } + requires_met(&m, &zone.images)?; + let mut lower: Vec = Vec::new(); + for img in &chosen { + lower.push(format!("{}/usr", open(img)?)); + } + lower.push("/usr".into()); + let set = set_dir(&chosen); + let lowerdir = format!("lowerdir={}", lower.join(":")); + let mounts = fs::read_to_string("/proc/self/mounts").map_err(|e| format!("/proc/self/mounts: {e}"))?; + if !stacked(&mounts, &set, &lowerdir)? { + fs::create_dir_all(&set).map_err(|e| format!("{set}: {e}"))?; + // No redirect in a lower layer is followed, though no image is built with one. + mount("kryptik-usr", &set, "overlay", Some(format!("{lowerdir},redirect_dir=nofollow").as_str()))?; + } + Ok(set) +} + +/// Whether the set at `at` is mounted already with exactly `lowerdir` (use it), not at all (stack +/// it), or over other images (refused): a set's name holds 64 bits of each root hash, the mounts +/// in its lowerdir the whole hash. `mounts` is /proc/self/mounts; the topmost mount at `at` counts. +fn stacked(mounts: &str, at: &str, lowerdir: &str) -> Result { + let options = mounts + .lines() + .filter_map(|l| { + let f: Vec<&str> = l.split_whitespace().collect(); + (f.len() >= 4 && f[1] == at).then(|| f[3]) + }) + .last(); + match options { + None => Ok(false), + Some(o) if o.split(',').any(|x| x == lowerdir) => Ok(true), + Some(_) => Err(format!("{at} already stacks other images than these")), + } +} + +/// The list for `base`, read once, its signature checked as nobody on a copy of those bytes. +fn verified_list(base: &str) -> Result { + let list = Path::new(STORE).join(format!("{base}.manifest")); + let text = read_bounded(&list, LIST_MAX) + .map_err(|e| format!("no software images are kept for this root ({}...): {e}", &base[..12]))?; + let sig = read_bounded(&list.with_extension("manifest.sig"), SIG_MAX)?; + signed(&text, &sig).map_err(|e| format!("the image list for this root is refused: {e}"))?; + let m = parse(std::str::from_utf8(&text).map_err(|_| "the image list is not text".to_string())?)?; + if m.base != base { + return Err(format!("the image list kept for this root is for another root ({})", m.release)); + } + Ok(m) +} + +/// Whether `sig` signs `text` as an image list, checked as nobody on a copy of these bytes. +fn signed(text: &[u8], sig: &[u8]) -> Result<(), String> { + let dir = crate::update::make_scratch(Path::new("/run/kryptik"))?; + let checked = fs::write(dir.join("manifest"), text) + .and_then(|_| fs::write(dir.join("manifest.sig"), sig)) + .map_err(|e| format!("{}: {e}", dir.display())) + .and_then(|_| crate::update::check_images(&dir)); + let _ = fs::remove_dir_all(&dir); + checked.map(|_| ()) +} + +/// A medium's images.manifest and images.manifest.sig in `dir`, checked as the installer has them +/// before it writes anything: signed as an image list, read as a kept list is, for the root `base` +/// it installs, and naming only images `dir` holds as NAME.img at the size the list signs. Each +/// image's hash is checked as it is copied (image-store.sh). With `out`, the very bytes checked are +/// written there for the installer to keep: a medium read a second time could answer otherwise. +pub fn check_medium(dir: &Path, base: &str, out: Option<&Path>) -> Result { + check_medium_with(dir, base, out, &signed) +} + +fn check_medium_with( + dir: &Path, + base: &str, + out: Option<&Path>, + check: &dyn Fn(&[u8], &[u8]) -> Result<(), String>, +) -> Result { + let text = read_bounded(&dir.join("images.manifest"), LIST_MAX)?; + let sig = read_bounded(&dir.join("images.manifest.sig"), SIG_MAX)?; + check(&text, &sig).map_err(|e| format!("the medium's image list is refused: {e}"))?; + let m = parse(std::str::from_utf8(&text).map_err(|_| "the image list is not text".to_string())?)?; + if m.base != base { + return Err(format!("the medium's image list ({}) is for another root than the one it installs", m.release)); + } + for img in &m.images { + let file = dir.join(format!("{}.img", img.name)); + let md = fs::symlink_metadata(&file).map_err(|e| format!("image {}: {}: {e}", img.name, file.display()))?; + if !md.is_file() || md.len() != img.bytes { + return Err(format!("image {}: {} holds {} bytes, and the list signs {}", img.name, file.display(), md.len(), img.bytes)); + } + } + if let Some(out) = out { + crate::files::write_atomic(&out.join("images.manifest"), &[&text[..]], 0o600, None) + .and_then(|_| crate::files::write_atomic(&out.join("images.manifest.sig"), &[&sig[..]], 0o600, None)) + .map_err(|e| format!("{}: {e}", out.display()))?; + } + Ok(format!("{} images of release {} for this root\n", m.images.len(), m.release)) +} + +fn read_bounded(path: &Path, max: u64) -> Result, String> { + use std::os::unix::fs::OpenOptionsExt; + /* A regular file alone, looked at before it is opened: a medium's ISO can carry a link, a FIFO, + * which would block the read, or a device node, some of which act on an open. Checked again + * once open, should it have been swapped in between. */ + if !fs::symlink_metadata(path).map_err(|e| format!("{}: {e}", path.display()))?.is_file() { + return Err(format!("{} is not a regular file", path.display())); + } + let f = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW) + .open(path) + .map_err(|e| format!("{}: {e}", path.display()))?; + if !f.metadata().map_err(|e| format!("{}: {e}", path.display()))?.is_file() { + return Err(format!("{} is not a regular file", path.display())); + } + let mut bytes = Vec::new(); + f.take(max + 1).read_to_end(&mut bytes).map_err(|e| format!("{}: {e}", path.display()))?; + if bytes.len() as u64 > max { + return Err(format!("{} is larger than {max} bytes", path.display())); + } + Ok(bytes) +} + +/// Open one image with dm-verity and mount it read-only: where it is mounted. +fn open(img: &Image) -> Result { + let file = PathBuf::from(format!("{STORE}/{}/{}.img", img.name, img.root_hash)); + let shown = file.display().to_string(); + let md = fs::symlink_metadata(&file).map_err(|e| format!("image {}: {shown}: {e}", img.name))?; + if !md.file_type().is_file() || md.uid() != 0 || md.mode() & 0o022 != 0 { + return Err(format!("image {}: {shown} is not a regular file only root writes", img.name)); + } + if md.len() != img.bytes { + return Err(format!("image {}: {shown} holds {} bytes, and the list signs {}", img.name, md.len(), img.bytes)); + } + // The whole root hash: at most 111 characters, inside device-mapper's 127. + let mapper = format!("kryptik-image-{}-{}", img.name, img.root_hash); + let dev = format!("/dev/mapper/{mapper}"); + if Path::new(&dev).exists() { + let status = crate::volume::run("veritysetup status", "veritysetup", &["status", mapper.as_str()], None).map_err(|e| e.1)?; + let (its, corrupted) = status_of(&status, &img.root_hash); + if !its { + return Err(format!("image {}: {dev} is there, and is not this image's dm-verity mapping", img.name)); + } + if corrupted { + return Err(format!( + "image {} read back other than signed while in use, so it is not used again until this machine restarts", + img.name + )); + } + } else { + let blocks = format!("--data-blocks={}", img.data_blocks); + let offset = format!("--hash-offset={}", img.data_blocks * BLOCK); + let salt = format!("--salt={}", img.salt); + let args: [&str; 12] = [ + "open", "--no-superblock", "--hash=sha256", "--data-block-size=4096", "--hash-block-size=4096", + &blocks, &offset, &salt, &shown, &mapper, &shown, &img.root_hash, + ]; + crate::volume::run("veritysetup open", "veritysetup", &args, None).map_err(|e| format!("image {}: {}", img.name, e.1))?; + } + let at = format!("{MOUNTS}/{}/{}", img.name, img.root_hash); + if !crate::volume::is_mountpoint(&at) { + fs::create_dir_all(&at).map_err(|e| format!("{at}: {e}"))?; + // A journal is never replayed: the device under it is read-only. + mount(&dev, &at, "ext4", Some("noload"))?; + } + // Its own directory: a link there would stack whatever it names over /usr. + if !fs::symlink_metadata(Path::new(&at).join("usr")).is_ok_and(|m| m.is_dir()) { + return Err(format!("image {} holds no usr directory to stack over /usr", img.name)); + } + Ok(at) +} + +/// What `veritysetup status` says of a mapping found under an image's name: whether it is that +/// image's, dm-verity with the root hash its list signs as read from the active table, and whether +/// dm-verity has marked it corrupted, at the first block that read back other than signed. +fn status_of(status: &str, root_hash: &str) -> (bool, bool) { + let field = |key: &str| status.lines().find_map(|l| l.trim().strip_prefix(key)).map(str::trim); + let its = field("type:") == Some("VERITY") && field("root hash:") == Some(root_hash); + let corrupted = status.lines().any(|l| l.split_whitespace().eq(["status:", "corrupted"])); + (its, corrupted) +} + +/// Read-only, with no set-id programs and no device files, whatever the image holds. +fn mount(src: &str, at: &str, fstype: &str, data: Option<&str>) -> Result<(), String> { + let c = |s: &str| std::ffi::CString::new(s).map_err(|_| format!("{s:?} holds a NUL")); + let (src_c, at_c, fs_c) = (c(src)?, c(at)?, c(fstype)?); + let data_c = data.map(c).transpose()?; + let flags = libc::MS_RDONLY | libc::MS_NOSUID | libc::MS_NODEV; + let data_p = data_c.as_ref().map_or(std::ptr::null(), |d| d.as_ptr().cast()); + if unsafe { libc::mount(src_c.as_ptr(), at_c.as_ptr(), fs_c.as_ptr(), flags, data_p) } < 0 { + return Err(format!("mount {fstype} at {at}: {}", std::io::Error::last_os_error())); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/compartments/kryptikd/src/images/tests.rs b/compartments/kryptikd/src/images/tests.rs new file mode 100644 index 00000000..1fd6c33e --- /dev/null +++ b/compartments/kryptikd/src/images/tests.rs @@ -0,0 +1,283 @@ +use super::*; + +fn hex(c: char) -> String { + std::iter::repeat(c).take(64).collect() +} + +fn image(name: &str) -> String { + format!( + "image: {name} 140.4.0 bytes=12288 sha256={} data-blocks=2 root-hash={} salt={}", + hex('a'), + hex('b'), + hex('c') + ) +} + +/// A list over the root `1…1`, with these lines after its header. +fn list(lines: &[&str]) -> String { + let mut s = format!("{MAGIC}\nrelease: 1.2.0\nbase: {}\n", hex('1')); + for l in lines { + s.push_str(l); + s.push('\n'); + } + s +} + +#[test] +fn parses_a_list_as_stage_06_writes_it() { + let m = parse(&list(&[&image("browser"), &image("viewer"), "needs: browser allow-syscall seccomp"])).unwrap(); + assert_eq!((m.release.as_str(), m.base.as_str()), ("1.2.0", hex('1').as_str())); + assert_eq!( + m.images[0], + Image { + name: "browser".into(), + version: "140.4.0".into(), + bytes: 12288, + sha256: hex('a'), + data_blocks: 2, + root_hash: hex('b'), + salt: hex('c'), + } + ); + assert_eq!(m.images[1].name, "viewer"); + assert_eq!(m.needs, vec![("browser".to_string(), "allow-syscall seccomp".to_string())]); +} + +#[test] +fn refuses_what_stage_06_never_writes() { + let good = image("browser"); + for (why, text) in [ + ("no header", list(&[&good]).replacen(MAGIC, "KRYPTIK-IMAGES-2", 1)), + ("no final newline", list(&[&good]).trim_end().to_string()), + ("a carriage return", list(&[&good]).replace('\n', "\r\n")), + ("a blank line", list(&["", &good])), + ("a comment", list(&["# browser", &good])), + ("no image", list(&[])), + ("a short base", list(&[&good]).replace(&hex('1'), &hex('1')[..63])), + ("an upper-case root hash", list(&[&good.replace(&hex('b'), &hex('B'))])), + ("a name with a slash", list(&[&image("a/b")])), + ("a name of 33 bytes", list(&[&image(&"x".repeat(33))])), + ("a field out of order", list(&[&good.replace("bytes=12288 ", "").replace(" salt=", " bytes=12288 salt=")])), + ("a leading zero", list(&[&good.replace("bytes=12288", "bytes=012288")])), + ("no room for the hash tree", list(&[&good.replace("bytes=12288", "bytes=8192")])), + ("bytes not whole blocks", list(&[&good.replace("bytes=12288", "bytes=12289")])), + ("no data", list(&[&good.replace("data-blocks=2", "data-blocks=0")])), + ("past the cap", list(&[&good.replace("bytes=12288", &format!("bytes={}", (16u64 << 30) + 4096))])), + ("two spaces", list(&[&good.replacen(' ', " ", 1)])), + ("out of order", list(&[&image("viewer"), &good])), + ("listed twice", list(&[&good, &good])), + ("needs for an image not listed", list(&[&good, "needs: viewer allow-syscall seccomp"])), + ("needs no line", list(&[&good, "needs: browser"])), + ("needs a line no policy file takes", list(&[&good, "needs: browser allow-syscall nosuchcall"])), + ("needs a denied call", list(&[&good, "needs: browser allow-syscall ptrace"])), + ("an image after the needs", list(&[&good, "needs: browser allow-syscall seccomp", &image("viewer")])), + ( + "needs out of their image's order", + list(&[&good, &image("viewer"), "needs: viewer allow-syscall seccomp", "needs: browser allow-syscall mlockall"]), + ), + ("an unknown line", list(&[&good, "signer: someone"])), + ("requires for an image not listed", list(&[&good, &image("toolkit"), "requires: viewer toolkit"])), + ("requires nothing", list(&[&good, &image("toolkit"), "requires: browser"])), + ("requires itself", list(&[&good, &image("toolkit"), "requires: browser browser"])), + ("requires an image not listed", list(&[&good, &image("toolkit"), "requires: browser fonts"])), + ("requires one image twice", list(&[&good, &image("toolkit"), "requires: browser toolkit toolkit"])), + ("two requires lines for one image", list(&[&good, &image("toolkit"), "requires: browser toolkit", "requires: browser toolkit"])), + ("requires out of image order", list(&[&good, &image("fonts"), &image("toolkit"), "requires: toolkit fonts", "requires: browser toolkit"])), + ("requires after needs", list(&[&good, &image("toolkit"), "needs: browser allow-syscall seccomp", "requires: browser toolkit"])), + ("an image after requires", list(&[&good, "requires: browser toolkit", &image("toolkit")])), + ] { + assert!(parse(&text).is_err(), "{why}: {text:?}"); + } +} + +#[test] +fn root_hash_from_the_signed_command_line() { + let table = |dev: &str| { + format!("0 2048000 verity 1 {dev} {dev} 4096 4096 256000 256001 sha256 {} {} 1 panic_on_corruption", hex('d'), hex('e')) + }; + let installed = format!( + "dm-mod.waitfor=PARTLABEL=kryptik-a dm-mod.create=\"kroot,,0,ro,{}\" root=/dev/dm-0 lockdown=confidentiality kryptik.slot=a", + table("PARTLABEL=kryptik-a") + ); + assert_eq!(root_hash_from(&installed), Some(hex('d'))); + let iso = format!( + "dm-mod.create=\"kmedia,,0,ro,0 4000 linear /dev/sr0 128;kroot,,1,ro,{}\" root=/dev/dm-1 kryptik.media=iso", + table("/dev/dm-0") + ); + assert_eq!(root_hash_from(&iso), Some(hex('d'))); + assert_eq!(root_hash_from("root=/dev/vda2 ro"), None, "no verity root"); + assert_eq!(root_hash_from("dm-mod.create=\"kroot,,0,ro,0 8 linear /dev/vda2 0\""), None, "a root that is not verity"); + let short = installed.replace(&hex('d'), &hex('d')[..40]); + assert_eq!(root_hash_from(&short), None, "a hash that is not 64 digits"); +} + +#[test] +fn needs_must_be_granted_by_the_zone_policy() { + let m = parse(&list(&[&image("browser"), &image("viewer"), "needs: browser allow-syscall seccomp"])).unwrap(); + let grants = |text: &str| policy::parse(text, "zone").unwrap(); + assert!(needs_met(&m, "browser", Some(&grants("allow-syscall seccomp\nallow-syscall mlockall\n"))).is_ok()); + let refused = needs_met(&m, "browser", Some(&grants("allow-syscall mlockall\n"))).unwrap_err(); + assert!(refused.contains("allow-syscall seccomp"), "{refused}"); + assert!(needs_met(&m, "browser", None).is_err(), "a zone with no policy file grants nothing"); + assert!(needs_met(&m, "viewer", None).is_ok(), "an image that needs nothing"); + // What the base policy already allows needs no grant. + let base = parse(&list(&[&image("browser"), "needs: browser allow-syscall read"])).unwrap(); + assert!(needs_met(&base, "browser", None).is_ok()); +} + +#[test] +fn an_image_built_over_others_needs_them_named() { + let m = parse(&list(&[ + &image("browser"), + &image("fonts"), + &image("toolkit"), + "requires: browser toolkit fonts", + "requires: toolkit fonts", + "needs: browser allow-syscall seccomp", + ])) + .unwrap(); + assert_eq!( + m.requires, + vec![ + ("browser".to_string(), vec!["toolkit".to_string(), "fonts".to_string()]), + ("toolkit".to_string(), vec!["fonts".to_string()]), + ] + ); + let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::>(); + assert!(requires_met(&m, &names(&["browser", "toolkit", "fonts"])).is_ok()); + assert!(requires_met(&m, &names(&["fonts"])).is_ok(), "an image built over nothing"); + let refused = requires_met(&m, &names(&["browser", "toolkit"])).unwrap_err(); + assert!(refused.contains("\"fonts\""), "{refused}"); + assert!(requires_met(&m, &names(&["toolkit"])).is_err()); +} + +#[test] +fn a_set_is_named_by_its_images_and_their_hashes() { + let m = parse(&list(&[&image("browser"), &image("viewer")])).unwrap(); + let both: Vec<&Image> = m.images.iter().collect(); + assert_eq!(set_dir(&both), format!("/run/kryptik/usr/browser-{0}+viewer-{0}", &hex('b')[..16])); + assert_eq!(set_dir(&both[..1]), format!("/run/kryptik/usr/browser-{}", &hex('b')[..16])); +} + +#[test] +fn a_set_is_used_again_only_where_it_stacks_these_very_images() { + let at = format!("/run/kryptik/usr/browser-{}", &hex('b')[..16]); + let want = format!("lowerdir=/run/kryptik/images/browser/{}/usr:/usr", hex('b')); + let other = format!("lowerdir=/run/kryptik/images/browser/{}{}/usr:/usr", &hex('b')[..16], &hex('c')[16..]); + let line = |lowerdir: &str| format!("kryptik-usr {at} overlay ro,nosuid,nodev,relatime,{lowerdir},redirect_dir=nofollow 0 0\n"); + let root = "/dev/vda2 / ext4 ro,relatime 0 0\n"; + assert_eq!(stacked(root, &at, &want), Ok(false), "not mounted: stack it"); + assert_eq!(stacked(&format!("{root}{}", line(&want)), &at, &want), Ok(true), "these very images: use it"); + let refused = stacked(&format!("{root}{}", line(&other)), &at, &want).unwrap_err(); + assert!(refused.contains("other images"), "{refused}"); + // The topmost mount at the path is the one that counts. + assert_eq!(stacked(&format!("{}{}", line(&other), line(&want)), &at, &want), Ok(true)); + assert!(stacked(&format!("{}{}", line(&want), line(&other)), &at, &want).is_err()); +} + +#[test] +fn names_are_what_a_path_and_a_zone_file_take() { + for ok in ["browser", "pdf-viewer", "a", "x1"] { + assert!(valid_name(ok), "{ok}"); + } + let long = "x".repeat(33); + for bad in ["", "Browser", "a b", "a/b", "..", "a.b", "a_b", long.as_str()] { + assert!(!valid_name(bad), "{bad:?}"); + } +} + +/// A mapping under an image's name is used again only if veritysetup shows it is that image's, as +/// cryptsetup 2.8 prints its status. +#[test] +fn a_mapping_is_used_again_only_if_it_is_this_images() { + let status = |kind: &str, state: &str, root: &str| { + format!( + "/dev/mapper/kryptik-image-browser-{root} is active and is in use.\n type: {kind}\n status: {state}\n \ + hash type: 1\n data block: 4096 [bytes]\n hash block: 4096 [bytes]\n hash name: sha256\n \ + salt: {}\n data device: /dev/loop0\n mode: readonly\n root hash: {root}\n", + hex('c') + ) + }; + let b = hex('b'); + assert_eq!(status_of(&status("VERITY", "verified", &b), &b), (true, false)); + assert_eq!(status_of(&status("VERITY", "corrupted", &b), &b), (true, true)); + assert_eq!(status_of(&status("VERITY", "verified", &hex('d')), &b), (false, false), "another root hash"); + assert_eq!(status_of(&status("LUKS2", "verified", &b), &b).0, false, "not dm-verity"); + assert_eq!(status_of(&status("VERITY", "verified", &b).replace(" root hash:", " root:"), &b).0, false, "no root hash shown"); +} + +/// What the installer has checked before it writes anything: the medium's list is signed, for the +/// root it installs, and each image it names is there at the size it signs. +#[test] +fn a_medium_is_checked_before_anything_is_written() { + let d = std::env::temp_dir().join(format!("kryptik-images-test-{}-medium", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join("images.manifest"), list(&[&image("browser")])).unwrap(); + std::fs::write(d.join("images.manifest.sig"), b"signature").unwrap(); + std::fs::write(d.join("browser.img"), vec![0u8; 12288]).unwrap(); + let yes = |_: &[u8], _: &[u8]| -> Result<(), String> { Ok(()) }; + let no = |_: &[u8], _: &[u8]| -> Result<(), String> { Err("not signed by the release key as an image list".into()) }; + + let said = check_medium_with(&d, &hex('1'), None, &yes).unwrap(); + assert!(said.contains("1 images of release 1.2.0"), "{said}"); + // The bytes checked are the ones handed on, and a refusal hands on nothing. + let out = d.join("checked"); + std::fs::create_dir(&out).unwrap(); + check_medium_with(&d, &hex('1'), Some(&out), &yes).unwrap(); + assert_eq!(std::fs::read(out.join("images.manifest")).unwrap(), std::fs::read(d.join("images.manifest")).unwrap()); + assert_eq!(std::fs::read(out.join("images.manifest.sig")).unwrap(), b"signature"); + std::fs::remove_dir_all(&out).unwrap(); + std::fs::create_dir(&out).unwrap(); + assert!(check_medium_with(&d, &hex('2'), Some(&out), &yes).is_err()); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "a refused list is not handed on"); + std::fs::remove_dir_all(&out).unwrap(); + + let refused = check_medium_with(&d, &hex('1'), None, &no).unwrap_err(); + assert!(refused.contains("the medium's image list is refused: not signed"), "{refused}"); + + let refused = check_medium_with(&d, &hex('2'), None, &yes).unwrap_err(); + assert!(refused.contains("another root"), "{refused}"); + + std::fs::write(d.join("browser.img"), vec![0u8; 4096]).unwrap(); + let refused = check_medium_with(&d, &hex('1'), None, &yes).unwrap_err(); + assert!(refused.contains("holds 4096 bytes, and the list signs 12288"), "{refused}"); + + std::fs::remove_file(d.join("browser.img")).unwrap(); + let refused = check_medium_with(&d, &hex('1'), None, &yes).unwrap_err(); + assert!(refused.starts_with("image browser:"), "{refused}"); + + // A directory where an image should be is no image, nor is a link to one. + std::fs::create_dir(d.join("browser.img")).unwrap(); + assert!(check_medium_with(&d, &hex('1'), None, &yes).is_err()); + std::fs::remove_dir(d.join("browser.img")).unwrap(); + std::fs::write(d.join("elsewhere"), vec![0u8; 12288]).unwrap(); + std::os::unix::fs::symlink(d.join("elsewhere"), d.join("browser.img")).unwrap(); + assert!(check_medium_with(&d, &hex('1'), None, &yes).unwrap_err().contains("holds"), "a link is not the image"); + std::fs::remove_file(d.join("browser.img")).unwrap(); + std::fs::rename(d.join("elsewhere"), d.join("browser.img")).unwrap(); + + // A FIFO in the list's place is refused at once, never waited on. + let list_text = std::fs::read(d.join("images.manifest")).unwrap(); + std::fs::remove_file(d.join("images.manifest")).unwrap(); + let fifo = std::ffi::CString::new(d.join("images.manifest").to_str().unwrap()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0); + assert!(check_medium_with(&d, &hex('1'), None, &yes).unwrap_err().contains("not a regular file")); + std::fs::remove_file(d.join("images.manifest")).unwrap(); + // Nor is a link to the list followed. + std::fs::write(d.join("list-elsewhere"), &list_text).unwrap(); + std::os::unix::fs::symlink(d.join("list-elsewhere"), d.join("images.manifest")).unwrap(); + assert!(check_medium_with(&d, &hex('1'), None, &yes).unwrap_err().contains("not a regular file")); + std::fs::remove_file(d.join("images.manifest")).unwrap(); + std::fs::write(d.join("images.manifest"), &list_text).unwrap(); + assert!(check_medium_with(&d, &hex('1'), None, &yes).is_ok()); + + std::fs::remove_file(d.join("images.manifest.sig")).unwrap(); + assert!(check_medium_with(&d, &hex('1'), None, &yes).unwrap_err().contains("images.manifest.sig")); + + // A list that does not parse is refused after its signature, as a kept one is. + std::fs::write(d.join("images.manifest.sig"), b"signature").unwrap(); + std::fs::write(d.join("images.manifest"), "KRYPTIK-IMAGES-1\nrelease: 1.2.0\n").unwrap(); + assert!(check_medium_with(&d, &hex('1'), None, &yes).is_err()); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/compartments/kryptikd/src/main.rs b/compartments/kryptikd/src/main.rs index 7f2dce17..4784efc4 100644 --- a/compartments/kryptikd/src/main.rs +++ b/compartments/kryptikd/src/main.rs @@ -11,6 +11,7 @@ mod caps; mod cgroup; mod consent; mod files; +mod images; mod isolate; mod landlock; mod netlink; @@ -53,6 +54,8 @@ USAGE: [--target] and require what a Kryptik kernel must have: root, unprivileged userns restricted, cgroup2 with memory+pids, every zone with [identity] + [--images FILE] and hold each zone's images to an image list: + listed, and what they need granted kryptikd list [--zones DIR] list configured zones kryptikd show NAME [--zones DIR] kryptikd explain NAME what starting this zone would do @@ -125,6 +128,12 @@ USAGE: kryptikd check-release DIR whether DIR's manifest and manifest.sig verify, checked as nobody on copies: how kryptik-update apply, which runs as root, has a release checked + kryptikd check-images DIR BASE [OUT] + a medium's images.manifest and its signature in + DIR, checked as a kept list is, for root BASE, + and each image it names there at its size: the + installer's check before it writes anything; + with OUT, the two files as checked, written there --rootfs DIR base directory for zone data (default: /var/lib/kryptik/zones); the zone sees its own directory as /home/NAME @@ -169,7 +178,7 @@ fn main() -> ExitCode { let zone_dir = zone_dir_from(&args); match args[0].as_str() { - "check" => cmd_check(&zone_dir, args.iter().any(|a| a == "--target")), + "check" => cmd_check(&zone_dir, args.iter().any(|a| a == "--target"), value(&args, "--images").map(Path::new)), "list" if args.iter().any(|a| a == "--running") => cmd_list_running(&zone_dir), "list" => cmd_list(&zone_dir), "show" => match args.get(1) { @@ -308,6 +317,22 @@ fn main() -> ExitCode { "stick" => cmd_stick(&zone_dir, &args), "camera" => cmd_camera(&zone_dir, &args), "time" => cmd_time(&args), + "check-images" => match args.as_slice() { + [_, dir, base] | [_, dir, base, _] => match images::check_medium(Path::new(dir), base, args.get(3).map(Path::new)) { + Ok(said) => { + print!("{said}"); + ExitCode::SUCCESS + } + Err(why) => { + eprintln!("kryptikd: check-images: {why}"); + ExitCode::FAILURE + } + }, + _ => { + eprintln!("usage: kryptikd check-images DIR BASE [OUT]"); + ExitCode::from(2) + } + }, "check-release" => match args.as_slice() { [_, dir] => match update::check_release(Path::new(dir)) { Ok(said) => { @@ -599,8 +624,12 @@ fn zone_dir_from(args: &[String]) -> PathBuf { PathBuf::from(value(args, "--zones").unwrap_or(DEFAULT_ZONE_DIR)) } -fn cmd_check(dir: &Path, target: bool) -> ExitCode { +fn cmd_check(dir: &Path, target: bool, image_list: Option<&Path>) -> ExitCode { let mut failed = false; + // Read unsigned, as the build writes it before signing; a zone start checks the signature. + let list = image_list.map(|p| { + std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display())).and_then(|t| images::parse(&t)) + }); let euid = unsafe { libc::geteuid() }; if target { println!("target contract: required (--target)"); @@ -754,6 +783,36 @@ fn cmd_check(dir: &Path, target: bool) -> ExitCode { } } } + if !z.images.is_empty() { + let granted = z.seccomp.as_ref().and_then(|rel| policy::load(&policy::resolve(dir, rel)).ok()); + match &list { + None => println!(" images {}: held to the signed list when the zone starts", z.images.join(" ")), + Some(Err(e)) => { + eprintln!(" images: {e}"); + failed = true; + } + Some(Ok(m)) => { + if let Err(e) = images::requires_met(m, &z.images) { + eprintln!(" {e}"); + failed = true; + } + for name in &z.images { + let held = m.images.iter().any(|i| &i.name == name); + match images::needs_met(m, name, granted.as_ref()) { + Ok(()) if held => println!(" image {name}: listed, and what it needs is granted"), + Ok(()) => { + eprintln!(" image {name}: the list holds no such image"); + failed = true; + } + Err(e) => { + eprintln!(" {e}"); + failed = true; + } + } + } + } + } + } if target && z.uid_base.is_none() { eprintln!( " --target: zone {:?} declares no [identity] uid_base; a root launch cannot start it", diff --git a/compartments/kryptikd/src/rootfs.rs b/compartments/kryptikd/src/rootfs.rs index 8a13257f..074b02ae 100644 --- a/compartments/kryptikd/src/rootfs.rs +++ b/compartments/kryptikd/src/rootfs.rs @@ -318,8 +318,9 @@ pub fn check_data_dir(path: &str, expected_uid: u32) -> Result<(), RootfsError> } /// Pivot into a root holding only what the zone should see; returns the home. Runs as root in the -/// new user namespace, before Landlock and seccomp; `ephemeral` is a tmpfs home's size, and -/// `service` adds dhcpcd's user and `SERVICE_HOME`. +/// new user namespace, before Landlock and seccomp; `ephemeral` is a tmpfs home's size, `usr` what +/// the zone sees as /usr (zone 0's, or images stacked over it), and `service` adds dhcpcd's user +/// and `SERVICE_HOME`. #[allow(clippy::too_many_arguments)] pub fn pivot_into( data_dir: &str, @@ -331,6 +332,7 @@ pub fn pivot_into( wifi_conf: Option<&str>, sound: Option<&str>, camera: Option<&str>, + usr: &str, service: bool, ) -> Result { let home = zone_home(zone); @@ -387,7 +389,8 @@ pub fn pivot_into( for p in SYSTEM_PATHS { // Skip paths the host lacks; is_dir follows a merged-usr /bin symlink. if Path::new(p).is_dir() { - bind_ro_dir(p, &format!("{root}{p}"))?; + let src = if *p == "/usr" { usr } else { *p }; + bind_ro_dir(src, &format!("{root}{p}"))?; } } diff --git a/compartments/kryptikd/src/seccomp.rs b/compartments/kryptikd/src/seccomp.rs index 3e9c3f9d..bf9bdfc5 100644 --- a/compartments/kryptikd/src/seccomp.rs +++ b/compartments/kryptikd/src/seccomp.rs @@ -428,6 +428,9 @@ pub const REFUSED_SOFTLY: &[(libc::c_long, u32)] = &[ // Firefox lowers the I/O priority of the thread writing its startup cache and writes at the // normal one when refused; killed, the browser ends seconds after its window shows. (libc::SYS_ioprio_set, EPERM), + // Denied, but Firefox's disk-space check reads the user's quota from its network cache's + // thread at start and keeps statfs's answer when refused; killed, no window ever shows. + (libc::SYS_quotactl, EPERM), ]; const fn errno_action(e: u32) -> u32 { diff --git a/compartments/kryptikd/src/seccomp/tests.rs b/compartments/kryptikd/src/seccomp/tests.rs index 9e0e46a7..b0b25265 100644 --- a/compartments/kryptikd/src/seccomp/tests.rs +++ b/compartments/kryptikd/src/seccomp/tests.rs @@ -306,6 +306,14 @@ fn thread_scheduling_refused_softly() { assert_eq!(evaluate(&p, X86, libc::SYS_sched_setscheduler as u32), SECCOMP_RET_ALLOW, "a policy opens it"); } +#[test] +fn quota_read_refused_softly() { + // Reading a quota hears EPERM, not a kill; quotactl stays denied, so no policy opens it. + let p = build_program(BASE_ALLOWLIST).unwrap(); + assert_eq!(evaluate(&p, X86, libc::SYS_quotactl as u32), errno_action(EPERM)); + assert!(is_denied(libc::SYS_quotactl), "quotactl must stay denied"); +} + #[test] fn io_priority_refused_softly() { // Lowering a thread's I/O priority hears EPERM, not a kill; reading it stays allowed. diff --git a/compartments/kryptikd/src/spawn.rs b/compartments/kryptikd/src/spawn.rs index 2b86eb79..a6ce17be 100644 --- a/compartments/kryptikd/src/spawn.rs +++ b/compartments/kryptikd/src/spawn.rs @@ -714,6 +714,15 @@ pub fn run_in_zone( } } + /* The images the zone names, opened and stacked now in zone 0's namespace, where the list's + * signature and the zone's policy are checked first: the zone only binds the result. */ + let usr = if zone.images.is_empty() { + "/usr".to_string() + } else { + crate::images::prepare(zone, zone_policy.as_ref()) + .map_err(|e| SpawnError::Setup(format!("zone {:?}: {e}", zone.name)))? + }; + // One launcher per zone: two would share a data directory, a cgroup and a veth name. let entry = registry::claim(&zone.name).map_err(|e| SpawnError::Setup(e.to_string()))?; @@ -875,7 +884,7 @@ pub fn run_in_zone( let rc = intermediate_main( zone, rootfs, argv, &id, parent_pid, &placed, &ready, &mapped, &initpid, zone_policy.as_ref(), &fs_rules, &broker_path_str, wayland_path_str.as_deref(), opts.wayland_inode, - wifi_conf.as_deref(), sound_dir.as_deref(), camera_dir.as_deref(), + wifi_conf.as_deref(), sound_dir.as_deref(), camera_dir.as_deref(), &usr, ); // Never return: this process must not run the parent's cleanup. unsafe { libc::_exit(rc) }; @@ -1108,6 +1117,7 @@ fn intermediate_main( wifi_conf: Option<&str>, sound_dir: Option<&str>, camera_dir: Option<&str>, + usr: &str, ) -> i32 { macro_rules! bail { ($($arg:tt)*) => {{ @@ -1241,6 +1251,21 @@ fn intermediate_main( Some(format!("/proc/self/fd/{raw}")) } }; + /* The images' /usr too: its set is stacked under /run/kryptik (images.rs), which the zone's + * mapped root may not walk. Like the broker's, the descriptor dies in pid 1's sweep. */ + let usr_in_zone = if usr == "/usr" { + usr.to_string() + } else { + let c = match std::ffi::CString::new(usr) { + Ok(c) => c, + Err(_) => bail!("the images' /usr path contains a NUL"), + }; + let fd = unsafe { libc::open(c.as_ptr(), libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW) }; + if fd < 0 { + bail!("the images' /usr at {}: {}", usr, io::Error::last_os_error()); + } + format!("/proc/self/fd/{fd}") + }; // Until the parent writes our id maps we are nobody (65534) and cannot become root. ready.signal(); @@ -1302,7 +1327,7 @@ fn intermediate_main( if inner == 0 { alive.close_write(); let service = maps_service(id.privileged, zone_policy); - let rc = zone_init(zone, rootfs, argv, flags, zone_policy, fs_rules, plumbed, &broker_in_zone, wayland_in_zone.as_deref(), wifi_conf, sound_dir, camera_dir, service, &alive); + let rc = zone_init(zone, rootfs, argv, flags, zone_policy, fs_rules, plumbed, &broker_in_zone, wayland_in_zone.as_deref(), wifi_conf, sound_dir, camera_dir, &usr_in_zone, service, &alive); unsafe { libc::_exit(rc) }; } alive.close_read(); @@ -1342,6 +1367,7 @@ fn zone_init( wifi_conf: Option<&str>, sound_dir: Option<&str>, camera_dir: Option<&str>, + usr: &str, service: bool, alive: &SyncPipe, ) -> i32 { @@ -1376,7 +1402,7 @@ fn zone_init( (crate::zone::NetworkMode::Routed, _) => rootfs::Resolver::Bridge, _ => rootfs::Resolver::None, }; - let home = match rootfs::pivot_into(rootfs, &zone.name, ephemeral, resolver, Some(broker_path), wayland_path, wifi_conf, sound_dir, camera_dir, service) { + let home = match rootfs::pivot_into(rootfs, &zone.name, ephemeral, resolver, Some(broker_path), wayland_path, wifi_conf, sound_dir, camera_dir, usr, service) { Ok(h) => h, Err(e) => bail!("could not build the zone root: {e}"), }; @@ -1636,7 +1662,17 @@ pub fn explain(zone: &Zone, rootfs: &str, zones_dir: &std::path::Path) -> String identity_line, data_line, storage, - rootfs::SYSTEM_PATHS.join(" "), + rootfs::SYSTEM_PATHS + .iter() + .map(|p| match (*p, zone.images.is_empty()) { + ("/usr", false) => format!( + "/usr (with the images {} over it, signed for this root and opened with dm-verity)", + zone.images.join(" ") + ), + _ => p.to_string(), + }) + .collect::>() + .join(" "), rootfs::ETC_RO_FILES .iter() .chain(rootfs::ETC_RO_DIRS) diff --git a/compartments/kryptikd/src/suspend.rs b/compartments/kryptikd/src/suspend.rs index d1412487..9402d3d8 100644 --- a/compartments/kryptikd/src/suspend.rs +++ b/compartments/kryptikd/src/suspend.rs @@ -35,6 +35,10 @@ const FREEZE_WITHIN: Duration = Duration::from_secs(10); /// How long the state partition's resume may take, its key derivation included. const RESUME_WITHIN: Duration = Duration::from_secs(60); const PATH: &str = "/usr/sbin:/usr/bin:/sbin:/bin"; +/// A seal that failed after wiping a zone volume's key counts itself here, beside the launch +/// socket, where group kryptik reads it: that zone waits with the state partition up, which the +/// chrome cannot tell from a seal under way. +const LEFT_WAITING: &str = "/run/kryptik-launch/left-waiting"; /// Whether the device-mapper device `name` is suspended, as `sys_block` says, or None if no /// device has that name. @@ -192,22 +196,38 @@ pub fn seal() -> Result, String> { // asks for neither skip_lockfs nor noflush), so no zone and no kernel thread, ext4's lazy // init or its journal, writes to a volume without its key; the resume thaws it. One asleep // since an earlier sleep, its passphrase not given yet, stays as it is. + let mut wiped = false; for zone in crate::volume::mappings() { if suspended(&crate::volume::mapper_name(&zone)) { continue; } if let Err(e) = crate::volume::suspend_mapping(&crate::volume::mapper_name(&zone)) { thaw(); + if wiped { + count_left_waiting(Path::new(LEFT_WAITING)); + } return Err(format!("zone {zone:?}'s volume did not suspend: {e}")); } + wiped = true; } if let Err(e) = crate::volume::suspend_mapping(STATE_MAPPER) { thaw(); + if wiped { + count_left_waiting(Path::new(LEFT_WAITING)); + } return Err(format!("the state partition did not suspend: {e}")); } Ok(zones) } +/// One more seal that stopped with a zone volume's key gone, counted at `path`, 0644 whatever the +/// daemon's umask, as the session reads it. Best effort: the count only opens the chrome's window, +/// and its menu names a waiting zone either way. +fn count_left_waiting(path: &Path) { + let n: u64 = fs::read_to_string(path).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0); + let _ = crate::files::write_atomic(path, &[format!("{}\n", n + 1).as_bytes()], 0o644, None); +} + /// Put the machine to sleep (suspend to RAM); returns once it has woken, or with why it did not /// sleep. Nothing is read or started on the way, as the state partition is suspended by now. pub fn sleep() -> Result<(), String> { diff --git a/compartments/kryptikd/src/suspend/tests.rs b/compartments/kryptikd/src/suspend/tests.rs index 236c421e..803c72e9 100644 --- a/compartments/kryptikd/src/suspend/tests.rs +++ b/compartments/kryptikd/src/suspend/tests.rs @@ -1,4 +1,5 @@ use super::*; +use std::os::unix::fs::PermissionsExt; /// A /sys/block of the kernel's shape: device-mapper devices with a name and a suspended flag. fn sys_block(dir: &Path, devices: &[(&str, &str, &str)]) { @@ -66,3 +67,23 @@ fn an_update_holding_its_lock_keeps_the_machine_awake() { assert!(!locked_out(&p), "released with its descriptor"); let _ = fs::remove_file(&p); } + +#[test] +fn a_seal_that_left_a_zone_waiting_counts_itself() { + let dir = std::env::temp_dir().join(format!("kryptikd-left-waiting-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let f = dir.join("left-waiting"); + count_left_waiting(&f); + assert_eq!(fs::read_to_string(&f).unwrap(), "1\n"); + count_left_waiting(&f); + assert_eq!(fs::read_to_string(&f).unwrap(), "2\n", "each such seal counts once more"); + fs::write(&f, "garbage").unwrap(); + count_left_waiting(&f); + assert_eq!(fs::read_to_string(&f).unwrap(), "1\n", "a count it cannot read starts again"); + assert!(!dir.join(format!(".left-waiting.{}", std::process::id())).exists(), "the count is renamed into place"); + assert_eq!(fs::metadata(&f).unwrap().permissions().mode() & 0o777, 0o644, "the session's group reads it"); + // No directory to write in: nothing, and no panic. + count_left_waiting(&dir.join("absent").join("left-waiting")); + let _ = fs::remove_dir_all(&dir); +} diff --git a/compartments/kryptikd/src/update.rs b/compartments/kryptikd/src/update.rs index 005b431a..98cbf1bf 100644 --- a/compartments/kryptikd/src/update.rs +++ b/compartments/kryptikd/src/update.rs @@ -280,7 +280,7 @@ fn check_lock() -> Result { } /// An exclusive flock on `path`, made if missing; held while the returned file is open. -fn lock(path: &Path) -> Result { +pub(crate) fn lock(path: &Path) -> Result { use std::os::unix::io::AsRawFd; let f = std::fs::OpenOptions::new().create(true).append(true).mode(0o600).custom_flags(libc::O_NOFOLLOW).open(path) .map_err(|e| format!("{}: {e}", path.display()))?; @@ -376,7 +376,7 @@ fn check_in(tool: &Path, dir: &Path, verb: &str, inputs: &[(&Path, &str)], whole } /// A fresh directory that mkdtemp names under `base`, or under the system's temporary directory. -fn make_scratch(base: &Path) -> Result { +pub(crate) fn make_scratch(base: &Path) -> Result { use std::os::unix::ffi::{OsStrExt, OsStringExt}; let parent = if base.is_dir() { base.to_path_buf() } else { std::env::temp_dir() }; let mut template = parent.join("kryptik-check.XXXXXX").as_os_str().as_bytes().to_vec(); @@ -405,6 +405,12 @@ pub fn tool_checks() -> Checks<'static> { } } +/// `kryptik-update check-images` on the image list and signature in `dir`, named as a release's +/// manifest is: whether the list is signed in the image namespace. +pub fn check_images(dir: &Path) -> Result { + check_signed_pair("check-images", dir) +} + /// `kryptik-update check-release` on the manifest and signature in `dir`: /// its version and signed date once both verify, in any version order. pub fn check_release(dir: &Path) -> Result { diff --git a/compartments/kryptikd/src/volume.rs b/compartments/kryptikd/src/volume.rs index e6ec53b3..8d78b1af 100644 --- a/compartments/kryptikd/src/volume.rs +++ b/compartments/kryptikd/src/volume.rs @@ -174,7 +174,7 @@ pub fn default_volume_path(zone: &str) -> String { format!("{DEFAULT_VOLUME_DIR}/{zone}.luks") } -fn run(what: &str, prog: &str, args: &[&str], stdin: Option<&[u8]>) -> Result { +pub(crate) fn run(what: &str, prog: &str, args: &[&str], stdin: Option<&[u8]>) -> Result { let mut cmd = Command::new(prog); cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).env_clear().env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin"); cmd.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() }); diff --git a/compartments/kryptikd/src/zone.rs b/compartments/kryptikd/src/zone.rs index e7ce5ed5..a519e97a 100644 --- a/compartments/kryptikd/src/zone.rs +++ b/compartments/kryptikd/src/zone.rs @@ -15,7 +15,7 @@ pub enum NetworkMode { None, /// veth into the bridge owned by the `nic` zone. Routed, - /// Holds the physical interface. Exactly one zone may have this. + /// Holds the physical interface. At most one zone has this, and one must where a zone is routed. Nic, } @@ -97,6 +97,9 @@ pub struct Zone { /// `[transfer] max_bytes = N`: the largest file this zone sends or /// receives through the broker, at most its cap. None: the cap alone. pub transfer_max: Option, + /// `[software] images = "browser viewer"`: signed images stacked over this zone's /usr + /// (images.rs). Adding one takes a zone file on the verified root. + pub images: Vec, /// `[sound] play = false`: no socket to zone 0's mixer (sound.rs). The nic zone never has one. pub sound: bool, /// `[storage] removable = false`: no USB stick is ever given to this zone (stick.rs). The nic @@ -143,6 +146,7 @@ pub const KNOWN_KEYS: &[&str] = &[ "limits.memory_max", "limits.pids_max", "limits.cpu_max", "limits.io_max", "identity.uid_base", "transfer.to", "transfer.max_bytes", + "software.images", "sound.play", "camera.use", "ui.border_color", "ui.border_pattern", "ui.glyph", "ui.label", @@ -502,11 +506,34 @@ impl Zone { }, }; + let images: Vec = match get("software.images") { + None => Vec::new(), + Some(v) => { + let mut out: Vec = Vec::new(); + for n in v.split_whitespace() { + if !crate::images::valid_name(n) { + return Err(bad("software.images", &v, "space-separated image names")); + } + if out.iter().any(|o| o == n) { + return Err(ZoneError::Invalid(format!( + "zone {name:?}: [software] images names {n:?} twice" + ))); + } + out.push(n.to_string()); + } + if out.is_empty() { + return Err(bad("software.images", &v, "at least one image name, or omit [software]")); + } + out + } + }; + let zone = Zone { nic, uid_base, transfer_to, transfer_max, + images, sound, removable, camera, @@ -680,15 +707,18 @@ pub fn check_invariants(zones: &[Zone]) -> Result<(), ZoneError> { } } } - // Exactly one zone holds the NIC: the single path to the network. + /* At most one zone holds the NIC, the single path to the network, and one must wherever a + * routed zone needs that path. A set of zones without network, as a test's, needs none. */ let nic: Vec<&str> = zones .iter() .filter(|z| z.network == NetworkMode::Nic) .map(|z| z.name.as_str()) .collect(); + let routed = zones.iter().any(|z| z.network == NetworkMode::Routed); match nic.len() { 1 => {} + 0 if !routed => {} 0 => { return Err(ZoneError::Invalid( "no zone holds the physical NIC; routed zones would have no path out" diff --git a/compartments/kryptikd/src/zone/tests.rs b/compartments/kryptikd/src/zone/tests.rs index 22f825a3..400b039e 100644 --- a/compartments/kryptikd/src/zone/tests.rs +++ b/compartments/kryptikd/src/zone/tests.rs @@ -357,7 +357,12 @@ fn exactly_one_nic_zone() { assert!(format!("{err}").contains("claim the physical NIC"), "got: {err}"); let none = vec![zone_with("work", "routed", "#111111")]; - assert!(check_invariants(&none).is_err()); + let err = check_invariants(&none).unwrap_err(); + assert!(format!("{err}").contains("routed zones would have no path out"), "got: {err}"); + + // Zones without network need no path out, so no NIC zone. + let offline = vec![zone_with("work", "none", "#111111"), zone_with("vault", "none", "#222222")]; + assert!(check_invariants(&offline).is_ok()); let one = vec![ zone_with("net", "nic", "#111111"), @@ -376,6 +381,16 @@ fn duplicate_colours_are_rejected() { assert!(format!("{err}").contains("border_color"), "got: {err}"); } +#[test] +fn software_images_are_space_separated_names() { + let with = |images: &str| VAULT.replace("[ui]", &format!("[software]\nimages = \"{images}\"\n[ui]")); + assert_eq!(Zone::from_str(&with("browser pdf-viewer")).unwrap().images, vec!["browser", "pdf-viewer"]); + assert!(Zone::from_str(VAULT).unwrap().images.is_empty(), "no [software], no images"); + for bad in ["", "Browser", "a/b", "browser browser", "x_y"] { + assert!(Zone::from_str(&with(bad)).is_err(), "{bad:?}"); + } +} + #[test] fn a_zone_takes_usb_sticks_unless_its_file_says_no() { assert!(Zone::from_str(VAULT).unwrap().removable, "a zone may be given a stick unless its file says no"); diff --git a/compartments/zones/personal.toml b/compartments/zones/personal.toml index 9c7583fd..5e1ece5c 100644 --- a/compartments/zones/personal.toml +++ b/compartments/zones/personal.toml @@ -16,7 +16,8 @@ seccomp = "policy/personal.seccomp" [limits] memory_max = "8G" -pids_max = 1024 +# Threads count too: Firefox runs a process for each site, each with dozens of threads. +pids_max = 4096 [identity] # Root inside maps to host uid/gid 262144, nobody to 327678; fixed, never derived from zone order. @@ -29,3 +30,8 @@ border_color = "#009b79" border_pattern = "dashed" glyph = "●" label = "PERSONAL" + +[software] +# The browser, and the toolkit it is built over (docs/design/software-delivery.md); the call its +# content sandbox needs is granted in the zone's policy. +images = "browser toolkit" diff --git a/compartments/zones/policy/personal.seccomp b/compartments/zones/policy/personal.seccomp index 574a3775..88ff77d2 100644 --- a/compartments/zones/policy/personal.seccomp +++ b/compartments/zones/policy/personal.seccomp @@ -1 +1,3 @@ # personal: the base policy only; the directives are in docs/design/zone-policy-files.md. +# The browser image's need: Firefox's content sandbox installs a seccomp filter of its own. +allow-syscall seccomp diff --git a/compartments/zones/policy/untrusted.seccomp b/compartments/zones/policy/untrusted.seccomp index 267ba684..d207192e 100644 --- a/compartments/zones/policy/untrusted.seccomp +++ b/compartments/zones/policy/untrusted.seccomp @@ -1 +1,3 @@ # untrusted: the base policy only; the directives are in docs/design/zone-policy-files.md. +# The browser image's need: Firefox's content sandbox installs a seccomp filter of its own. +allow-syscall seccomp diff --git a/compartments/zones/policy/work.seccomp b/compartments/zones/policy/work.seccomp index 6101451b..f4876625 100644 --- a/compartments/zones/policy/work.seccomp +++ b/compartments/zones/policy/work.seccomp @@ -1 +1,3 @@ # work: the base policy only; the directives are in docs/design/zone-policy-files.md. +# The browser image's need: Firefox's content sandbox installs a seccomp filter of its own. +allow-syscall seccomp diff --git a/compartments/zones/untrusted.toml b/compartments/zones/untrusted.toml index 2a10d310..e7628150 100644 --- a/compartments/zones/untrusted.toml +++ b/compartments/zones/untrusted.toml @@ -20,7 +20,8 @@ seccomp = "policy/untrusted.seccomp" [limits] memory_max = "4G" -pids_max = 512 +# Threads count too: Firefox runs a process for each site, each with dozens of threads. +pids_max = 2048 # Two CPUs' worth of time: what runs here cannot take the whole machine. cpu_max = "200%" @@ -35,3 +36,8 @@ border_color = "#ba7c02" border_pattern = "dash-dot" glyph = "▲" label = "UNTRUSTED" + +[software] +# The browser, and the toolkit it is built over (docs/design/software-delivery.md); the call its +# content sandbox needs is granted in the zone's policy. +images = "browser toolkit" diff --git a/compartments/zones/work.toml b/compartments/zones/work.toml index 2ef40fbb..4416a76e 100644 --- a/compartments/zones/work.toml +++ b/compartments/zones/work.toml @@ -16,7 +16,8 @@ seccomp = "policy/work.seccomp" [limits] memory_max = "8G" -pids_max = 1024 +# Threads count too: Firefox runs a process for each site, each with dozens of threads. +pids_max = 4096 [identity] # Root inside maps to host uid/gid 458752, nobody to 524286; fixed, never derived from zone order. @@ -29,3 +30,8 @@ border_color = "#2d60d1" border_pattern = "solid" glyph = "■" label = "WORK" + +[software] +# The browser, and the toolkit it is built over (docs/design/software-delivery.md); the call its +# content sandbox needs is granted in the zone's policy. +images = "browser toolkit" diff --git a/compositor/wlproxy/src/policy.rs b/compositor/wlproxy/src/policy.rs index 77878a90..efb4b5f6 100644 --- a/compositor/wlproxy/src/policy.rs +++ b/compositor/wlproxy/src/policy.rs @@ -80,21 +80,28 @@ pub fn app_id_for(zone: &str, claimed: &str) -> String { format!("kryptik.{zone}.{}", if cleaned.is_empty() { "app".to_string() } else { cleaned }) } -// Resource bounds per client connection and across a zone's connections. +/* Resource bounds per client connection and across a zone's connections. Passing one ends the + * connection, as Wayland answers a pool or a window it will not make only with a protocol error, + * and a browser draws all its windows over one connection: the bounds sit above what Firefox + * needs on two 4K heads (docs/design/resource-limits-and-ephemeral-zones.md). */ pub const MAX_OBJECTS: usize = 4096; /// Id slots per range: libwayland reuses freed ids, so only a client that never does reaches this. pub const MAX_ID_SLOTS: usize = 2 * MAX_OBJECTS; pub const MAX_PENDING_BYTES: usize = 1 << 20; // per direction pub const MAX_PENDING_FDS: usize = 64; -pub const MAX_TOPLEVELS_PER_SESSION: usize = 8; +/// A browser's windows, dialogs and picture-in-picture all count. +pub const MAX_TOPLEVELS_PER_SESSION: usize = 32; pub const MAX_TOPLEVELS_PER_ZONE: usize = 64; /// Menus and their submenus, tooltips: the compositor keeps each inside its window. pub const MAX_POPUPS_PER_SESSION: usize = 16; -pub const MAX_SHM_POOLS_PER_SESSION: usize = 32; -pub const MAX_SHM_POOLS_PER_ZONE: usize = 128; -pub const MAX_SHM_POOL_BYTES: usize = 64 << 20; // room for a 4K RGBA frame and stride padding -pub const MAX_SHM_BYTES_PER_SESSION: usize = 128 << 20; -pub const MAX_SHM_BYTES_PER_ZONE: usize = 256 << 20; +/// Each pool is a mapping in dwl: far below vm.max_map_count, where dwl's own mappings would fail. +pub const MAX_SHM_POOLS_PER_SESSION: usize = 128; +pub const MAX_SHM_POOLS_PER_ZONE: usize = 256; +pub const MAX_SHM_POOL_BYTES: usize = 128 << 20; // room for a 6K RGBA frame and stride padding +/// What dwl maps for a zone at one time. A page dwl reads before the zone wrote it is charged to +/// dwl, not to the zone's memory_max; these bound that while the pools live, not after. +pub const MAX_SHM_BYTES_PER_SESSION: usize = 1 << 30; +pub const MAX_SHM_BYTES_PER_ZONE: usize = 1 << 30; /// A pool a surface may still show stays charged, so these bound what one session can hold. pub const MAX_SUBSURFACES_PER_SESSION: usize = 256; pub const MAX_HELD_PER_SURFACE: usize = 64; diff --git a/compositor/wlproxy/src/session/tests.rs b/compositor/wlproxy/src/session/tests.rs index 213157f6..84f3cb57 100644 --- a/compositor/wlproxy/src/session/tests.rs +++ b/compositor/wlproxy/src/session/tests.rs @@ -285,9 +285,9 @@ fn shm_pool_limits() { for id in 4..4 + policy::MAX_SHM_POOLS_PER_SESSION as u32 { send_with_fd(c.as_raw_fd(), &create(id, 4096), fd.as_raw_fd()); pump_all(&mut s).unwrap(); + read_all(&mut sv); } assert_eq!(s.shm_pool_count, policy::MAX_SHM_POOLS_PER_SESSION); - read_all(&mut sv); let denied = 4 + policy::MAX_SHM_POOLS_PER_SESSION as u32; send_with_fd(c.as_raw_fd(), &create(denied, 4096), fd.as_raw_fd()); assert!(matches!(pump_all(&mut s), Err(SessionError::ResourceLimit(_)))); @@ -296,17 +296,40 @@ fn shm_pool_limits() { let (mut s, mut c, mut sv) = make(); s.objects.place(3, (protocol::find("wl_shm").unwrap(), 1)); - for id in 4..6 { + let full = (policy::MAX_SHM_BYTES_PER_SESSION / policy::MAX_SHM_POOL_BYTES) as u32; + for id in 4..4 + full { send_with_fd(c.as_raw_fd(), &create(id, policy::MAX_SHM_POOL_BYTES as i32), fd.as_raw_fd()); pump_all(&mut s).unwrap(); + read_all(&mut sv); } assert_eq!(s.shm_pool_bytes, policy::MAX_SHM_BYTES_PER_SESSION); - read_all(&mut sv); - send_with_fd(c.as_raw_fd(), &create(6, 4096), fd.as_raw_fd()); + send_with_fd(c.as_raw_fd(), &create(4 + full, 4096), fd.as_raw_fd()); assert!(matches!(pump_all(&mut s), Err(SessionError::ResourceLimit(_)))); assert!(read_all(&mut sv).is_empty()); } +#[test] +fn a_browser_on_two_4k_heads_fits() { + // Firefox draws each window into pools of its own, a front buffer, the one it draws and up to + // three spare, and GTK holds two more for the window: two 4K windows and developer tools. + let (mut s, mut c, mut sv) = make(); + s.objects.place(3, (protocol::find("wl_shm").unwrap(), 1)); + let (fd, _) = UnixStream::pair().unwrap(); + let frame = 3840 * 2160 * 4; + for id in 4..4 + 3 * 7 { + send_with_fd(c.as_raw_fd(), &MessageWriter::new(3, 0).u32(id).i32(frame).finish().unwrap(), fd.as_raw_fd()); + pump_all(&mut s).unwrap(); + read_all(&mut sv); + } + assert_eq!(s.shm_pool_bytes, 3 * 7 * frame as usize); + s.objects.place(30, (protocol::find("xdg_surface").unwrap(), 1)); + for id in 31..31 + 12 { + c.write_all(&MessageWriter::new(30, 1).u32(id).finish().unwrap()).unwrap(); + pump_all(&mut s).unwrap(); + } + assert_eq!(s.toplevels, 12); +} + #[test] fn pool_budget_follows_buffers() { let (mut s, mut c, mut sv) = make(); diff --git a/docs/audit-scope.md b/docs/audit-scope.md index a77a2720..fc6aa330 100644 --- a/docs/audit-scope.md +++ b/docs/audit-scope.md @@ -121,7 +121,10 @@ the author, with their findings fixed: across zones, and pools are bounded per connection and per zone. Whether the compositor's share of that memory is charged to the zone's cgroup is still a measurement to make - ([resource limits](design/resource-limits-and-ephemeral-zones.md#tests)). + ([resource limits](design/resource-limits-and-ephemeral-zones.md#tests)), + and the bounds hold it only while the pools live: a zone that keeps a + pool's descriptor keeps what the compositor faulted + ([Wayland shared memory](design/resource-limits-and-ephemeral-zones.md#wayland-shared-memory)). The broker's request parser and the proxy's wire parser have seeded mutation tests with corpora in the tree. Coverage-guided fuzzing is not run. diff --git a/docs/building.md b/docs/building.md index 9601e9f3..ecaa223b 100644 --- a/docs/building.md +++ b/docs/building.md @@ -43,9 +43,17 @@ make toolchain # stage 01 (unprivileged) make temp-tools # stage 02 (unprivileged) make system # stage 04, inside the chroot (root for the mounts) make kernel # stage 05, inside the chroot +make images # the software images over stage 04: the toolkit, the browser (hours) make media # stage 06: USB image, ISO, signed release payload ``` +`untrusted`, `personal` and `work` name the browser and toolkit images, and a +zone naming an image does not start unless its root has a signed list that +holds it, so a medium for use needs `make images` before `make media`. Firefox +alone takes some three hours on four cores; a rebuilt stage 04 rebuilds it +only when a step up to `wlroots` changed, or the headers and libraries a build +can find ([software delivery](design/software-delivery.md)). + `make paths` prints where everything goes; `KRYPTIK_WORK` moves the work tree. Each step is stamped, so a rerun resumes where it stopped and a changed recipe rebuilds from that step on. diff --git a/docs/decisions.md b/docs/decisions.md index bfc13f29..55e0da62 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -260,7 +260,7 @@ firmware's boot entries and a judged trial, not a loader's menu. file; an initramfs inside the signed image, measured but a second userland to keep small and right. -## ADR-015 (proposed): Applications ship as signed images beside the root +## ADR-015: Applications ship as signed images beside the root The root keeps the base system: what zone 0 runs and what every zone needs. Applications (the browser and its toolkit, a mail client, a document viewer, @@ -293,7 +293,7 @@ ephemeral zones); another distribution's userland in a zone (gives up ADR-001 where the applications run); everything on the root (does not fit the slots of machines already installed). -## ADR-016 (proposed): Firefox in zones, rendering in software; the GPU and X11 by zone +## ADR-016: Firefox in zones, rendering in software; the GPU and X11 by zone The browser is Firefox ESR, built by stage 04's GCC for Wayland alone, on GTK 3 without X11 or D-Bus, and run with its own seccomp sandbox inside the diff --git a/docs/design/boot-and-updates.md b/docs/design/boot-and-updates.md index 7b25c829..aceb2193 100644 --- a/docs/design/boot-and-updates.md +++ b/docs/design/boot-and-updates.md @@ -33,7 +33,11 @@ The USB image holds `kryptik-esp` and `kryptik-media`; the ISO holds the ESP as its El Torito image and the root image at a sector offset (a `linear` dm target over `/dev/sr0`, verity on top). The root image is byte-identical on the medium, in slot A and in a payload, so the installer copies bytes and -signs nothing. +signs nothing. A release with +[software images](software-delivery.md) also carries them: the USB image in +a third partition, `kryptik-images`, a FAT32 like the ESP, and the ISO as +files under `/images` in its own filesystem. Either holds the signed list, +its signature and each image. ## The command line @@ -130,14 +134,19 @@ entry left alone. ## Updates A payload directory holds exactly `kryptik-root.img.zst`, `kryptik-a.efi`, -`kryptik-b.efi`, `root.json`, `manifest` and `manifest.sig`. The manifest -lists each file's sha256 and size, the version and the role, and is signed by -the release key (Ed25519, `ssh-keygen -Y`, namespace `kryptik-release`). The -root image travels compressed, at about a quarter of its size; `root.json`, -which the manifest lists, names the image's own sha256 and size. `apply` -decompresses only a file whose hash matched the manifest, and checks what it -decompresses to against `root.json` before it writes the inactive slot, then -writes it decompressing again and reads the slot back. +`kryptik-b.efi`, `root.json`, `manifest` and `manifest.sig`, and the +release's software images if it has any: `images.manifest`, its signature +and each image as `NAME.img.zst` ([how software reaches +zones](software-delivery.md)). The manifest lists each file's sha256 and +size, the version and the role, and is signed by the release key (Ed25519, +`ssh-keygen -Y`, namespace `kryptik-release`). The root image travels +compressed, at about a quarter of its size; `root.json`, which the manifest +lists, names the image's own sha256 and size. `apply` decompresses only a +file whose hash matched the manifest, and checks what it decompresses to +against `root.json` before it writes the inactive slot, then writes it +decompressing again and reads the slot back. Each software image is +decompressed into the state partition's store, and checked there against +its list, before the slot is written. Versions are ordered as `sort -V` orders them. A production release is numbered MAJOR.MINOR.PATCH, which that order reads as a person does ([release keys](../release-keys.md)). The trust anchor and required role are on the verified root in diff --git a/docs/design/browser-and-graphics.md b/docs/design/browser-and-graphics.md index b495aeb1..3b7207ee 100644 --- a/docs/design/browser-and-graphics.md +++ b/docs/design/browser-and-graphics.md @@ -6,7 +6,7 @@ Today a zone gets a terminal and a text browser, drawn into shared memory and composited on the CPU. This document chooses the browser, the toolkit under it, how a zone is given the GPU and what the proxy must then carry, how Xwayland runs inside a zone (ADR-004), and what all of it costs the -root. The proposed decision is ADR-016 in [decisions](../decisions.md). +root. The decision is ADR-016 in [decisions](../decisions.md). ## Where things stand @@ -22,9 +22,10 @@ root. The proposed decision is ADR-016 in [decisions](../decisions.md). (below). A cursor named through `wp_cursor_shape_manager_v1` is drawn by the compositor from its own theme, while an image a zone draws is never taken. It disconnects a client - that binds anything else, and bounds shared-memory pools at 64 MiB each, - 128 MiB per connection and 256 MiB per zone - (`compositor/wlproxy/src/policy.rs`). + that binds anything else, and bounds shared-memory pools at 128 MiB each + and 1 GiB per connection and per zone, which Firefox on two 4K heads fits + (`compositor/wlproxy/src/policy.rs`, + [resource limits](resource-limits-and-ephemeral-zones.md#wayland-shared-memory)). - **A zone has no GPU device.** Its `/dev` holds `null`, `zero`, `full`, `random`, `urandom`, `tty`, its own `/dev/shm` and a devpts instance (`DEVICES` in `compartments/kryptikd/src/rootfs.rs`). @@ -48,8 +49,8 @@ root. The proposed decision is ADR-016 in [decisions](../decisions.md). - **ADR-001 and ADR-010.** Built from source here; Rust comes from the pinned release toolchain, as for kryptikd. - **The slot size** of machines already installed (below), and the software - images proposed as ADR-015 in `docs/design/software-delivery.md`, beside - this design. + images of ADR-015 (`docs/design/software-delivery.md`), beside this + design. ## The browser @@ -58,9 +59,9 @@ root. The proposed decision is ADR-016 in [decisions](../decisions.md). - **Build.** Firefox builds with GCC 11.1 or newer on Linux, so stage 04's GCC 14 can compile it. It still needs clang and libclang, since bindgen generates its style system's bindings through libclang whatever the - compiler; the pinned Rust toolchain and cbindgen; Node.js, unless the - build passes `--disable-nodejs`, which upstream warns will become an - error; Python 3; and nasm for its codecs' SIMD. Its wasm-sandboxed + compiler; the pinned Rust toolchain and cbindgen; Node.js, which the + build runs for the devtools debugger's modules, `--disable-nodejs` or + not; Python 3; and nasm for its codecs' SIMD. Its wasm-sandboxed libraries (RLBox: graphite, hunspell, expat, ogg, woff2 and others run as WebAssembly compiled back to native code) need clang's `wasm32` target and a WASI sysroot. They are worth keeping: a font or spelling parser bug stays @@ -71,24 +72,73 @@ root. The proposed decision is ADR-016 in [decisions](../decisions.md). - **Its sandbox inside a zone.** Firefox confines its content processes with seccomp-bpf, installed with `seccomp(2)` and `SECCOMP_FILTER_FLAG_TSYNC`, and adds a namespace layer (chroot, no network) where it can create user - namespaces. Two things stop it in a zone today: + namespaces. - At start it probes for user namespaces by calling - `clone(CLONE_NEWUSER)` in its own process (`SandboxInfo.cpp`), unless - `MOZ_ASSUME_USER_NS` says the answer. The zone filter kills that call, - and the browser with it. - - `seccomp(2)` is not in the base list, so the call is killed too. - - The fix for the first is general: answer `clone` and `unshare` with - namespace flags with `EPERM`, as the kernel itself answers an unprivileged - caller, instead of killing. Chromium, bubblewrap and Firefox all probe this - way, and an `EPERM` grants nothing. The second is a policy line, - `allow-syscall seccomp`, in the zones that run the browser. Firefox's - filters then stack under the zone's, and the stricter answer wins. Its - namespace layer stays off, the price of no nested user namespaces - (`hardened.fragment`). + `clone(CLONE_NEWUSER)` in its own process (`SandboxInfo.cpp`). The zone + filter answers `clone` and `unshare` with namespace flags with `EPERM`, + as the kernel answers an unprivileged caller, so the probe fails and the + browser starts; Chromium and bubblewrap probe the same way, and an + `EPERM` grants nothing. + - `seccomp(2)` is not in the base list. The browser image needs it + (`build/images/browser.needs`), and the zones that run the browser, + `untrusted`, `personal` and `work`, grant it with + `allow-syscall seccomp`, which `kryptikd check` holds them to. + + Firefox's filters then stack under the zone's, and the stricter answer + wins. Its namespace layer stays off, the price of no nested user + namespaces (`hardened.fragment`). - **Updates.** The extended-support release (ESR) has a security release every four weeks, like the rapid channel, with fewer feature changes. +### A second launch where Firefox is open + +With no remote, a second `firefox` in a zone whose browser is open finds +its profile locked and says that Firefox is already running. The browser +key (Alt+Shift+u) and the menu's `b` should instead put that zone's open +Firefox window in front, and start Firefox only when the zone has none. A +design, not yet built: + +- **Who knows the window.** dwl alone. For every window it holds the zone, + taken from the socket the client came through and never from the client, + the `app_id` Firefox sets, its tags and monitor, and the focus order. The + `app_id` is the client's own word, but a match is only sought among one + zone's windows, so a zone that claims it can bring forward only its own + window. The chrome sees only the focused window of each output, from + dwl's status. The launch daemon sees the zone's processes, not its + windows. +- **The key.** A dwl action in place of `spawn`, focus-or-launch, given the + zone, the `app_id` and the launch command. It takes the first window in + the focus order whose zone and `app_id` match, so the one used last. If + that window is not shown, dwl first views its first tag on its monitor, + as Alt and the tag's number would, then gives it the keyboard. With no + such window, it starts the command as the key does now. No channel to + dwl is needed. +- **The menu.** The chrome keeps the launch, as the zone's passphrase is + asked on its terminal. It needs two things from dwl: + - dwl's status gains a line naming each zone's open `app_id`s. The + chrome's status reader records it beside the focus record, in the + session's runtime directory, and `b` looks there first. + - When the browser is open, the chrome writes a one-line request, the + zone and that `app_id`, to a file in that directory. dwl watches it the + way it watches the microphone and camera marks: inotify on the + directory, and a file that must be the session user's own and not + writable by group or others. dwl reads it, removes it and acts on it as + the key does. The runtime directory is the session user's alone, mode + 0700, so no zone can make the request. +- **On another tag or monitor.** dwl views the window's tag on the monitor + it is on, and the keyboard follows it there. A window is never moved + between tags, so the vault keeps its tag. +- **A waiting zone.** The window of a zone frozen after a sleep, its volume + waiting for the passphrase, comes forward all the same and does not + answer. The menu already says the zone is waiting and asks with `p`. +- **Not chosen.** Firefox's remote over a D-Bus session bus private to the + zone would hand the second launch to the open browser, but it puts D-Bus + back into the image and the zone, which the build leaves out on purpose. + A Wayland protocol for the chrome, wlroots' foreign-toplevel management + or one of Kryptik's own, is more code than a status line and a watched + file, and foreign-toplevel management carries no zone: the chrome would + have to trust the titles. + ### Chromium Google builds and supports Chromium with clang alone, and it needs Rust and @@ -109,15 +159,34 @@ browser. ## The toolkit stack GTK 3 for Firefox, built with `-Dx11_backend=false`, so no X11 library -enters the image, and without the accessibility bridge, which talks D-Bus: - -- glib, pango, harfbuzz, fribidi, freetype, fontconfig, cairo (pixman is - already built), gdk-pixbuf with libpng and libjpeg-turbo, libepoxy, ATK - from at-spi2-core; -- shared-mime-info, the hicolor theme and one small icon theme; -- GSettings with its keyfile backend, since dconf needs D-Bus; -- fonts: DejaVu is shipped; Noto for wider scripts costs tens of megabytes, - and CJK fonts a hundred or more, so they are a choice, not a default. +enters the image, and without the accessibility bridge, which talks D-Bus. +It is the `toolkit` software image (`build/images/toolkit.sh`), which the +browser image is built over: + +- glib (with pcre2, without its JIT), pango, harfbuzz, fribidi, freetype, + fontconfig, cairo (pixman is the root's), gdk-pixbuf with libpng, libepoxy + and ATK from at-spi2-core, built alone with a patch that drops its + unused libxml2; +- the Khronos EGL headers from libglvnd, which libepoxy and GTK's Wayland + backend compile against. No EGL library comes with them: a zone has no + GPU, so GL stays unavailable there; +- gdk-pixbuf reads PNG alone, built in: GTK's theme and icons are PNG, and + each other loader is a parser more. JPEG, GIF and the rest come only if a + program the image carries needs them; Firefox decodes its own; +- the hicolor theme. An icon theme with symbolic icons needs librsvg, so + GTK falls back to its built-in icons until one is chosen; +- fontconfig's and GTK's configuration under `/usr/etc`, as an image adds + nothing outside `/usr`; fontconfig keeps its cache in the user's home; +- GSettings with the memory backend by default, or the keyfile backend + (`GSETTINGS_BACKEND=keyfile`), since dconf needs D-Bus; +- printing to a file alone, which GTK requires; no CUPS; +- fonts: DejaVu is the root's; Noto for wider scripts costs tens of + megabytes, and CJK fonts a hundred or more, so they are a choice, not a + default. + +gtk3-demo and gtk3-widget-factory come with it, the first GUI programs a +zone can run. The zones suite runs `fc-match` and `gtk-query-immodules-3.0` +in a zone that names the image, which loads every library GTK needs. Firefox's own copies of NSS, NSPR, libvpx, dav1d and opus stay bundled: they are built from source in its tree either way, and every library made @@ -125,6 +194,110 @@ separate is a recipe and a pin review of its own. H.264 needs FFmpeg's decoder, which Firefox loads if it is present; whether to ship it is a licensing question as much as a size one, and is left open. +## The document viewer + +PDFs open in the browser's own viewer, pdf.js, which runs as page script +inside the content process's sandbox. No image of its own is needed until a +workflow needs a format pdf.js does not read. zathura, the light viewer that +was the alternative, moved to GTK 4 in 2026 and now also needs json-glib, +libmagic, sqlite and xxhash. Building it would mean a second toolkit image, +or pinning a GTK 3 branch nobody maintains any more. + +## How the browser image is built + +Firefox ESR 153 is the `browser` software image, built over the toolkit +image and over build-only layers that never reach a zone (`image_ships=no`, +[software delivery](software-delivery.md)). Stage 04 already treats cmake +this way: a pinned binary the build runs and never installs. ESR 153's own +floors are given with each layer. + +- **Its layers.** + - **`rust`:** the pinned release toolchain for `x86_64-unknown-linux-gnu`, + vouched for by the same Rust key as kryptikd's. ESR 153 needs 1.90 or + newer. + - **`clang`:** LLVM's release build, for libclang. bindgen loads it to + generate the style system's bindings, and its `wasm32` target compiles + the sandboxed libraries. It is about a gigabyte compressed, in the + sources cache. A source build of clang and libclang would instead cost + an hour each time the base system changes. + - **`cbindgen`:** 0.29.4 or newer, built with the `rust` layer. Its crate + and the crates its lock file names are fetched before the build, since + the chroot has no network, and checked against that lock. + - **`nasm`:** for dav1d's AV1 decoder. + - **`node`:** Node.js's release build, the program alone, which the build + runs for the devtools debugger's modules. ESR 153's configure still + takes `--disable-nodejs`, but the build then stops at those modules. + 12.22.12 or newer; the pin keeps to an LTS line, and one of the + releasers whose keys Node.js lists signs its checksum list. +- **The build.** + - Stage 04's GCC with the hardening flags; an exception each flag needs + goes into `hardening-exceptions.txt` with its reason. Firefox drops + `-fno-delete-null-pointer-checks`, under which GCC 14 cannot evaluate + SpiderMonkey's compile-time checks (Mozilla's own builds go without it + too), and carries the few changes a GCC + build needs (`build/patches/firefox-153.5.0`). + - `--enable-default-toolkit=cairo-gtk3-wayland-only`, `--disable-dbus`, + `--enable-release`, `--disable-tests`, `--disable-crashreporter`, + `--disable-updater` (updates are the image's) and `--disable-eme`. + With neither D-Bus nor X11 Firefox has no remote: a second launch in a + zone whose browser is open is not handed to it, and says that Firefox is + already running (`0004` in the patch set). + - `--disable-jemalloc`, as the image preloads hardened_malloc for every + process. + - `--enable-audio-backends=alsa`: a zone's default ALSA device is zone + 0's mixer (the laptop design's sound path). Firefox needs alsa-lib in + any case, as Web MIDI on Linux links ALSA's sequencer API; with no + sequencer in the kernel and no `/dev/snd` in a zone, it reaches no + device. + - The first build is `--without-wasm-sandboxed-libraries`. The next adds + a WASI sysroot to the `clang` layer, since a font or spelling parser + bug should stay inside that sandbox. + - The build's Glean step caches what it parses in SQLite, through + Python's `sqlite3`, which the base system's Python lacks, as zone 0 has + no SQLite. The recipe compiles that module from the base system's own + Python source and the SQLite Firefox carries, into the build's root + alone, and `browser.strip` keeps it out of the image. +- **Time.** + - On four cores with GCC, without LTO or PGO, ESR takes some hours. The + images build in a job of their own, between stages 04-05 and stage 06, + since a runner gives a job six hours and a stage 04 built from nothing + (a tag's, or a run after a new pin) beside the browser and stage 06 + would not fit one job. Not every run should pay it. + - The trees of the shipped images go into a cache of their own, keyed by + their fingerprints, which include the base system's stamp up to + `wlroots`, the last step an image builds against. A run restores a tree + whose inputs have not changed, so only a change to the browser, the + toolkit or the base system up to `wlroots` rebuilds it; zone 0's own + programs, which stage 04 builds after it, do not. +- **Updates.** ESR has a security release every four weeks. Its pins are the + images' (`build/images/versions.env`), so a bump rebuilds the browser and + leaves stage 04 and its cache alone. +- **Choices taken.** + - **The Rust tarballs are image sources.** The `rust` layer lists the + rustc, cargo and rust-std tarballs `build/config/rust.lock` pins, so the + signature and currency gates cover them, and kryptikd's compiler with + them. A CI check holds both locks to the same hashes, so the shipped + daemon and the browser are built by one release, bumped every six weeks. + - **cbindgen is built inside the chroot, offline.** Its crate and the + crates its own lock file names are fetched beforehand and checked + against that lock. In the chroot each is unpacked into a vendored source + directory, and `cargo build --offline --locked` uses the `rust` layer. + Stage 03's runner and stage 04's cache key stay as they are. + - **The images' sources have a cache of their own.** They go under + `sources/images/`, keyed by `build/images/sources.lock`, each named in + `sources/` by a link, so every tool finds it where it always has. The + root's set is keyed by `sources.lock` alone and saved with `images/` set + aside, so a browser bump copies neither set into the other's entry. A job + that builds no image fetches the root's alone (`fetch-sources.sh --root`). + - **Mozilla's key comes from Mozilla.** The fingerprint is pinned like + every other, confirmed by Mozilla's security blog post of August 2026. + The key material is taken from the `KEY` file beside the release rather + than from the keyserver, whose copy lacks the new subkey and the old + one's revocation. That is an `origin` row in `tools/key-provenance.tsv`, + a kind only a pinned key may have: the pin says who signs, the row only + where the key is fetched from on every run, so a later revocation lands. + Rust's and LLVM's keys come from their own sites the same way. + ## GPU rendering, zone by zone ### What software rendering gives @@ -287,7 +460,7 @@ filesystem and the hash tree. 400 MiB left. Mesa and libLLVM leave about 200 MiB; a mail client after that does not fit, and the release that crosses the line needs a reinstall: a major version under [releases](../releases.md). -- As a software image (ADR-015, proposed), the browser costs the root +- As a software image (ADR-015), the browser costs the root nothing, and the state partition about twice the image while an update is in flight. - **Build time.** Firefox is one of the largest builds there is, and it diff --git a/docs/design/delta-updates.md b/docs/design/delta-updates.md index ea049c7a..0b2c7b91 100644 --- a/docs/design/delta-updates.md +++ b/docs/design/delta-updates.md @@ -10,8 +10,9 @@ whether it is worth doing at today's release sizes. ## Where things stand - **A payload** is `kryptik-root.img.zst`, `kryptik-a.efi`, `kryptik-b.efi`, - `root.json`, `manifest` and `manifest.sig`. The manifest lists every - file's SHA-256 and size and is signed by the release key + `root.json`, `manifest` and `manifest.sig`, and the release's software + images if it has any. The manifest lists every file's SHA-256 and size and + is signed by the release key ([boot and updates](boot-and-updates.md#updates)). - **The channel moves whole files.** `update-put` takes the manifest and its signature first. Then it takes only listed files, each from the byte zone 0 diff --git a/docs/design/installer-choices.md b/docs/design/installer-choices.md index 505c8345..7a418497 100644 --- a/docs/design/installer-choices.md +++ b/docs/design/installer-choices.md @@ -52,8 +52,8 @@ often a major version happens. The proposed decision is ADR-023 in `--slot-size` covers the user who knows. The default is the question. Image plus half again was right for a root that grew slowly. Version 2 puts more in the root: firmware, Mesa if the compositor moves to the GPU, and the -browser stack itself unless applications ship as images (ADR-015, proposed -in `docs/design/software-delivery.md`). The browser design works out that the +browser stack itself unless applications ship as images (ADR-015, +`docs/design/software-delivery.md`). The browser design works out that the software-rendered browser in the root alone leaves about 400 MiB of today's slot. diff --git a/docs/design/laptop.md b/docs/design/laptop.md index 183b5ae3..d566a9a0 100644 --- a/docs/design/laptop.md +++ b/docs/design/laptop.md @@ -278,7 +278,9 @@ to resume can run from the root while the state partition is suspended. `kryptikd volume resume` as root); the daemon's `info` says `waiting yes` meanwhile, and a launch into it is told why. Once the state partition is back the chrome opens a window that asks for each waiting zone's - passphrase in turn (`kryptik-launch --resume`, on its own terminal); + passphrase in turn (`kryptik-launch --resume`, on its own terminal), as + it does when a sleep fails after a zone volume's key went, which the + daemon counts in `/run/kryptik-launch/left-waiting`; the menu shows the zone WAITING and takes it with `p`, and `kryptik-launch --ask`, as the zone keys run it, asks for it instead of starting the zone again. Stopped without its diff --git a/docs/design/resource-limits-and-ephemeral-zones.md b/docs/design/resource-limits-and-ephemeral-zones.md index a1df3355..7bd141b9 100755 --- a/docs/design/resource-limits-and-ephemeral-zones.md +++ b/docs/design/resource-limits-and-ephemeral-zones.md @@ -108,6 +108,52 @@ buffers are deleted and no surface shows one of them, counting each buffer a synchronized subsurface committed until wlroots applies that subsurface's cache. +## Wayland shared memory + +A zone's windows reach dwl as shared memory: a client hands dwl a descriptor +(`wl_shm.create_pool`), which dwl maps and draws from. The proxy bounds what +a zone hands over (`compositor/wlproxy/src/policy.rs`): + +| Bound | Per connection | Per zone | What it holds | +| --- | --- | --- | --- | +| Pools | 128 | 256 | dwl's mappings, one a pool, far below `vm.max_map_count`, past which dwl's own mappings fail | +| Bytes in pools | 1 GiB | 1 GiB | what dwl maps for a zone at one time, at most 128 MiB a pool (a 6K frame) | +| Windows | 32 | 64 | how many windows a zone puts on the screen | + +Passing one ends the client's connection, as Wayland answers a pool or a +window it will not make only with a protocol error. Firefox draws all its +windows over one connection, so a breach closes every one of them at once. + +**What a browser needs.** Firefox renders each window into pools of its own: +the buffer on screen, the one it draws and up to three spare +(`widget/gtk/WindowSurfaceWaylandMultiBuffer.cpp`), and GTK holds one or two +for the window itself. At 3840×2160 a frame is 33 MB, so a window takes 100 +to 230 MB. Two windows on two 4K heads, developer tools and a +picture-in-picture window come to about 0.7 GB in about 30 pools. The bounds +before these, 128 MiB a connection and 256 MiB a zone, ended Firefox with one +4K window open, and a ninth window under the earlier limit of 8. + +**What the zone pays for, and what dwl does.** A page the zone writes is +charged to the zone's cgroup and held by its `memory_max`. A page dwl reads +before the zone has written it is allocated by dwl's fault and charged to +dwl. The byte bounds limit that while the pools live: a zone can make dwl +hold at most 1 GiB this way at one time. They do not limit it after. A zone +that keeps its own descriptor once a pool is gone keeps the pages dwl +faulted, still charged to dwl, and can do it again with a new pool, a +screen's worth of pages a frame. No bound on live pools closes that, at any +size. + +**Closing it, not built yet.** The proxy would keep a duplicate of each +pool's descriptor and punch the pool's range (`fallocate` with +`FALLOC_FL_PUNCH_HOLE`) once the pool and its buffers are gone and no surface +shows one, which frees what dwl faulted. It would refuse a pool it could not +punch: a read-only descriptor, a write seal, a file that is not shared +memory. What dwl holds for a zone is then bounded by the per-zone bound, and +a `memory.max` on the session's cgroup contains the rest. Checking at a +commit that a buffer's pages are resident is not enough, as the zone can +punch them itself between the check and dwl's read. The measurement under +Tests confirms the charge on the installed kernel. + ## Files `cgroup.rs`, `spawn.rs` (cgroup setup before the intermediate unshares; diff --git a/docs/design/software-delivery.md b/docs/design/software-delivery.md index ee2487de..8e54b3db 100644 --- a/docs/design/software-delivery.md +++ b/docs/design/software-delivery.md @@ -7,8 +7,8 @@ ships whole in every release. Version 2's applications (a graphical browser, a mail client, a document viewer) do not fit that model for long. This document chooses how they arrive: a package manager with a signed binary repository, zones that carry their own userland, or something between. The -[roadmap](../roadmap.md) asks for an ADR first; the proposed text is -ADR-015 in [decisions](../decisions.md). +[roadmap](../roadmap.md) asked for an ADR first: it is ADR-015 in +[decisions](../decisions.md). ## Where things stand @@ -80,8 +80,8 @@ its files are in the root image. - **Policies** are the zone files', as now. - **The slot limit** decides it. The browser and its toolkit stack alone take most of the 800 MiB of headroom a 1.0 install has (the browser and - graphics design, `docs/design/browser-and-graphics.md`, proposed beside - this one, works it out); a mail client, a document viewer and Mesa do not + graphics design, `docs/design/browser-and-graphics.md`, beside this one, + works it out); a mail client, a document viewer and Mesa do not fit after it. - **Other costs:** every build carries every application; every zone sees every application's files, including zones that never run them. @@ -184,40 +184,131 @@ up ADR-001 in the zones where the applications run. or a path the root already has fails the step, so the root's files stay the ones verified at boot. The build also writes, per image, the policy lines its programs need, found with `kryptikd seccomp-trace`. -- **Packed.** Stage 06 makes `image-.img` the way it makes the root - (ext4 without a journal, then the verity tree) and a manifest: + + The stage is `build/stages/04-images.sh` (`make images`). An image is a + file under `build/images/` naming its version and its rows, which run + through the same `step()` and hardening flags as stage 04's + (`build/lib/recipes.sh`). `tools/check-image-tree.sh` checks the upper + directory: no whiteout or opaque directory, no file the root has, no + directory of the root's `/usr` with another mode or owner, nothing outside + `/usr` and no link that leads out of it, no device node, setuid or setgid + bit, or file capability. The + probe image (`build/images/probe.sh`), one small program that locks its + memory, proves the stage and the zones suite's checks; it ships to no one. + An image's sources are pinned in `build/images/versions.env` and locked in + `build/images/sources.lock`, so a change to them leaves stage 04 alone, + and the image installs their licence files itself, as stage 04 does the + root's; stage 06 refuses an image missing one. An image can be built over + others (`image_over`): their trees lie under it, above the root, while its + rows run, the tree check refuses a file any of them has, and the list's + `requires:` line names those that ship. A layer marked `image_ships=no`, a + toolchain only builds use, is never packed or listed. `image_over` is not + followed through: an image built over one that is itself built over others + names those too, and a zone names every image of the chain, as each image's + `requires:` line is checked on its own. An image is built again when what + it is built from changes: its definition, the shared recipe helpers and + flags, its rows' recipes as stage 04 fingerprints a step, the images under + it, the base system up to `wlroots`, the last step an image builds against + (`IMAGES_BASE`), and the names of the headers, libraries and pkg-config + files the sysroot has, which a build's checks can find wherever stage 04 + put them. Zone 0's own programs and applications come after `wlroots` in + stage 04, so changing them rebuilds no image, and a kept image's tree is + checked against the root again. A release builds every image from nothing. +- **Packed.** Stage 06 makes `images-/.img` the way it + makes the root (ext4 without a journal in 4096-byte blocks, the size + dm-verity reads it in, then the verity tree, with a salt and UUID derived + from the content), runs the root's artifact audit + over it with the image's own accepted findings + (`build/images/NAME.accepted`, each with its reason) or none, and the + image's own program list, after leaving out what `build/images/NAME.strip` + names (headers, pkg-config files, code generators: what only builds over the + image use, which keep them in its build tree), and writes a manifest, + `images-/images.manifest`: ```text KRYPTIK-IMAGES-1 - base: 1.2.0 - image: browser 140.4.0 root-hash=<64 hex> salt= bytes= sha256=<64 hex> + release: 1.2.0 + base: + image: browser 140.4.0 bytes= sha256=<64 hex> data-blocks= root-hash=<64 hex> salt=<64 hex> + image: toolkit 3.24.49 bytes= sha256=<64 hex> data-blocks= root-hash=<64 hex> salt=<64 hex> + requires: browser toolkit needs: browser allow-syscall seccomp ``` signed by the release key in the namespace `kryptik-image`, which the - anchor grants the release key alone. -- **Stored** under `/var/lib/kryptik/images//.img`, root - only. Zone 0 keeps the generation the committed root uses and, during a - trial, the one the trial root uses. -- **Fetched** by the same verbs as a release. The statement of what is - current gains one line per image for each base it serves; zone 0 fetches - only images whose hash it does not hold, never past the signed size. + anchor grants the release key alone. The base is the root hash, not the + version, as a version can name two builds; kryptikd reads the running one + from the signed kernel's command line. The verity parameters are the + root's (SHA-256, 4096-byte blocks, no superblock, the hash tree after + `data-blocks`), so every value kryptikd passes to dm-verity is signed. + Image lines are sorted by name; a requires line, one per image in image + order, names the images it is built over, which a zone naming it must + name too; a needs line uses the policy-file syntax. Anything else in the + file refuses it. A development build also signs the two lists the guest + must refuse, under `images-/controls/`: the same list in + `kryptik-release`, and one for another root. +- **On the medium.** The USB image carries the list, its signature and each + image in a third partition, `kryptik-images`; the ISO carries the same + files under `/images` in its filesystem. A release without images has + neither. The controls stay with the build. Whoever can write the stick + can craft that partition, and the installer mounts it, so it is a FAT32 + like the ESP: the kernel parses no filesystem for it that the medium did + not already need. Before the installer writes anything, `kryptikd + check-images` checks the list as a kept one is checked, its signature as + nobody on a copy, for the root the signed command line names, and finds + every image it names there at the size it signs, and hands on the list + and signature it checked, which are what the installer keeps; each + image's hash is checked as it is copied, as an update's are. +- **Stored** under `/var/lib/kryptik/images//.img`, with + the list for each root as `.manifest` and `.manifest.sig`, root + only. Zone 0 keeps the lists of the roots in the two slots and the images + they name, so a rollback finds its images; an image both lists name is + kept once. +- **Shipped** with each release. Its payload carries `images.manifest`, the + signature and each image as `NAME.img.zst`, compressed for the trip as the + root is, all listed in the release manifest, so the channel fetches them + with the rest. `kryptik-update apply` checks, before it writes the slot, + that the list is for the root the payload carries, and decompresses each + image into the store, where the copy is checked against the size and hash + the list signs before it takes its name. Once the trial is armed, the + store drops the release the slot held. The installer takes the medium's + copy (above): kryptikd checks the list and the images' sizes before it + writes anything, and it keeps each image as `apply` does. + `kryptik-recover --restore-slot` does not open the state partition, so a + slot it restores finds its images only if the store still holds that + release's, from the install or a kept update; otherwise a zone that names + one is refused until the next update brings them. +- **Fetched** alone for an image-only fix, by the same verbs as a release. + The statement of what is current gains one line per image for each base + it serves; zone 0 fetches only images whose hash it does not hold, never + past the signed size. A second list for a root whose list the store keeps + replaces it only if the statement names it as newer: a signed list from + before an image's fix, replayed, must not bring the old image back. - **Mounted** by kryptikd on first use: a read-only loop device, dm-verity with the manifest's root hash, and ext4 mounted read-only, `nosuid,nodev` - at `/run/kryptik/images/`. A mapping that dm-verity has marked - corrupted is not used again: the next start of a zone that names the image - is refused, with the reason, until the image is fetched again. For each distinct set of images a zone - names, kryptikd mounts a read-only overlay (no upper directory; the - images' `usr` over the root's `/usr`) at `/run/kryptik/usr/` in the - initial namespace, and the zone's bind of `/usr` takes that instead. Since - zone 0 mounts it and the zone only receives a bind, the hardened kernel's - `OVERLAY_FS_UNPRIVILEGED`, which is off, does not come into it. Loop, - dm-verity and overlayfs are already built in. + under `/run/kryptik/images//`, one directory per root hash; the + mapping and the directory are named by the whole hash. Each start checks + the list's signature again, as nobody, on a copy of the bytes it then + reads. A mapping that dm-verity has marked corrupted is not used again: + the next start of a zone that names the image is refused, with the + reason, until this machine restarts. An update that carries the image + replaces the stored copy, since the store keeps an image only while its + size and SHA-256 match the list's. For each distinct set of images + a zone names, kryptikd mounts a read-only overlay (no upper directory; the + images' `usr` over the root's `/usr`; redirects in a layer not followed) + at `/run/kryptik/usr/` in the initial namespace, and the zone's bind + of `/usr` takes that instead, through a descriptor kryptikd opens before + it takes the zone's ids: `/run/kryptik` is root's alone, and the zone's + mapped root may not walk it. A set already mounted there is used only if + it stacks exactly those mounts, as its name holds 64 bits of each root + hash. Since zone 0 mounts it and the zone only receives a bind, the + hardened kernel's `OVERLAY_FS_UNPRIVILEGED`, which is off, does not come + into it. Loop, dm-verity and overlayfs are already built in. - **Named** in the zone file: ```toml [software] - images = ["browser"] + images = "browser" ``` `untrusted` names the browser image too, since the @@ -255,7 +346,7 @@ image-only update from the channel: zone already running keeps the old image until it stops; - a flipped block in a stored image reads as an I/O error in the zone, the machine keeps running, and the next start of a zone that names the image is - refused with the reason until it is fetched again; + refused with the reason until the machine restarts; - an image built for another base, an image manifest signed in `kryptik-release`, and a root manifest signed in `kryptik-image` are each refused; diff --git a/docs/design/update-channel.md b/docs/design/update-channel.md index ad208bcf..e6a09167 100644 --- a/docs/design/update-channel.md +++ b/docs/design/update-channel.md @@ -89,8 +89,8 @@ latest.sig an OpenSSH signature over those bytes, namespace kryptik-latest reach, and the release key signs only in a release run its maintainer approves, so the build uses two. The anchor stage 06 puts on the image lists the release key as -`kryptik-release namespaces="kryptik-release,kryptik-media"` (manifests, and -the media's checksums) and the statement key as +`kryptik-release namespaces="kryptik-release,kryptik-media,kryptik-image"` +(manifests, the media's checksums and image lists) and the statement key as `kryptik-latest namespaces="kryptik-latest"`, each honoured in its own namespaces only, and no key under both. While a key is replaced it lists the old one and the new one. A development build proves the split with a probe diff --git a/docs/design/zone-policy-files.md b/docs/design/zone-policy-files.md index cd677a19..b524a075 100755 --- a/docs/design/zone-policy-files.md +++ b/docs/design/zone-policy-files.md @@ -77,6 +77,11 @@ a kill, is printed with `soft` after its name and gets the same errno here: and again on a timer, and writes at the normal priority when refused, where a kill would end the browser. A policy may allow it, and the kernel still refuses the real-time class without `CAP_SYS_NICE`. +- `quotactl` fails with EPERM: Firefox's disk-space check reads the user's + quota on the profile's device from its network cache's thread, a moment + after it starts, and keeps the free space `statfs` gave when refused, + where a kill ended the browser before its window showed. It stays denied: + no policy opens it, as setting a quota is the call's other use. A printed name can go on an `allow-syscall` line unless it is on the denied list; a call refused for its arguments (namespace flags to `clone`, diff --git a/docs/hardening.md b/docs/hardening.md index f2cc62d9..e2bfc6d7 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -34,12 +34,17 @@ references `main()`, so every shared library fails to link. A package that cannot build with a flag goes in `build/config/hardening-exceptions.txt` as ` # reason`; -`build/lib/common.sh` fails the build on an entry without a reason. The only -one is glibc's `-D_FORTIFY_SOURCE=3`: glibc implements FORTIFY, and its own +`build/lib/common.sh` fails the build on an entry without a reason. There are +two. glibc's `-D_FORTIFY_SOURCE=3`: glibc implements FORTIFY, and its own `--enable-fortify-source=3` applies it to everything but the loader, startup and checking code (its `elf`, `dlfcn`, `csu` and `debug` directories), whose programs `pldd`, `sprof`, `pcprofiledump` and `sotruss-lib.so` the audit -accepts by name. +accepts by name. And Firefox's `-fno-delete-null-pointer-checks`, in the +browser image: under it GCC 14 cannot evaluate SpiderMonkey's compile-time +checks, which compare an object's address with null. With +`CONFIG_DEFAULT_MMAP_MIN_ADDR=65536` no process can map the first 64 KiB, which +covers a null pointer dereferenced at a small offset, and Mozilla's own builds +delete these checks as well. ### What the audit finds diff --git a/docs/release-keys.md b/docs/release-keys.md index 2528bb29..d1891c97 100644 --- a/docs/release-keys.md +++ b/docs/release-keys.md @@ -18,7 +18,8 @@ run. The build never makes these keys (`build/lib/release-keys.sh`). 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 +manifests, `kryptik-media` for the media's checksums and `kryptik-image` for +the lists of software images, the statement key to `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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 9eaa096a..d08fb590 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -183,16 +183,31 @@ work in zones from a terminal and a text browser, and keep it up to date. - [x] **A destroy verb.** `kryptikd volume destroy NAME` erases a stopped zone's key slots and deletes its container; it refuses an open volume ([design](design/encrypted-volumes.md)). The zone's definition stays. +- [ ] **Memory a zone makes the compositor hold.** A page of a zone's + shared-memory pool that dwl reads before the zone writes it is charged + to dwl, not to the zone's `memory_max`, and a zone that keeps the + pool's descriptor keeps that page after the pool is gone; the proxy's + bounds hold it only while the pools live + ([Wayland shared memory](design/resource-limits-and-ephemeral-zones.md#wayland-shared-memory)). + First the measurement (`wlprobe charge`), then the proxy punching each + pool's range once the pool and its buffers are gone, refusing a pool it + cannot punch, and a `memory.max` on the session's cgroup. ## Version 2 A desktop someone can live in, built the same way. -- **Applications.** A graphical browser and the toolkit stack under it, per - zone, with GPU rendering decided zone by zone; a mail client, a document - viewer, a file manager that understands transfers between zones. -- **Software from somewhere.** A package manager and a signed binary - repository, or zones that carry their own userland. An ADR first. +- **Applications.** Built: the GTK toolkit and Firefox ESR over it, in + `untrusted`, `personal` and `work`, its PDF viewer the document viewer + ([browser and graphics](design/browser-and-graphics.md)). Still to come: + GPU rendering decided zone by zone, a mail client, and a file manager that + understands transfers between zones. +- **Software from somewhere.** Built: signed software images (ADR-015), + built over the root and signed with each release, carried on its install + media and in its update payload, and opened with dm-verity and stacked + read-only over the `/usr` of the zones that name them + ([software delivery](design/software-delivery.md)). Not decided: a package + manager, or images released apart from the root. - **The laptop.** What the laptop design asked for is built: sound mixed in zone 0 for each zone, the microphone granted to one zone at a time; suspend with the state partition's and each zone volume's keys dropped, diff --git a/docs/status.md b/docs/status.md index b6356114..cea96249 100644 --- a/docs/status.md +++ b/docs/status.md @@ -23,10 +23,11 @@ that ship, under QEMU with OVMF firmware. | Installation and the state partition | **tested**: `make install-test` (install, boot alone, reboot, cold boot, the firmware's entry for slot a naming the ESP by its PARTUUID, no release named for the empty slot b, refusals including an injected I/O error and a disk with 4096-byte logical sectors), `make state-test` (first-boot setup answered at the console, a cloned disk, ambiguous labels, a corrupt or missing state partition: the system boots degraded and says so; `kryptik state passphrase` refusing a wrong current passphrase and a new one under 8 characters, then changing it), the medium's console suite `tools/image/medium-shell-test.sh` (with a NIC attached, no net zone starts and the NIC stays down in zone 0; typed at the medium's root shell: a dry run that writes nothing, a plan naming what each of the disk's partitions holds, ERASE and the passphrase asked before the first write and a passphrase under 8 characters refused, the refusal of a partition and of a disk with a partition mounted, used as swap or held open by device-mapper, then the user guide's install, state header backup and recovery as it gives them), `make keyboard-test` (the layout a firmware variable names loads before the passphrase is asked; a name this release lacks is said, and the passphrase asked under `us`) | | Boot integrity | **tested**: `make media-smoke-secureboot`, `make media-refused-foreign-keys` (Microsoft keys refuse the medium), `make integrity-test` (a foreign-signed boot file refused and put back by `kryptik-recover --commit-slot`, a tampered root refused by dm-verity, recovery from the medium with state intact, also when the disk's ESP no longer mounts, and the state partition's header saved, wiped and put back) | | Zones, network and encrypted storage on the installed kernel | **tested**: `make zones-test`, running `zones-check.sh` (the net zone associating over a `mac80211_hwsim` radio; its DHCP client split into an unprivileged user's processes, with no capability in an empty root, and a helper that stays its root; its DNS server as nobody with only `CAP_NET_BIND_SERVICE`, and its clock query and update fetcher with no capability; its CPU limit read back, and each routed zone's share of its flow table measured; a NIC it renamed, readdressed or gave altnames, and a radio it left stations on, reaching the next net zone reset; the installed root's setuid bits and file capabilities against the allowlists, and every sysctl read back) and the compartment suites as root on the Kryptik kernel | -| USB sticks and SD cards | **tested**: `make zones-test` plugs a FAT stick into the running guest: zone 0 mounts nothing, and eudev reads no filesystem on it, nor later through the grant's mapping; the launch daemon, asked as the session's user, lists its partition by the device's name, size, number and GPT name, refuses it to `vault` and the net zone, and gives it to `untrusted` read-only through a device-mapper mapping of that partition alone, which the zone's broker hands over as a descriptor; there `kryptik-stick` lists it and copies a file off whole, a write is refused, and the zone has no device node for it; another zone asking is refused; eject frees the partition at once, and a descriptor the zone kept reads only EIO; given to write, what the zone puts is on the image the host reads after pulling it out; pulled out under a program holding it, the program reads EIO, the grant ends and the kernel logs no oops; a zone's stop ends its grant; the install medium plugged in offers its ESP, which the reader lists, and its root, which the reader refuses. An exFAT card mkfs.exfat made, fsck.exfat finding it sound, is read in `untrusted`, its photo copied off whole, and refused a write though the grant allows one. `tools/tests/chrome-sticks.sh` checks the chrome's window (a stick plugged in opens it, the menu's `d`) against a stand-in launcher; the exFAT reader's unit tests refuse a corpus of malformed volumes, each with its reason, and `tools/tests/exfat-card.sh` reads a card mkfs.exfat made, a name with accents through the card's up-case table | +| Software images | **tested**: `make images` builds each image over the base system through an overlay, and its tree check refuses a file of the root's, anything outside `/usr`, a setuid bit, a file capability and a link out of `/usr` (`tools/tests/check-image-tree.sh`); stage 06 packs each with the root's artifact audit, strict, against the image's own accepted findings, its program list and its licences, and signs the list for the root it was built over. `make zones-test` keeps that list and its images, and checks a zone granted the probe's call runs it from the image over its `/usr`, one not granted it is refused before it starts, one naming no image does not see it, `explain` names it, a list signed under the release namespace or for another root is refused, a flipped block of a kept image reads as an I/O error in the zone and the next start is refused, and a zone naming the toolkit image loads GTK and finds the root's fonts, and one naming the browser image and the toolkit draws a page with Firefox, headless, its content sandbox granted the seccomp call it asks for | | The zoned desktop | **tested**: `make gui-test`, covering what a zone's client is offered through its proxy, zone borders and title prefixes windowed and fullscreen, a zone's window getting the keyboard only when the user picks it, a zone's popup drawn only inside its window and closed when the keyboard moves, the screen lock (Alt+Shift+l) taking every key from every window, a zone's grab notwithstanding, showing nothing else, staying locked when its program is killed and opening for the login password alone, and locking by itself after a quiet stretch, copy and paste between a zone's own windows and never beyond them, per-zone clipboards, the clipboard-move gesture and file transfers answered on the trusted chrome, and a second monitor plugged in and pulled out under the running session, coming at the scale the user chose for it, and named to zones by number with no make or model (`tools/tests/display.sh` checks what `kryptik display` keeps); Ctrl+Alt+F2 shows the administration login and Ctrl+Alt+F1 brings back a desktop that answers clients and keys | | Suspend to RAM with the disk keys dropped | **tested**: `make suspend-test`: with the machine up, the keys dropped (`kryptikd suspend --awake`) and the dentry and inode caches let go, a lock dwl starts asks for the state passphrase, refuses a wrong one and resumes with the right one, a client's request is refused at once, and nothing but a writer on `/var` waits on the partition; the zone on a volume stays frozen until its own passphrase, which the daemon's `info` and the menu name, a launch into it is told why, a wrong one is refused, and the chrome opens a window that asks for it, where the right one, typed, thaws it; then `kryptik suspend` sleeps for real, and the sleeping guest's memory holds neither the state partition's key, nor the zone volume's, nor a page that volume had cached, nor the passphrase typed at the lock, while it holds bytes kept in a file in memory; after the wake the zone waits again, Enter alone at the chrome's window leaves it waiting, and it stops without its passphrase, its volume closing, and starts again with it, the volume checking clean; it sleeps past the watchdog's timeout and waits three minutes at the state prompt after waking without a reset; asked again with a read left waiting on the suspended device, the kernel refuses to sleep, the refusal is told, and the passphrase still resumes; the power button has the machine sleep, unlocked and locked, `kryptik-lock --suspend` with a lock up hands the sleep to that lock, and an idle watcher with `--sleep` has its lock ask; the suite's VM shows through std VGA's framebuffer, as virtio-gpu's driver has no suspend callbacks, and on virtio-gpu `make gui-test` checks that `kryptik suspend` locks, is told the machine stays awake, and unlocks the same boot. The lid is untested until physical hardware (`docs/design/laptop.md`) | | Sound | **tested**: `make sound-test`: two of QEMU's HD Audio cards register at boot with their codecs bound, every codec driver `build/config/modprobe.d/sound.conf` names having loaded ahead of `snd-hda-intel` (stage 05 holds that list to the codec drivers the kernel builds); a zone's `/proc/asound` is empty; zone 0's mixer runs as uid 87 in group `audio` alone, with a socket for every zone but `vault` and the net zone; a tone a zone plays through its default device reaches the card, and two zones' tones are mixed; a zone records only once granted the microphone, which the status shows while it records and which goes when the zone stops; another uid is refused at a zone's socket, and no zone has `/dev/snd` or the control socket; the chrome's menu, typed into as the session's user, grants the microphone, names the zone that has it and says when it records, takes it back, which closes the microphone's window, and sets the volume of all and of one zone and mutes; a grant to another zone moves the microphone and ends the first zone's record; a USB sound card plugged in takes the sound, and pulled out while a zone plays, the rest of the tone reaches the built-in card, which the host hears, the zone playing on and the mixer running on. The microphone key waits for the tests on physical hardware, as QEMU has none. `make gui-test` presses the volume-down key, then under the screen lock the volume-up and mute keys, which act there as no other binding does ([the laptop](design/laptop.md#sound)); the mixer records who may record for the compositor, in a file of its own that no one else may write, and clears it with the grant | +| USB sticks and SD cards | **tested**: `make zones-test` plugs a FAT stick into the running guest: zone 0 mounts nothing, and eudev reads no filesystem on it, nor later through the grant's mapping; the launch daemon, asked as the session's user, lists its partition by the device's name, size, number and GPT name, refuses it to `vault` and the net zone, and gives it to `untrusted` read-only through a device-mapper mapping of that partition alone, which the zone's broker hands over as a descriptor; there `kryptik-stick` lists it and copies a file off whole, a write is refused, and the zone has no device node for it; another zone asking is refused; eject frees the partition at once, and a descriptor the zone kept reads only EIO; given to write, what the zone puts is on the image the host reads after pulling it out; pulled out under a program holding it, the program reads EIO, the grant ends and the kernel logs no oops; a zone's stop ends its grant; the install medium plugged in offers its ESP, which the reader lists, and its root, which the reader refuses. An exFAT card mkfs.exfat made, fsck.exfat finding it sound, is read in `untrusted`, its photo copied off whole, and refused a write though the grant allows one. `tools/tests/chrome-sticks.sh` checks the chrome's window (a stick plugged in opens it, the menu's `d`) against a stand-in launcher; the exFAT reader's unit tests refuse a corpus of malformed volumes, each with its reason, and `tools/tests/exfat-card.sh` reads a card mkfs.exfat made, a name with accents through the card's up-case table | | The camera | **tested**: `make zones-test`, with the kernel's `vivid` standing in for a camera, which QEMU lacks: loading by a camera's alias, as eudev does when one is plugged in, loads nothing; `untrusted` finds no camera before a grant; `vault` and the net zone are refused; granted through the launch daemon as the session's user, `uvcvideo` loads and the node, `untrusted`'s root's alone, appears in its directory, and the zone captures a frame; the zone's stop ends the grant; another zone finds no camera; `k` alone takes it back, a program holding it reads an error, the node goes, the stand-in is let go of and `uvcvideo` unloads; granted again to a zone already running, it captures. `make gui-test`: the screen locking takes it back. `tools/tests/chrome-camera.sh` checks the menu's `k`, `k` and Camera line and the camera's window. `uvcvideo` itself waits for the tests on physical hardware. `make gui-test`: over a window in full screen, which covers the chrome's windows, dwl's bar names the zone that may record and the one that has the camera, and names neither once both are taken back and a record of the microphone's holder that the mixer did not write is put in place | | The power source | **tested**: `make power-test`, with the kernel's `test_power` as the battery: the power service follows the mains adapter both ways, the chrome's menu names the source and the charge from the service's own state, a window warns at 10% and again at 5% and closes on mains, and at 2% unplugged it stops the zones, the running zone's volume closed first (read from the state partition afterwards), and powers off; the kernel carries AMD's and Intel's power controller drivers, which take an s2idle laptop's SoC to its deepest state and bind nothing in QEMU; the energy preference it sets by the source, against stand-in `/sys` trees (`tools/tests/power.sh`), as QEMU's processors offer none | | Brightness | **implemented**: dwl's brightness keys step the backlight through `brightness.sh`, which udev lets the session's group write; the steps, the ends of the range and the choice among several backlights are tested against stand-in `/sys` trees (`tools/tests/brightness.sh`). QEMU has no backlight, so the rule and the keys are untested until physical hardware | diff --git a/docs/supply-chain.md b/docs/supply-chain.md index 697d67f7..4eb7b6a3 100644 --- a/docs/supply-chain.md +++ b/docs/supply-chain.md @@ -31,7 +31,12 @@ Two build tools that do not ship are special cases: ## Source integrity `sources.lock` pins a SHA-256 for every upstream tarball, and -`tools/fetch-sources.sh` refuses to proceed on a mismatch. +`tools/fetch-sources.sh` refuses to proceed on a mismatch. The software +images' sources are pinned apart, in `build/images/versions.env` and +`build/images/sources.lock`: they never reach the root, so a change to them +is no input of stage 04 and leaves its cache standing. Every check here +reads both, and `fetch-sources.sh --list-root` and `--list-image NAME` give +each set alone. Because the lock decides, a file can come from anywhere that serves the same bytes. When a source's host does not answer, the fetcher asks the diff --git a/docs/user-guide.md b/docs/user-guide.md index 2f2d5ecc..a13fa8e6 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -209,7 +209,7 @@ It writes four GPT partitions: `kryptik-esp` (the medium's ESP, with the slot A kernel as the boot file), `kryptik-a` (the verified root image, read back and hashed against the medium's record), `kryptik-b` (empty until the first update), and `kryptik-state` (LUKS2 with ext4 inside: users, zone -volumes, updates). Its plan names the disk's model and what each of its +volumes, updates, and the medium's software images). Its plan names the disk's model and what each of its partitions holds (`holds gpt: sda1 ntfs "Windows" 237G; ...`): read it, as nothing else tells a backup disk from the one you mean. Before its first write it asks you to type `ERASE` (`--yes` skips that), then twice for the @@ -291,7 +291,7 @@ pointer moving onto it moves the keyboard there. Keys (Alt is the modifier): | Alt+p | the chrome menu: zones, their applications, stop a zone, move a clipboard between zones, the Wi-Fi networks with w, and with f the zone of the window you last had | | Alt+Shift+Return | a terminal (havoc) in the work zone | | Alt+Shift+p | a terminal in the personal zone | -| Alt+Shift+u | the browser (lynx) in the untrusted zone | +| Alt+Shift+u | the browser (Firefox) in the untrusted zone | | Alt+Shift+l | lock the screen until the login password is typed | | Super+Space | the next of your desktop's layouts, when you have more than one | | volume keys, mute, microphone mute | the volume up or down a step, mute or unmute everything, and take the microphone back from whichever zone has it | @@ -341,7 +341,11 @@ write then. Testing on physical hardware is planned for October 2026. Zone windows come from the menu: Alt+p, then a zone's number and `t` (a terminal), `e` (the editor) or `b` (the browser), for example `1t`, then -Enter. A zone with encrypted storage asks for its passphrase when it starts, +Enter. The browser is Firefox in `untrusted`, `personal` and `work`, which +carry its software image, and lynx in a terminal elsewhere; Firefox opens +PDFs too. A zone runs one Firefox: asked for again while it is open there, +it says that Firefox is already running, and a new window comes from the +open one (Ctrl+N). A zone with encrypted storage asks for its passphrase when it starts, in the menu's window or, for a key, in a small prompt window; the passphrase never appears on a command line. On a machine with a battery, a Power line under the zones gives its charge and whether the machine runs on it; @@ -509,9 +513,11 @@ the clock". A release brought by hand is a signed payload directory holding exactly `manifest`, `manifest.sig`, `kryptik-root.img.zst` (the root image, -compressed), `kryptik-a.efi`, `kryptik-b.efi` and `root.json` (what `make media` writes as +compressed), `kryptik-a.efi`, `kryptik-b.efi` and `root.json`, and the +release's software images if it has any (`images.manifest`, its signature +and `NAME.img.zst` files): what `make media` writes as `images/payload-VERSION`, and what a release on the repository's page -carries). Bring it onto the state partition, for example under +carries. Bring it onto the state partition, for example under `/var/lib/kryptik/updates/`, and as root (the administration login, then `su`): diff --git a/tools/acceptance.sh b/tools/acceptance.sh index 264ff7b3..7efb8f62 100755 --- a/tools/acceptance.sh +++ b/tools/acceptance.sh @@ -338,9 +338,9 @@ it_compositor_sources() { return "$ok" } it_sources_lock() { - echo "-- every build input matches sources.lock (offline)" - ( cd "$KRYPTIK_SOURCES" && sha256sum --check --quiet --strict "${ROOT}/sources.lock" ) || return 1 - echo " ok: $(grep -c . "${ROOT}/sources.lock") entries verified" + echo "-- every build input matches sources.lock and the software images' lock (offline)" + ( cd "$KRYPTIK_SOURCES" && sha256sum --check --quiet --strict "${ROOT}/sources.lock" "${ROOT}/build/images/sources.lock" ) || return 1 + echo " ok: $(cat "${ROOT}/sources.lock" "${ROOT}/build/images/sources.lock" | grep -c .) entries verified" } # Check each medium's sidecar against the hash the run took at the start. it_media_hashes() { diff --git a/tools/artifact-manifest.sh b/tools/artifact-manifest.sh index 1be99da9..94090bc8 100755 --- a/tools/artifact-manifest.sh +++ b/tools/artifact-manifest.sh @@ -47,7 +47,9 @@ emit_inputs() { "$KRYPTIK_ROOT"/build/config/hardening.env \ "$KRYPTIK_ROOT"/build/config/hardening-exceptions.txt \ "$KRYPTIK_ROOT"/build/config/kernel/*.fragment \ - "$KRYPTIK_ROOT"/sources.lock; do + "$KRYPTIK_ROOT"/sources.lock \ + "$KRYPTIK_ROOT"/build/images/versions.env \ + "$KRYPTIK_ROOT"/build/images/sources.lock; do [[ -f "$f" ]] || continue rel="${f#"$KRYPTIK_ROOT"/}" printf 'input\trecipe\t%s\t%s\n' "$rel" "$(sha256_of "$f")" diff --git a/tools/channel-host.sh b/tools/channel-host.sh index 703c31d2..8bb82133 100755 --- a/tools/channel-host.sh +++ b/tools/channel-host.sh @@ -121,7 +121,7 @@ case "$MODE" in ssh-keygen -q -t ed25519 -N '' -C kryptik-release -f "${w}/release" < /dev/null ssh-keygen -q -t ed25519 -N '' -C kryptik-latest -f "${w}/latest" < /dev/null { - printf 'kryptik-release namespaces="kryptik-release,kryptik-media" %s\n' "$(cut -d' ' -f1,2 "${w}/release.pub")" + printf 'kryptik-release namespaces="kryptik-release,kryptik-media,kryptik-image" %s\n' "$(cut -d' ' -f1,2 "${w}/release.pub")" printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 "${w}/latest.pub")" } > "${w}/release-signers" KEY="${w}/latest" diff --git a/tools/check-image-licences.sh b/tools/check-image-licences.sh index 21f01885..9dfd488e 100755 --- a/tools/check-image-licences.sh +++ b/tools/check-image-licences.sh @@ -1,14 +1,21 @@ #!/usr/bin/env bash # Checks ROOT/usr/share/licenses/ holds Kryptik's licence and each source's, or its exception line. # -# ./tools/check-image-licences.sh ROOT +# ./tools/check-image-licences.sh ROOT the root image: the root's sources +# ./tools/check-image-licences.sh --image NAME TREE software image NAME: the sources it is built from source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" -ROOT="${1:?usage: check-image-licences.sh ROOT}" +LIST=(--list-root); OF="the root's" +if [[ "${1:-}" == --image ]]; then + LIST=(--list-image "${2:?usage: check-image-licences.sh --image NAME TREE}"); OF="image ${2}'s" + shift 2 +fi +ROOT="${1:?usage: check-image-licences.sh ROOT, or --image NAME TREE}" LIC="${ROOT}/usr/share/licenses" EXC="${KRYPTIK_ROOT}/build/config/licence-exceptions.txt" -[[ -d "$LIC" ]] || die "no ${LIC}" +# An image built from no listed source (the probe) has nothing here to hold. +[[ -d "$LIC" || "${LIST[0]}" == --list-image ]] || die "no ${LIC}" declare -A EXCEPT=() if [[ -f "$EXC" ]]; then @@ -25,12 +32,17 @@ while read -r name _ _; do n=$((n + 1)) [[ -n "${EXCEPT[$name]:-}" ]] && continue has_files "${LIC}/${name}" || missing+=("$name") -done < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list) -[[ "$n" -gt 0 ]] || die "fetch-sources.sh --list named no sources" -[[ -f "${LIC}/kryptik/LICENSE" ]] || missing+=(kryptik) +done < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" "${LIST[@]}") +if [[ "$n" -eq 0 && "${LIST[0]}" == --list-image ]]; then + ok "image ${LIST[1]} is built from no listed source" + exit 0 +fi +[[ "$n" -gt 0 ]] || die "fetch-sources.sh ${LIST[*]} named no sources" +# Kryptik's own licence is the root's; an image holds only its sources'. +[[ "${LIST[0]}" != --list-root || -f "${LIC}/kryptik/LICENSE" ]] || missing+=(kryptik) if [[ "${#missing[@]}" -gt 0 ]]; then printf ' no licence files: %s\n' "${missing[@]}" die "${#missing[@]} without licence files: install them, or give each a line and its reason in ${EXC}" fi -ok "all ${n} sources have their licence files, and Kryptik has its own" +ok "all ${n} of ${OF} sources have their licence files" diff --git a/tools/check-image-tree.sh b/tools/check-image-tree.sh new file mode 100755 index 00000000..b5855cf6 --- /dev/null +++ b/tools/check-image-tree.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Fail on what an image's recipes did to the root instead of adding to /usr: the upper directory of the +# overlay they ran on, against the sysroot under it (docs/design/software-delivery.md). +# +# tools/check-image-tree.sh LOWER[:LOWER...] UPPER +# +# LOWER is the sysroot, after the trees of the images this one is built over when it has any, nearest +# first, as overlayfs takes them: a file any of them has is one the image may not replace. +# Refused: a whiteout, a redirected directory, or an opaque one over a directory a layer has (a path +# removed or hidden), a file the root already has, a directory of the root's under /usr with another +# mode or owner, a file outside /usr, a link that leads out of /usr, a device node, a setuid or setgid +# bit, a file capability. The chroot's own scratch, mount points and marks are let be. +source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" +# The verdict is python's exit status; the ERR trap would abort before it is read. +trap - ERR +set +e +[[ "$#" -eq 2 ]] || die "usage: check-image-tree.sh LOWER[:LOWER...] UPPER" +IFS=: read -ra lowers <<< "$1" +for d in "${lowers[@]}" "$2"; do [[ -d "$d" ]] || die "not a directory: $d"; done +log "What the image's recipes left in $2, against $1" +python3 - "$1" "$2" <<'EOF' +import os, stat, sys +lowers, upper = sys.argv[1].split(":"), sys.argv[2] +# Written by the chroot itself (stage 03), or left by builds; none of it is the image's. +SCRATCH = ("tmp/", "var/tmp/", "root/", "run/", "dev/", "proc/", "sys/") +MOUNTS = {"kryptik", "kryptik-sources", "kryptik-work", "kryptik-kryptikd", "kryptik-wlproxy"} +# Stage 03 writes these on every run: its marker, and the two links it sets again. +MARKS = {"etc/kryptik/inside-chroot", "var/run", "var/lock"} + +def xattr(path, name): + try: + return os.getxattr(path, name, follow_symlinks=False) + except OSError: + return None + +# A link resolves in the zone's view, where outside /usr is the zone's own files, not the image's. +def in_usr(rel, target): + path = os.path.normpath(os.path.join("/" + os.path.dirname(rel), target)) + return path == "/usr" or path.startswith("/usr/") + +bad, files, dirs = [], 0, 0 +for d, subdirs, names in os.walk(upper): + for entry in sorted(subdirs) + sorted(names): + p = os.path.join(d, entry) + rel = os.path.relpath(p, upper) + top = rel.split("/", 1)[0] + if (rel + "/").startswith(SCRATCH) or top in MOUNTS or rel in MARKS: + continue + st = os.lstat(p) + # The nearest layer that has the path: the last is the root, the others images it is built over. + lst, under = None, False + for i, lower in enumerate(lowers): + try: + lst = os.lstat(os.path.join(lower, rel)) + except FileNotFoundError: + continue + under = i < len(lowers) - 1 + break + what = f"/{rel} of an image under it" if under else f"the root's /{rel}" + dwhat = f"the directory /{rel} of an image under it" if under else f"the root's directory /{rel}" + if stat.S_ISCHR(st.st_mode) and st.st_rdev == 0 or xattr(p, "trusted.overlay.whiteout") is not None: + bad.append(f"removes {what}") + continue + if xattr(p, "trusted.overlay.redirect") is not None: + bad.append(f"moves /{rel} within the root") + continue + if stat.S_ISDIR(st.st_mode): + # overlayfs marks each directory it makes inside a merged one opaque, to spare later + # lookups: over a path no layer has that hides nothing, and the tree's copy drops it. + if lst is not None and not stat.S_ISDIR(lst.st_mode): + bad.append(f"replaces {what} with a directory") + elif lst is not None and xattr(p, "trusted.overlay.opaque") == b"y": + bad.append(f"hides what {'an image under it' if under else 'the root'} has under /{rel}") + elif lst is not None and top == "usr" and (stat.S_IMODE(st.st_mode), st.st_uid, st.st_gid) != (stat.S_IMODE(lst.st_mode), lst.st_uid, lst.st_gid): + bad.append(f"changes {dwhat} " + f"({oct(stat.S_IMODE(lst.st_mode))} {lst.st_uid}:{lst.st_gid} -> {oct(stat.S_IMODE(st.st_mode))} {st.st_uid}:{st.st_gid})") + elif lst is None and top != "usr": + bad.append(f"makes /{rel} outside /usr") + elif top == "usr": + dirs += 1 + continue + if lst is not None: + bad.append(f"replaces {what}") + elif top != "usr": + bad.append(f"installs /{rel} outside /usr") + elif not (stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode)): + bad.append(f"makes /{rel}, neither a file nor a link") + elif stat.S_ISREG(st.st_mode) and st.st_mode & (stat.S_ISUID | stat.S_ISGID): + bad.append(f"/{rel} is setuid or setgid") + elif xattr(p, "security.capability") is not None: + bad.append(f"/{rel} carries a file capability") + elif stat.S_ISLNK(st.st_mode) and not in_usr(rel, os.readlink(p)): + bad.append(f"/{rel} links to {os.readlink(p)}, outside /usr") + else: + files += 1 +for b in bad: + print(f" REFUSED {b}", file=sys.stderr) +if bad: + sys.exit(f"{len(bad)} change(s) to the root: an image may only add files under /usr") +if not os.path.isdir(os.path.join(upper, "usr")): + sys.exit("the recipes installed nothing under /usr") +print(f" {files} files and links, {dirs} directories under /usr; nothing of the root's replaced, removed or changed") +EOF +rc=$? +[[ "$rc" -eq 0 ]] || exit "$rc" +ok "the image only adds to /usr" diff --git a/tools/currency-rules.tsv b/tools/currency-rules.tsv index 5b8b72d6..72ca3d19 100644 --- a/tools/currency-rules.tsv +++ b/tools/currency-rules.tsv @@ -43,3 +43,28 @@ libdisplay-info api https://gitlab.freedesktop.org/api/v4/projects/emersion%2Fli # Each series changes the API dwl uses. wlroots api https://gitlab.freedesktop.org/api/v4/projects/wlroots%2Fwlroots/repository/tags?per_page=100 name series dwl api https://codeberg.org/api/v1/repos/dwl/dwl/tags?limit=50 name - +# The software images' sources (build/images/versions.env). +pcre2 listing https://api.github.com/repos/PCRE2Project/pcre2/tags?per_page=50 "name": "pcre2-([0-9]+\.[0-9]+)" - +# GNOME numbers a development series with an odd minor; each project's cache.json names every release. +glib listing https://download.gnome.org/sources/glib/cache.json glib-([0-9]+\.[0-9]+\.[0-9]+)\.tar\.xz even +# Pango 1.90 and up are the next major's previews. +pango listing https://download.gnome.org/sources/pango/cache.json pango-(1\.[0-8][0-9]?\.[0-9]+)\.tar\.xz even +gdk-pixbuf listing https://download.gnome.org/sources/gdk-pixbuf/cache.json gdk-pixbuf-([0-9]+\.[0-9]+\.[0-9]+)\.tar\.xz even +at-spi2-core listing https://download.gnome.org/sources/at-spi2-core/cache.json at-spi2-core-([0-9]+\.[0-9]+\.[0-9]+)\.tar\.xz even +# GTK 3 is in maintenance as 3.24.x; GTK 4 is another toolkit. +gtk3 listing https://download.gnome.org/sources/gtk/cache.json gtk-(3\.24\.[0-9]+)\.tar\.xz - +# 1.6 is the stable series; the 1.7 tags are betas. +libpng listing https://api.github.com/repos/pnggroup/libpng/tags?per_page=100 "name": "v(1\.6\.[0-9]+)" - +fontconfig api https://gitlab.freedesktop.org/api/v4/projects/890/releases?per_page=20 tag_name drop90 +libglvnd api https://gitlab.freedesktop.org/api/v4/projects/glvnd%2Flibglvnd/releases?per_page=20 tag_name - +# GitHub's API answers with spaced JSON, which only a listing pattern reads. +rustc listing https://api.github.com/repos/rust-lang/rust/releases?per_page=20 "tag_name": "([0-9]+\.[0-9]+\.[0-9]+)" - +cbindgen listing https://api.github.com/repos/mozilla/cbindgen/releases?per_page=20 "tag_name": "v?([0-9]+\.[0-9]+\.[0-9]+)" - +# The directory links alone: the page's DOCTYPE names HTML 4.01, and "4.01//" is no release. +nasm listing https://www.nasm.us/pub/nasm/releasebuilds/ href="([0-9]+\.[0-9]+(\.[0-9]+)?)/" - +# Every release in one index; a new major is a new LTS line, not a currency fix. +node listing https://nodejs.org/dist/index.json "version":"v([0-9]+\.[0-9]+\.[0-9]+)" major +# The current ESR and, while two overlap, the next one; the pin's own ESR series is what counts. +firefox listing https://product-details.mozilla.org/1.0/firefox_versions.json "FIREFOX_ESR[_NEXT]*": "([0-9]+\.[0-9]+\.[0-9]+)esr" major +# An odd minor is a development snapshot. +cairo listing https://www.cairographics.org/releases/ cairo-([0-9]+\.[0-9]+\.[0-9]+)\.tar\.xz even diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index 493d7a5f..00d53a1c 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -61,6 +61,8 @@ SYS_BLOCK=/sys/class/block # kryptikd's record of the camera's grant (camera.rs), and its note that the driver stayed loaded. CAMERA=/run/kryptik-camera/grant CAMERA_STAYED=/run/kryptik-camera/stayed +# kryptikd's count of sleeps that failed after a zone volume's key went (suspend.rs). +LEFT=/run/kryptik-launch/left-waiting die() { echo "kryptik-chrome: $*" >&2; exit 1; } @@ -69,6 +71,8 @@ zone_field() { # zone key sed -n "s/^$2[[:space:]]*=[[:space:]]*\"\\(.*\\)\"[[:space:]]*$/\\1/p" "$ZONES/$1.toml" 2>/dev/null | head -1 } zone_is_nic() { grep -q '^mode[[:space:]]*=[[:space:]]*"nic"' "$ZONES/$1.toml" 2>/dev/null; } +# Whether the zone's file names the browser image among its images. +zone_has_browser() { grep -Eq '^images[[:space:]]*=[[:space:]]*"([^"]* )?browser( [^"]*)?"' "$ZONES/$1.toml" 2>/dev/null; } # --- the status stream --------------------------------------------------------- @@ -182,7 +186,8 @@ run_status() { # as the battery reaches 10% and again 5%, a window warns of the power-off at 3%; and once # the state partition is back after a sleep, a window asks for each waiting zone's passphrase. ( - shown="" filming="" warned="" waited="" blocks="" seen="" + shown="" filming="" warned="" suspended="" blocks="" seen="" + left="$(cat "$LEFT" 2>/dev/null)" while :; do mic="$("$SOUND" --control status 2>/dev/null | sed -n 's/^zone \([^ ]*\) .* granted.*/\1/p' | head -1)" if [ -n "$mic" ] && [ "$mic" != "$shown" ]; then @@ -199,12 +204,16 @@ run_status() { "$TERMINAL" /usr/bin/kryptik-chrome --battery & fi warned="$w" - if ! dm_suspended kryptik-state; then - wz="$(waiting_zones | tr '\n' ' ')" - if [ -n "$wz" ] && [ "$wz" != "$waited" ]; then - "$TERMINAL" /usr/bin/kryptik-chrome --waiting & - fi - waited="$wz" + # Opened when the state partition comes back from a suspension seen here: kryptikd + # suspends each zone's volume first, and a look in between would find a zone waiting + # with the state partition up and start a terminal that then stops on it. A sleep that + # failed after a volume's key went, the state partition never suspended, counts itself. + l="$(cat "$LEFT" 2>/dev/null)" + if dm_suspended kryptik-state; then + suspended=1 + elif [ -n "$suspended" ] || [ "$l" != "$left" ]; then + suspended="" left="$l" + [ -z "$(waiting_zones)" ] || "$TERMINAL" /usr/bin/kryptik-chrome --waiting & fi # A stick plugged in opens the window that asks which zone gets it. set -- "$SYS_BLOCK"/* @@ -447,7 +456,7 @@ run_menu() { menu_sound menu_camera echo - echo "Apps: t terminal e editor (nano) b browser (lynx, needs routed network)" + echo "Apps: t terminal e editor (nano) b browser (Firefox where the zone has it, else lynx)" echo "Admin: u updates: what the channel names, fetch, forget, install" echo "Also: s stop zone N m move zone N's clipboard to zone M (one payload, once)" echo " w Wi-Fi networks f the last zone window's zone r refresh q close this window" @@ -513,7 +522,8 @@ run_menu() { case "$app" in t) set -- "$TERMINAL" ;; e) set -- "$TERMINAL" /usr/bin/nano ;; - b) set -- "$TERMINAL" /usr/bin/lynx ;; + # Firefox where the zone's file names the browser image, lynx elsewhere. + b) if zone_has_browser "$z"; then set -- firefox; else set -- "$TERMINAL" /usr/bin/lynx; fi ;; esac echo "launching in zone $z: $*" # --ask: a passphrase is read on this zone 0 terminal, never a zone's. diff --git a/tools/fetch-sources.sh b/tools/fetch-sources.sh index 273611a9..24da53ce 100755 --- a/tools/fetch-sources.sh +++ b/tools/fetch-sources.sh @@ -1,24 +1,39 @@ #!/usr/bin/env bash # Fetch and verify upstream source tarballs. # -# ./tools/fetch-sources.sh download and verify against sources.lock -# ./tools/fetch-sources.sh --lock download and WRITE sources.lock -# ./tools/fetch-sources.sh --list print the resolved manifest, download nothing +# ./tools/fetch-sources.sh download and verify against sources.lock +# ./tools/fetch-sources.sh --root only the root's, for a job that builds no image +# ./tools/fetch-sources.sh --lock download and WRITE sources.lock +# ./tools/fetch-sources.sh --list print the resolved manifest, download nothing +# ./tools/fetch-sources.sh --list-root only the sources the root is built from +# ./tools/fetch-sources.sh --list-image NAME only those software image NAME is built from +# ./tools/fetch-sources.sh --crates CRATE name, version and hash of the crates CRATE's lock names # +# The software images' sources are pinned in build/images/versions.env and locked in +# build/images/sources.lock, so a change to them is no input of stage 04. They are kept in +# sources/images/, which has a cache of its own, each named in sources/ by a link where every +# reader looks. A Rust crate's own Cargo.lock names the crates it is built from, each with its +# hash: they go to sources/images/crates/, for an offline build in the chroot. # --lock trusts whatever downloads: audit sources.lock before committing it (docs/supply-chain.md). source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" load_config -MODE="verify" +MODE="verify"; IMAGE=""; ROOT_ONLY=0 case "${1:-}" in + --root) ROOT_ONLY=1 ;; --lock) MODE="lock" ;; --list) MODE="list" ;; + --list-root) MODE="list-root" ;; + --list-image) MODE="list-image"; IMAGE="${2:?--list-image needs an image name}" ;; + --crates) MODE="crates"; CRATE="${2:?--crates needs a .crate file}" ;; "") ;; - *) die "unknown argument: $1 (expected --lock, --list, or nothing)" ;; + *) die "unknown argument: $1 (expected --root, --lock, --list, --list-root, --list-image NAME, --crates CRATE, or nothing)" ;; esac -# Test hook: tools/tests/fetch-sources.sh substitutes a manifest of file:// URLs. +# Test hook: tools/tests/fetch-sources.sh substitutes a manifest of file:// URLs, and crates.io. +[[ -z "${KRYPTIK_CRATES:-}" || -n "${KRYPTIK_FETCH_MANIFEST:-}" ]] \ + || die "KRYPTIK_CRATES replaces crates.io for tools/tests/fetch-sources.sh alone" if [[ -n "${KRYPTIK_FETCH_MANIFEST:-}" ]]; then [[ "${KRYPTIK_FETCH_SELFTEST:-0}" == "1" ]] \ || die "Refusing to fetch or lock against a substituted manifest without KRYPTIK_FETCH_SELFTEST=1" @@ -35,6 +50,8 @@ fi # probe whichever of .sig, .asc and .sign is published, now or in a later release # sha256 the publisher's .sha256 beside it (tools/verify-provenance.sh) # sha256.txt the same, named .sha256.txt +# sha256sum the same, named .sha256sum +# stem.sha256sum a .sha256sum named without the .tar.* suffix (glib-2.90.1.sha256sum) # tag a signed tag the archive must reproduce (tools/verify-provenance.sh) # none nothing, as beside a generated GitHub, GitLab or sr.ht archive # new: where its newest release is found (tools/check-source-currency.sh) @@ -166,12 +183,59 @@ linux-hardened|${V_LINUX_HARDENED}|${MIRROR_HARDENED}/v${V_LINUX_HARDENED}/linux MANIFEST } -if [[ "$MODE" == "list" ]]; then - manifest | while IFS='|' read -r name ver url sig new; do - printf '%-12s %-10s %s %s %s\n' "$name" "$ver" "$url" "$sig" "$new" - done - exit 0 -fi +# The software images' sources (build/images/versions.env), never installed in the root: the image +# each is built into, then the row as above. Their checksums are in build/images/sources.lock. +image_manifest() { + if [[ -n "${KRYPTIK_FETCH_MANIFEST:-}" ]]; then + # A test's images, when it gives any. + [[ -z "${KRYPTIK_FETCH_IMAGE_MANIFEST:-}" ]] || cat "$KRYPTIK_FETCH_IMAGE_MANIFEST" + return 0 + fi + local gnome="$MIRROR_GNOME" + cat < "${KRYPTIK_LOCK}.new" + # A tree without software images has no directory for their lock. + if [[ -d "${KRYPTIK_IMAGES_LOCK%/*}" ]]; then : > "${KRYPTIK_IMAGES_LOCK}.new"; fi fi total=0; fetched=0; cached=0 -while IFS='|' read -r name ver url _; do +while IFS='|' read -r owner name ver url _; do [[ -z "$name" ]] && continue - total=$((total + 1)) file="$(basename "$url")" dest="${KRYPTIK_SOURCES}/${file}" + lock="$KRYPTIK_LOCK"; [[ "$owner" == . ]] || lock="$KRYPTIK_IMAGES_LOCK" + if [[ "$owner" != . ]]; then + mkdir -p "${KRYPTIK_SOURCES}/images" + # One fetched before images/ was made moves in, under --root too, so the root's set is + # saved without it. + if [[ -f "$dest" && ! -L "$dest" ]]; then mv -- "$dest" "${KRYPTIK_SOURCES}/images/${file}"; fi + ln -sfn "images/${file}" "$dest" + dest="${KRYPTIK_SOURCES}/images/${file}" + [[ "$ROOT_ONLY" -eq 0 ]] || continue + fi + total=$((total + 1)) was_fetched=no if [[ -f "$dest" ]]; then @@ -288,11 +408,11 @@ while IFS='|' read -r name ver url _; do actual="$(sha256_of "$dest")" if [[ "$MODE" == "lock" ]]; then - printf '%s %s\n' "$actual" "$file" >> "${KRYPTIK_LOCK}.new" + printf '%s %s\n' "$actual" "$file" >> "${lock}.new" ok "${name} ${ver} ${actual:0:16}…" else - if ! expected="$(lookup_hash "$file")"; then - die "no entry for ${file} in sources.lock; run './tools/fetch-sources.sh --lock', then audit it." + if ! expected="$(lookup_hash "$file" "$lock")"; then + die "no entry for ${file} in ${lock#"$KRYPTIK_ROOT"/}; run './tools/fetch-sources.sh --lock', then audit it." fi if [[ "$actual" != "$expected" ]]; then err "CHECKSUM MISMATCH for ${file}" @@ -305,8 +425,9 @@ while IFS='|' read -r name ver url _; do die "It was already on disk, so it CHANGED after it was locked. Do not delete it before finding out why (docs/supply-chain.md)." fi ok "${name} ${ver} verified" + [[ "$file" != *.crate ]] || fetch_crates "$dest" fi -done < <(manifest) +done < <(all_sources) # Kept in the sources cache for the signature gate, as ftp.gnu.org does not answer every runner. keyring="${KRYPTIK_SOURCES}/.keys/gnu-keyring.gpg" @@ -318,9 +439,12 @@ if [[ ! -s "$keyring" ]]; then fi if [[ "$MODE" == "lock" ]]; then - sort -k2 "${KRYPTIK_LOCK}.new" > "$KRYPTIK_LOCK" - rm -f "${KRYPTIK_LOCK}.new" - ok "wrote $(wc -l < "$KRYPTIK_LOCK") entries to sources.lock" + for lock in "$KRYPTIK_LOCK" "$KRYPTIK_IMAGES_LOCK"; do + [[ -f "${lock}.new" ]] || continue + sort -k2 "${lock}.new" > "$lock" + rm -f "${lock}.new" + ok "wrote $(wc -l < "$lock") entries to ${lock#"$KRYPTIK_ROOT"/}" + done warn "NOT YET AUDITED. Verify against upstream signatures before trusting." else ok "${total} package(s) verified (${fetched} downloaded, ${cached} cached)" diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 2b440678..429156b0 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -147,6 +147,7 @@ DRIVE_TIMEOUT=600 drive --qmp "$QMP" \ "expect:GT KEY-MENU-OPEN" "key:alt+p" \ "type-from:GT OPEN-TYPE 1 ([a-z0-9]+)" "type-from:GT OPEN-TYPE 2 ([a-z0-9]+)" \ "type-from:GT OPEN-TYPE 3 ([a-z0-9]+)" \ + "expect:GT KEY-VIEW-BROWSER" "key:alt+3" "expect:GT KEY-BROWSER-BACK" "key:alt+1" \ "expect:GT KEY-VT-ADMIN" "key:ctrl+alt+f2" "expect:GT KEY-VT-DESKTOP" "key:ctrl+alt+f1" \ "expect:GT KEY-MENU-AFTER-VT" "key:alt+p" \ "expect:GT END" "expect:GCHECK-DONE" \ @@ -167,7 +168,7 @@ for name in session-socket compositor-running chrome-focus-record chrome-window- map-keeps-zone0-focus close-keeps-zone0-focus alone-keeps-no-keyboard alone-reached-by-key focus-shows-zone focus-shows-label title-prefixed last-zone-recorded menu-opens-on-key menu-keeps-last-zone menu-names-stale-statement menu-names-install-age menu-update-auto menu-names-failed-trial menu-names-armed-trial zone-fullscreen-refused compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved zone-copy-pastes-in-zone zone-copy-stays-in-zone zone-copy-not-in-zone0 \ move-door-unreached move-crosses-zones move-leaves-nothing-behind move-in-no-log move-door-refuses-others \ transfer-policy no-question-for-policy-refusal consent-code-shown transfer-approved transfer-landed plain-y-refused denied-file-absent \ - second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head chrome-wifi-add menu-network-line chrome-wifi-add-open chrome-wifi-scan \ + second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head chrome-wifi-add menu-network-line chrome-wifi-add-open chrome-wifi-scan browser-window browser-stays-up \ zone-popup-drawn zone-popup-yields-keyboard zone-popup-ends-on-own-focus \ password-check-pauses lock-takes-keyboard lock-takes-camera lock-refuses-wrong-password lock-survives-its-death lock-unlocks-with-password lock-keeps-keys-from-zone-grab \ idle-lock-armed idle-locks idle-blanks key-wakes-screens idle-locks-again \ diff --git a/tools/image/install-test.sh b/tools/image/install-test.sh index f1881703..95dd1f47 100755 --- a/tools/image/install-test.sh +++ b/tools/image/install-test.sh @@ -113,6 +113,7 @@ drive --record "$REC" \ "grab:secureboot:od -An -tu1 -j4 -N1 /sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c 2>/dev/null || echo none" \ "grab:bootresult:cat /var/lib/kryptik/boot/last-result" \ "su:${RPASS}:kryptik-update status" \ + "su:${RPASS}:bash /usr/lib/kryptik/guest-tests/image-probe.sh" \ "run:echo KRYPTIK-CLEAR-MARKER-7f3a91 > /home/${TUSER}/persisted-p2 && sync" \ "$(ROOTSH "grep -rqa state[-]pw /proc/[0-9]*/cmdline /run /etc 2>/dev/null && echo PW-LEAK || echo PW-NOLEAK")" "expect:PW-NOLEAK" \ "su:${RPASS}:reboot" \ @@ -145,6 +146,7 @@ want "$P2" 'KRYPTIK_SMOKE: etc_source=overlay' "/etc is an overlay want "$P2" 'KRYPTIK_SMOKE: root_writable=no' "the verified root is not writable" want "$P2" 'boot-success: slot a up' "boot-success recorded slot a" want "$P2" 'slot b: +version none, kernel absent' "the ESP names no release for the empty slot b" +want "$P2" 'IMAGE-PROBE kryptik-probe 1.0, from the probe image: ' "a zone runs the probe from the image the installer kept from the medium" want "$P2" 'reboot: Restarting system' "the guest rebooted itself" if [[ "$(grep -c 'Linux version' "$P2")" -ge 2 ]]; then green "two kernel boots in one session (reboot worked)"; else red "expected two kernel boots"; fi want "$P2" 'Power down' "the guest powered off from inside" diff --git a/tools/image/update-test.sh b/tools/image/update-test.sh index 2832d52d..e243f0c1 100755 --- a/tools/image/update-test.sh +++ b/tools/image/update-test.sh @@ -194,6 +194,7 @@ drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "grab:v2:grep ^VERSION_ID= /etc/os-release; cat /run/kryptik/boot-identity; cat /var/lib/kryptik/boot/last-result" \ "run:test \"\$(cat /home/${TUSER}/marker)\" = before-update" \ "$(ROOTSH 'sha256sum -c /root/work.sha && kryptik-update status && echo B-OK')" "expect:B-OK" \ + "$(ROOTSH 'bash /usr/lib/kryptik/guest-tests/image-probe.sh')" "expect:IMAGE-PROBE " \ "$(ROOTSH 'kryptikd time status')" \ "$(ROOTSH 'poweroff')" "expect:Power down" "wait-exit" rc=$?; stop_vm @@ -203,6 +204,9 @@ txt | grep -q "KRYPTIK_SMOKE: boot_identity=slot=b" && green "booted slot b" || txt | grep -q "version_id=${VB}" && green "guest reports version ${VB}" || red "guest did not report ${VB}" txt | grep -q "boot-success: committed: BOOTX64.EFI is now slot b" && green "boot-success committed slot b" || red "no commit of slot b" txt | grep -q "committed slot: b" && green "status shows committed slot b" || red "committed slot is not b" +txt | grep -q 'IMAGE-PROBE kryptik-probe 1.0, from the probe image: ' \ + && green "a zone on ${VB} runs the probe from the image apply kept with it" \ + || red "the probe image did not run on ${VB}: $(txt | grep -m1 'IMAGE-PROBE')" txt | grep -qF "floor ${B_FLOOR} UTC (release ${VB}, the newest this machine committed to" \ && green "the clock's floor is ${VB}'s signed date (${B_FLOOR} UTC)" || red "after the commit of ${VB} the floor is not its date: $(txt | grep -E '(floor|release) ' | tail -2 | tr '\n' ' ')" @@ -268,11 +272,15 @@ drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "$(ROOTSH 'reboot')" "expect:Linux version" "expect:KRYPTIK_SMOKE: END" \ "login:${TUSER}:${TPASS}" \ "$(ROOTSH 'cat /run/kryptik/boot-identity; cat /var/lib/kryptik/boot/last-result; echo RB2-OK')" "expect:slot=b" "expect:commit b" "expect:RB2-OK" \ + "$(ROOTSH 'bash /usr/lib/kryptik/guest-tests/image-probe.sh')" "expect:IMAGE-PROBE " \ "$(ROOTSH 'poweroff')" "expect:Power down" "wait-exit" rc=$?; stop_vm [[ "$rc" -eq 0 ]] && green "rollback: slot b armed, booted and committed" || red "step 5 drive failed" stop_unless_ok "$rc" "step 5" txt | grep -q "version_id=${VB}" && green "guest reports ${VB} after rollback" || red "guest did not report ${VB}" +txt | grep -q 'IMAGE-PROBE kryptik-probe 1.0, from the probe image: ' \ + && green "after ${VA} was applied to the other slot, ${VB}'s images were still kept, and its zone runs the probe" \ + || red "${VB}'s probe image did not run after the rollback: $(txt | grep -m1 'IMAGE-PROBE')" # ----------------------------------------------------------------- step 6 -- step "step 6: interruption during the slot write, then after arming" @@ -429,6 +437,10 @@ else # the user, automatic fetching brings the release whole without a fetch # (one asked for as well changes nothing), then apply, trial boot and # commit, and the setting outlasts the update. + # The staged release: the root's files and the image list with its signature, listed, and each + # image the list names, left out of the listing and held to the list instead. No single quote: + # vm-drive's su wraps the command in them. + staged="cd /var/lib/kryptik/update/incoming/${VB} && "'sed -n "s/^image: \([a-z0-9-]*\) .*/\1.img.zst/p" images.manifest > /tmp/staged-images; ls | grep -vxF -f /tmp/staged-images | sort | tr "\n" " "; echo LISTED; w=0; while read -r f; do [ -s "$f" ] || w=1; done < /tmp/staged-images; echo IMAGES-WHOLE=$w' drive "${UP_TO_STATEMENT[@]}" \ "$(ROOTSH 'sleep 70; echo STAGED-UNASKED=$(ls /var/lib/kryptik/update/incoming 2>/dev/null | wc -l)')" "expect:STAGED-UNASKED=0" \ "run:kryptik update status | grep -q 'fetching *only when asked'" \ @@ -437,7 +449,7 @@ else "run:kryptik update fetch" "expect:${VB} will be fetched" \ "run:$(wait_arrival)" "run:$(wait_arrival)" "run:$(wait_arrival)" "run:$(wait_arrival)" \ "run:kryptik update status | grep -q 'bytes, complete'" \ - "$(ROOTSH "ls /var/lib/kryptik/update/incoming/${VB} | sort | tr \"\\n\" \" \"; echo LISTED")" "expect:kryptik-a.efi kryptik-b.efi kryptik-root.img.zst manifest manifest.sig root.json LISTED" \ + "$(ROOTSH "$staged")" "expect:images.manifest images.manifest.sig kryptik-a.efi kryptik-b.efi kryptik-root.img.zst manifest manifest.sig root.json LISTED" "expect:IMAGES-WHOLE=0" \ "run:kryptik update apply" "expect:armed: the next boot tries slot b" \ "$(ROOTSH 'reboot')" "expect:Linux version" "expect:KRYPTIK_SMOKE: END" \ "login:${TUSER}:${TPASS}" \ diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index f0abb011..67bb7b53 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -10,7 +10,8 @@ # step 2 guest-tests/zones-check.sh: kernel support, the net zone, zone 0 # offline, routed egress and DNS, zone separation, fail-closed # restart, the net zone over a mac80211_hwsim radio, time claims, -# limits, and the LUKS2 volume lifecycle +# limits, the LUKS2 volume lifecycle, and the software images stage +# 06 signed for this root, stacked over a zone's /usr # step 2b guest-tests/stick-check.sh: a USB stick plugged in while the guest # runs, given to a zone through the launch daemon, read, written, # taken back and pulled out; then the install medium plugged in @@ -32,12 +33,12 @@ while [[ "$#" -gt 0 ]]; do --disk) DISK="${2:?}"; shift 2 ;; --timeout) TIMEOUT="${2:?}"; shift 2 ;; --skip-suites) SUITES=0; shift ;; - -h|--help) sed -n '2,15p' "${BASH_SOURCE[0]}"; exit 0 ;; + -h|--help) sed -n '2,17p' "${BASH_SOURCE[0]}"; exit 0 ;; *) die "unknown argument: $1" ;; esac done [[ -f "$USB" ]] || die "--usb IMG is required" -for t in python3 sfdisk truncate mkfs.vfat mcopy mtype mkfs.exfat fsck.exfat; do have "$t" || die "required tool not found: $t"; done +for t in python3 sfdisk truncate mkfs.ext4 mkfs.vfat mcopy mtype mkfs.exfat fsck.exfat; do have "$t" || die "required tool not found: $t"; done VMDIR="${KRYPTIK_WORK}/vm"; mkdir -p "$VMDIR" DISK="${DISK:-${VMDIR}/zones.img}" [[ -e "$DISK" && ! -f "$DISK" ]] && die "refusing: ${DISK} is not a regular file" @@ -53,8 +54,24 @@ install_disk zones-install "$USB" --vars clean && green "installed" || { red "in # ----------------------------------------------------------------- step 2 -- step "step 2: the guest-side zone, network and storage checks (as root)" +# The software images stage 06 packed and signed for this medium's root, beside it as +# images-VERSION, on a disk of their own: the guest's image checks keep them from there. +MVER="$(basename "$USB")"; MVER="${MVER#kryptik-}"; MVER="${MVER%-usb.img}" +IMAGES="$(dirname "$USB")/images-${MVER}" +IMGDISK="${VMDIR}/zones-images.img" +# The label the guest finds the disk by: ext4 keeps 16 bytes of one, and cuts a longer one short. +IMGLABEL=kryptik-zt-img +(( ${#IMGLABEL} <= 16 )) || { red "the images disk's label ${IMGLABEL} is over ext4's 16 bytes"; exit 1; } +IMGARGS=() +if [[ -f "${IMAGES}/images.manifest.sig" ]] && rm -f "$IMGDISK" \ + && truncate -s "$(( $(du -sm "$IMAGES" | cut -f1) + 64 ))M" "$IMGDISK" \ + && mkfs.ext4 -q -F -L "$IMGLABEL" -d "$IMAGES" "$IMGDISK"; then + IMGARGS=(--disk "$IMGDISK") +else + red "no signed software images for ${MVER} at ${IMAGES}: the guest's image checks have nothing to keep" +fi # 3 GB, not 2: ephemeral-size-bound fills untrusted's 2G tmpfs, which is RAM, until ENOSPC. -start_vm zones-p2 --net user --mem 3072 --usb-port +start_vm zones-p2 --net user --mem 3072 --usb-port "${IMGARGS[@]}" DRIVE_TIMEOUT=900 drive "expect:KRYPTIK_SMOKE: END" "seen:kryptik-firstboot: created user '${TUSER}'" "login:${TUSER}:${TPASS}" \ "$(ROOTSH 'bash /usr/lib/kryptik/guest-tests/zones-check.sh 2>&1 | tee /var/log/kryptik/zones-check.log; echo ZCHECK-DONE')" \ "expect:ZT END" "expect:ZCHECK-DONE" @@ -86,7 +103,8 @@ for name in kernel-support policies net-ready net-dns dhcpcd-separated dnsmasq-u terminal-terminfo man-page text-browser tls-trust no-shared-writable-mount net-zone-no-zone-data no-swap no-dbus \ volume-init zone-source-pinned bridge-ports-closed encrypted-zone-start stop-closes-volume wrong-passphrase persist-reopen no-mapping-after ephemeral-gone concurrent-start-refused full-volume header-restore volume-destroy vault-offline vault-ping no-passphrase-leak \ setuid-only-allowed no-file-capabilities sysctls-applied host-activity-hidden fetch-and-sntp-no-caps \ - microcode-notice; do + microcode-notice \ + image-store image-runs image-needs-refused image-hidden image-explained image-list-namespace image-list-other-base image-corrupt image-toolkit image-browser; do grep -q "ZT PASS ${name}" <<<"$T2" && green "guest: ${name}" || red "guest: ${name} (not passed)" done diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index b1085aa6..e123d76f 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -69,9 +69,9 @@ done # --- every external tool, checked before the first write ------------------- missing="" -for tool in sfdisk blockdev blkid cryptsetup veritysetup stty mkfs.ext4 dd sha256sum mount umount sync awk sed od \ +for tool in sfdisk blockdev flock blkid cryptsetup veritysetup stty mkfs.ext4 dd sha256sum mount umount sync awk sed od \ grep readlink lsblk head tail wc cmp cp mkdir stat tr cut basename mv mountpoint rmdir chmod rm cat sleep \ - loadkeys chattr kryptik-efiboot; do + loadkeys chattr kryptik-efiboot kryptikd; do command -v "$tool" >/dev/null 2>&1 || missing="${missing} ${tool}" done [ -z "$missing" ] || die "this system is missing:${missing} @@ -80,8 +80,8 @@ touches a disk, not at the moment it is first called." # Only a table the kernel reads whole (BLKRRPART) gives each partition its name (PARTNAME), # which kryptik_named looks up. Partitions added one at a time, as partx adds them and as sfdisk -# does when its own re-read finds the disk busy, carry none. udev's probe of the new table keeps -# the disk busy for a moment. +# does when its own re-read finds the disk busy, carry none. A program that still has an old +# partition open keeps the disk busy for a moment. reread_table() { # DISK n=0 until blockdev --rereadpt "$1" 2>/dev/null; do @@ -117,6 +117,7 @@ mounted_on() { # mounted_on DEVS < TABLE: "SOURCE on WHERE" for each source th . /usr/libexec/kryptik/medium-root.sh # The ESP's kernels and records, written as every other tool writes them. . /usr/libexec/kryptik/esp-records.sh +. /usr/libexec/kryptik/image-store.sh [ -z "$KEYBOARD" ] || kb_row "$KEYBOARD" > /dev/null || die "no keyboard layout named ${KEYBOARD}: kryptik keyboard lists them" [ -z "$PRESEED" ] || preseed_ok "$PRESEED" @@ -183,20 +184,24 @@ fi # --- what we are installing, from the medium --------------------------------- media="$(sed -n 's/^media=//p' /run/kryptik/boot-identity 2>/dev/null)" [ -n "$media" ] || die "this is not an install medium (no kryptik.media= on the signed command line)" -mkdir -p "$MNT_BASE/media" "$MNT_BASE/esp" "$MNT_BASE/state" "$MNT_BASE/tesp" +mkdir -p "$MNT_BASE/media" "$MNT_BASE/esp" "$MNT_BASE/state" "$MNT_BASE/tesp" "$MNT_BASE/images" MAPPING=kryptik-install-state cleanup() { [ -t 0 ] && stty echo 2>/dev/null || true - for m in "$MNT_BASE/tesp" "$MNT_BASE/state" "$MNT_BASE/esp" "$MNT_BASE/media"; do + for m in "$MNT_BASE/tesp" "$MNT_BASE/state" "$MNT_BASE/images" "$MNT_BASE/esp" "$MNT_BASE/media"; do mountpoint -q "$m" 2>/dev/null && umount "$m" 2>/dev/null || true rmdir "$m" 2>/dev/null || true done [ -b "/dev/mapper/$MAPPING" ] && cryptsetup close "$MAPPING" 2>/dev/null || true + rm -rf "$MNT_BASE/images-checked" rmdir "$MNT_BASE" 2>/dev/null || true } trap cleanup EXIT INT TERM ESP_SRC="" # a block device or a regular file holding the ESP image +IMG_SRC="" # the medium's software images, if it carries any +# Image NAME as the medium carries it, never past the size its list signs and one byte. +medium_image() { head -c "$(( $2 + 1 ))" "$IMG_SRC/$1.img"; } # medium_image NAME BYTES ROOT_SRC="" # block device holding the root image at ROOT_OFF ROOT_OFF=0 case "$media" in @@ -208,14 +213,22 @@ case "$media" in [ -b "$ESP_SRC" ] || die "no single kryptik-esp partition on the medium this system booted from" mount -t vfat -o ro "$ESP_SRC" "$MNT_BASE/esp" || die "could not mount the medium's ESP" ROOT_JSON="$MNT_BASE/esp/kryptik/root.json" + # The release's software images, when the medium carries them, in a partition of their own. + IMG_DEV="$(kryptik_part kryptik-images 2>/dev/null)" || IMG_DEV="" + if [ -b "$IMG_DEV" ]; then + mount -t vfat -o ro,nosuid,nodev,noexec "$IMG_DEV" "$MNT_BASE/images" || die "could not mount the medium's kryptik-images" + IMG_SRC="$MNT_BASE/images" + fi ;; iso) - mount -t iso9660 -o ro /dev/sr0 "$MNT_BASE/media" || die "could not mount the medium (/dev/sr0)" + # As the stick's images partition is: Rock Ridge can carry device nodes and set-id files. + mount -t iso9660 -o ro,nosuid,nodev,noexec /dev/sr0 "$MNT_BASE/media" || die "could not mount the medium (/dev/sr0)" ESP_SRC="$MNT_BASE/media/esp.img" [ -f "$ESP_SRC" ] || die "no esp.img on the medium" # For its kernels, read before anything is written. mount -t vfat -o ro,loop "$ESP_SRC" "$MNT_BASE/esp" || die "could not mount the medium's ESP image" ROOT_JSON="$MNT_BASE/media/root.json" + if [ -f "$MNT_BASE/media/images/images.manifest" ]; then IMG_SRC="$MNT_BASE/media/images"; fi ROOT_SRC=/dev/sr0 # The signed linear table "0 N linear /dev/sr0 START" puts the root image at sector START. ROOT_OFF="$(sed -n 's/.*linear \/dev\/sr0 \([0-9]*\).*/\1/p' /proc/cmdline | head -1)" @@ -225,6 +238,14 @@ case "$media" in *) die "unknown medium type '${media}'" ;; esac medium_root "$ROOT_JSON" /proc/cmdline +ROOT_HASH="$(json_field "$ROOT_JSON" root_hash)" +# The medium's software images, checked before anything is written (medium-root.sh); the list +# and signature kept later are the ones checked, from IMG_CHECKED. +IMG_CHECKED="$MNT_BASE/images-checked" +if [ -n "$IMG_SRC" ]; then + rm -rf "$IMG_CHECKED" && mkdir -m 0700 "$IMG_CHECKED" || die "cannot make $IMG_CHECKED" + medium_images_ok "$IMG_SRC" "$IMG_CHECKED" +fi for s in a b; do kernel_names_root "$MNT_BASE/esp/EFI/kryptik/kryptik-$s.efi" \ || die "the medium's kernel for slot $s does not carry the root hash its signed command line names; nothing was written" @@ -329,6 +350,12 @@ kb_store "$LAYOUT" || die "the firmware did not take the keyboard layout ${LAYOU # --- partition ------------------------------------------------------------- say "partitioning (sfdisk, GPT: kryptik-esp, kryptik-a, kryptik-b, kryptik-state)" +# The disk stays locked until the install ends, as udev asks of whatever writes a partition +# table. When sfdisk closes the disk, eudev re-reads its table unless the disk is locked, and a +# re-read after ours drops the new partitions while they are named and written. The lock dies +# with this process; sfdisk and the tools after it take none of their own. +exec 7<"$TARGET_REAL" +flock -w 30 -x 7 || die "another program held ${TARGET} locked for 30 seconds; nothing was written" P1="$(part_dev "$TARGET_REAL" 1)"; P2="$(part_dev "$TARGET_REAL" 2)" P3="$(part_dev "$TARGET_REAL" 3)"; P4="$(part_dev "$TARGET_REAL" 4)" GPT_ESP=C12A7328-F81F-11D2-BA4B-00A0C93EC93B @@ -398,6 +425,10 @@ fi # --- the state partition: what installed this, and the first-boot preseed -- mount -t ext4 -o rw "/dev/mapper/$MAPPING" "$MNT_BASE/state" || die "could not mount kryptik-state" mkdir -p "$MNT_BASE/state/lib/kryptik" +if [ -n "$IMG_SRC" ]; then + images_keep "$IMG_CHECKED/images.manifest" "$IMG_CHECKED/images.manifest.sig" "$MNT_BASE/state/lib/kryptik/images" "$ROOT_HASH" medium_image + say "images $(grep -c '^image: ' "$IMG_CHECKED/images.manifest") kept for ${VERSION}" +fi cat > "$MNT_BASE/state/lib/kryptik/install.json" </dev/null || echo unknown)", diff --git a/tools/key-provenance.tsv b/tools/key-provenance.tsv index 4db38639..4f7c565f 100644 --- a/tools/key-provenance.tsv +++ b/tools/key-provenance.tsv @@ -16,11 +16,12 @@ # Columns are whitespace separated; the note runs to the end of the line. # # fingerprint the full 40-character primary key fingerprint, uppercase -# kind korg | wkd | github | savannah +# kind korg | wkd | github | savannah | origin # locator kind korg: the URL the armoured key was fetched from # kind wkd: the email address whose Web Key Directory serves it # kind github: https://github.com/.gpg # kind savannah: the project's release-gpgkeys.php?group=&download=1 +# kind origin: the URL of the key file the project serves on its own site # retrieved the date the confirmation was walked # signs comma-separated source names this key signs, no spaces; the row # speaks for these alone, so a key published per project has a @@ -55,6 +56,14 @@ # fingerprint was not learned from the signature under test - it came # from the account that publishes the releases. # +# origin the key file a project serves on its own site, beside its releases or +# on its release page, for a key ALSO pinned in PINNED_FPRS. The pin +# says who signs; the row says only where the key's material is taken +# from, so a new subkey or a revocation the project publishes lands on +# the next run where a keyserver's copy may lag. A key not pinned has +# no origin row: that would be a fingerprint learned from the site that +# serves the release, and nothing more. +# # NONE IS OUT-OF-BAND CONFIRMATION. All four rest on TLS and the CA system, and # no human here has checked any of these fingerprints through a second channel. # They are strictly better than a keyserver lookup by the id under test, and @@ -104,3 +113,8 @@ B3F2469D78D78BD09D366F37C94035C21B4F2AEB korg https://git.kernel.org/pub/scm 155D3FC500C834486D1EEA677FD9FCCB000BEEEE savannah https://savannah.gnu.org/project/release-gpgkeys.php?group=gzip&download=1 2026-10-02 gzip Jim Meyering 0CF4E8D871593224842832B888DD9E08C5DDACB9 savannah https://savannah.gnu.org/project/release-gpgkeys.php?group=findutils&download=1 2026-10-02 findutils James Youngman B1D2BD1375BECB784CF4F8C4D73CF638C53C06BE savannah https://savannah.gnu.org/project/release-gpgkeys.php?group=inetutils&download=1 2026-10-02 inetutils Simon Josefsson ; signs with subkey A3CC9C870B9D310ABAD4CF2F51722B08FE4745A2; Savannah's copy expired 2022-10-23, the GNU keyring's runs later +D33A3F0CA16B0ACC51A60738494C8A5FBF4DECEB wkd mak@debian.org 2026-10-13 hicolor-icon-theme Matthias Klumpp ; db.debian.org and nm.debian.org show the same fingerprint; signs the 0.18 tarball, the first signed release; expires 2028-07-18 +14F26682D0916CDD81E37B6D61B7B526D98F0353 origin https://archive.mozilla.org/pub/firefox/releases/153.5.0esr/KEY 2026-10-15 firefox Mozilla Software Releases ; the file carries the signing subkey 827E658608679618CD349F93678E455D76767AA3 (2026-08-06, to 2028-08-05) and the revocation of the leaked 09BEED63F3462A2DFFAB3B875ECB6497C1A20256, neither of which keyserver.ubuntu.com serves; V_FIREFOX's bump moves this URL +108F66205EAEB0AAA8DD5E1C85AB96E6FA1BE5FE origin https://static.rust-lang.org/rust-key.gpg.ascii 2026-10-13 rustc,cargo,rust-std Rust Language (Tag and Release Signing Key) ; served beside the releases it signs +D574BD5D1D0E98895E3BF90044F2485E45D59042 origin https://releases.llvm.org/release-keys.asc 2026-10-13 llvm Tobias Hieta ; one of the release managers' keys LLVM's site lists, and the one that signs 23.1.3 +C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C origin https://raw.githubusercontent.com/nodejs/release-keys/HEAD/keys/C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C.asc 2026-10-15 node Richard Lau ; one of the releasers' keys in Node.js's release-keys repository, and the one that signs 24.22.0 diff --git a/tools/make-release-keys.sh b/tools/make-release-keys.sh index 24fdc45c..8a463ca5 100755 --- a/tools/make-release-keys.sh +++ b/tools/make-release-keys.sh @@ -51,7 +51,7 @@ for n in kryptik-release kryptik-latest kryptik-testctl; do ssh-keygen -q -t ed25519 -N '' -C "$n" -f "$n" < /dev/null done { - printf 'kryptik-release namespaces="kryptik-release,kryptik-media" %s\n' "$(cut -d' ' -f1,2 kryptik-release.pub)" + printf 'kryptik-release namespaces="kryptik-release,kryptik-media,kryptik-image" %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 diff --git a/tools/pin-reviews.tsv b/tools/pin-reviews.tsv index d60b6cc7..3decfab9 100644 --- a/tools/pin-reviews.tsv +++ b/tools/pin-reviews.tsv @@ -12,6 +12,7 @@ binutils 2.43.1 2.47 fine 2026-09-19 NEWS 2.44 to 2.47 and the Debi gcc 14.4.0 16.2.0 fine 2026-09-19 14.4.0 is the newest 14.x. 15 changes the default C standard and 16 the default C++ one; neither is a security release. GCC Bugzilla milestones for 14.3 and 14.4 were read when the pin moved off 14.2.0. glibc 2.40 2.44 fine 2026-09-19 The tarball is 2.40 and build/patches/glibc-2.40 carries upstream's maintained release/2.40/master branch to cdaa5d6d (2026-09-10), which is where 2.40's security fixes live. Re-read that branch's head when this row is next reviewed. GLIBC-SA-2026-0022 (CVE-2026-86805, the loader's $ORIGIN check racing for setuid programs; fixed only for 2.45) needs a setuid program whose RPATH climbs out of $ORIGIN, and fs.protected_hardlinks off: su and passwd carry no RPATH (the artifact audit lists every one in the image) and build/config/sysctl.d sets protected_hardlinks to 1. glibc-branch cdaa5d6db08e 9bc3c99bffba fine 2026-10-15 build/patches/glibc-2.40's 0001 carries release/2.40/master to cdaa5d6d. The branch's one commit since, 9bc3c99b (2026-10-13), fixes an off-by-one in the AT_HWCAP4 loop of sysdeps/powerpc/dl-procinfo.h, reached only on PowerPC: nothing in it builds for x86-64. Move the patch to the branch head when the branch next fixes generic or x86-64 code. +rustc 1.98.1 1.99.1 fine 2026-10-16 Release notes 1.99.0: language, lint, library and Cargo changes; no security fix. 1.99.1 fixes a miscompilation that 1.99.0 brought in on x86-64, a bool returned to C with its upper bits left set (rust-lang/rust#163911: rustc stopped marking the return zeroext), and wcslen on UEFI and rust-lld's rpath on macOS; 1.98.1 still masks the bool. Never 1.99.0. The browser's layer and kryptikd (build/config/rust.lock) move together. mpc 1.3.1 1.4.1 fine 2026-09-19 NEWS 1.4.0 and 1.4.1: no security or wrong-result fix. 1.4.0 is to be skipped if this ever moves. # --- fine: nothing in the gap is a security fix that reaches Kryptik --------------- @@ -30,7 +31,7 @@ gettext 0.22.5 1.0 fine 2026-09-19 NEWS 0.23 to 1.0: no security texinfo 7.1 7.3 fine 2026-09-19 NEWS 7.1.1 to 7.3: no security fix. libtool 2.4.7 2.6.2 fine 2026-09-19 NEWS 2.5.x and 2.6.x: no security fix. gperf 3.1 3.3 fine 2026-09-19 NEWS 3.2 and 3.3: no security fix. -pkgconf 2.3.0 3.0.8 fine 2026-10-09 NEWS to 3.0.8: no security fix. 3.0 breaks the libpkgconf ABI, moves to meson and changed quoting and sysroot handling twice; 3.0.8 (2026-10-09) percent-encodes spdxtool URLs, uses the platform path separator in the built-in path variables, and fixes documentation. +pkgconf 2.3.0 3.0.9 fine 2026-10-16 NEWS to 3.0.9: no security fix. 3.0 breaks the libpkgconf ABI, moves to meson and changed quoting and sysroot handling twice; 3.0.8 (2026-10-09) percent-encodes spdxtool URLs, uses the platform path separator in the built-in path variables, and fixes documentation; 3.0.9 (2026-10-15) only adds spdxtool's utility tests to the Autotools build (d71cd7a364). intel-microcode 20260812 20260925 fine 2026-09-27 Release notes 20260925: one functional update, Meteor Lake C0 (06-aa-04, 0x28 to 0x2b), and no security advisory. ca-bundle 2026-08-13 2026-09-25 fine 2026-09-27 The 2026-09-25 bundle holds the same 121 certificates, byte for byte; only its header's date and certdata.txt digest differ. groff 1.23.0 1.24.2 fine 2026-09-29 NEWS 1.24.0 to 1.24.2: 1.24.2 fixes command injection (CWE-78) in mmroff, pdfmom and pre-grohtml, which the image does not install (build/recipes/groff.sh); man renders for a terminal, never HTML. 1.24.0 has incompatible request-syntax changes; never that one. diff --git a/tools/provenance-inventory.sh b/tools/provenance-inventory.sh index 376a1b42..0e035062 100755 --- a/tools/provenance-inventory.sh +++ b/tools/provenance-inventory.sh @@ -270,7 +270,7 @@ else echo fi -export KRYPTIK_LOCK KRYPTIK_SOURCES MD OFFLINE JSON KEYSTATE +export KRYPTIK_LOCK KRYPTIK_IMAGES_LOCK KRYPTIK_SOURCES MD OFFLINE JSON KEYSTATE INV_COMMIT="$(git -C "$KRYPTIK_ROOT" rev-parse HEAD 2>/dev/null || echo unknown)" export INV_COMMIT python3 - "$MANIFEST" "$SIGREP" "$PROVREP" "$IDREP" "$LICREP" "$ARTREP" "$NOTES_TSV" <<'PYEOF' @@ -338,14 +338,16 @@ CLASS_ORDER = {k: i for i, (k, _) in enumerate(CLASSES)} manifest = [(r[0], r[1], r[2]) for r in rows(manifest_path) if len(r) >= 3] lock = {} -try: - with open(lock_path) as fh: - for line in fh: - parts = line.split() - if len(parts) >= 2: - lock[parts[1]] = parts[0] -except OSError: - pass +# The root's lock and the software images'. +for path in (lock_path, os.environ.get("KRYPTIK_IMAGES_LOCK", "")): + try: + with open(path) as fh: + for line in fh: + parts = line.split() + if len(parts) >= 2: + lock[parts[1]] = parts[0] + except OSError: + pass sig = {} for r in rows(sig_path): diff --git a/tools/prune-sources.sh b/tools/prune-sources.sh index 743d0417..643a9439 100755 --- a/tools/prune-sources.sh +++ b/tools/prune-sources.sh @@ -4,7 +4,8 @@ # ./tools/prune-sources.sh remove them # ./tools/prune-sources.sh --dry-run say what would go # -# Keeps CI's source cache from growing by a tarball per version bump; subdirectories are left alone. +# Keeps CI's source caches from growing by a tarball per version bump. images/, the software images' +# own, is pruned the same way, with the links naming its files; other subdirectories are left alone. source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" load_config @@ -25,8 +26,9 @@ done < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list) [[ "${#keep[@]}" -gt 0 ]] || die "the manifest is empty; refusing to prune against it" removed=0 -for f in "${KRYPTIK_SOURCES}"/*; do - [[ -f "$f" ]] || continue +for f in "${KRYPTIK_SOURCES}"/* "${KRYPTIK_SOURCES}"/images/*; do + # A link stays while the manifest names it, whether or not the images' cache was restored. + [[ -f "$f" || -L "$f" ]] || continue name="$(basename "$f")" [[ -n "${keep[$name]:-}" ]] && continue if [[ "$DRY" -eq 1 ]]; then @@ -38,3 +40,23 @@ for f in "${KRYPTIK_SOURCES}"/*; do removed=$((removed + 1)) done ok "${#keep[@]} files named by the manifest; ${removed} not named $([[ "$DRY" -eq 1 ]] && echo "would be" || echo "were") removed" + +# images/crates/ keeps what the current crates' own locks name; with a crate missing its lock cannot be +# read, and nothing there is removed. +[[ -d "${KRYPTIK_SOURCES}/images/crates" ]] || exit 0 +declare -A wanted=() +while read -r _ _ url _; do + f="${url##*/}" + [[ "$f" == *.crate ]] || continue + [[ -f "${KRYPTIK_SOURCES}/${f}" ]] || { dim "${f} is not downloaded: images/crates/ is left as it is"; exit 0; } + while read -r cname cver _; do wanted["${cname}-${cver}.crate"]=1; done \ + < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --crates "${KRYPTIK_SOURCES}/${f}") +done < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list) +dropped=0 +for f in "${KRYPTIK_SOURCES}"/images/crates/*.crate; do + [[ -f "$f" ]] || continue + [[ -n "${wanted[$(basename "$f")]:-}" ]] && continue + if [[ "$DRY" -eq 1 ]]; then echo "would remove crates/$(basename "$f")"; else rm -f -- "$f"; echo "removed crates/$(basename "$f")"; fi + dropped=$((dropped + 1)) +done +ok "${#wanted[@]} crates named by the crates' locks; ${dropped} not named $([[ "$DRY" -eq 1 ]] && echo "would be" || echo "were") removed" diff --git a/tools/source-bundle.sh b/tools/source-bundle.sh index 9311fc1b..e19ec8ce 100755 --- a/tools/source-bundle.sh +++ b/tools/source-bundle.sh @@ -21,7 +21,9 @@ commit="$(git -C "$KRYPTIK_ROOT" rev-parse HEAD 2>/dev/null)" || die "${KRYPTIK_ OUT="${OUT:-${KRYPTIK_OUT}/source-${commit:0:12}}" [[ ! -e "$OUT" ]] || die "${OUT} already exists" mkdir -p "$OUT/sources" "$OUT/signatures" "$OUT/crates" -LOCK="${KRYPTIK_ROOT}/sources.lock" +# The root's and the software images' locks: the bundle is everything a release is built from. +LOCKS=("$KRYPTIK_LOCK") +[[ ! -f "$KRYPTIK_IMAGES_LOCK" ]] || LOCKS+=("$KRYPTIK_IMAGES_LOCK") log "Source bundle for ${commit} in ${OUT}" # Each tarball is checked against sources.lock as it is copied. @@ -29,8 +31,7 @@ n=0 while read -r name _ver url sig _; do [[ -n "$name" ]] || continue f="${url##*/}" - want="$(awk -v f="$f" '$2 == f { print $1; exit }' "$LOCK")" - [[ -n "$want" ]] || die "${name}: ${f} is not in sources.lock" + want="$(lock_entry "$f")" || die "${name}: ${f} is not in sources.lock or build/images/sources.lock" [[ -f "${KRYPTIK_SOURCES}/${f}" ]] || die "${name}: ${f} is not downloaded; run make sources" [[ "$(sha256_of "${KRYPTIK_SOURCES}/${f}")" == "$want" ]] || die "${name}: ${f} does not match sources.lock" [[ -f "$OUT/sources/${f}" ]] || { cp "${KRYPTIK_SOURCES}/${f}" "$OUT/sources/"; n=$((n + 1)); } @@ -46,9 +47,10 @@ missing=() while read -r hash f; do [[ -z "$hash" || "$hash" == \#* ]] && continue [[ -f "$OUT/sources/${f}" ]] || missing+=("$f") -done < "$LOCK" -[[ "${#missing[@]}" -eq 0 ]] || die "sources.lock names what the bundle lacks: ${missing[*]}" -cp "$LOCK" "$OUT/" +done < <(cat "${LOCKS[@]}") +[[ "${#missing[@]}" -eq 0 ]] || die "the locks name what the bundle lacks: ${missing[*]}" +cp "$KRYPTIK_LOCK" "$OUT/" +[[ ! -f "$KRYPTIK_IMAGES_LOCK" ]] || cp "$KRYPTIK_IMAGES_LOCK" "$OUT/images-sources.lock" ok "${n} tarballs, $(find "$OUT/signatures" -type f | wc -l) signatures" git -C "$KRYPTIK_ROOT" archive --format=tar.gz --prefix="kryptik-${commit:0:12}/" \ @@ -62,6 +64,22 @@ for ws in compartments/kryptikd compositor; do done ok "the crates of both Rust workspaces" +# A crate a software image is built with, cbindgen for the browser, comes with the crates its own lock +# names, which fetch-sources.sh fetched into sources/images/crates/: each, held to that lock's hash. +nc=0 +while read -r _ _ url _; do + f="${url##*/}" + [[ "$f" == *.crate ]] || continue + while read -r cname cver csum; do + c="${KRYPTIK_SOURCES}/images/crates/${cname}-${cver}.crate" + [[ -f "$c" ]] || die "crate ${cname} ${cver}, which ${f} is built from, is not downloaded; run make sources" + [[ "$(sha256_of "$c")" == "$csum" ]] || die "crate ${cname} ${cver} does not match ${f}'s Cargo.lock" + mkdir -p "$OUT/crates/images" + [[ -f "$OUT/crates/images/${c##*/}" ]] || { cp "$c" "$OUT/crates/images/"; nc=$((nc + 1)); } + done < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --crates "${KRYPTIK_SOURCES}/${f}") +done < <("${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list) +ok "${nc} crates the software images' crates are built from" + (cd "$OUT" && { printf '# Kryptik corresponding source for commit %s\n' "$commit" find . -type f ! -name MANIFEST -print0 | LC_ALL=C sort -z | xargs -0 sha256sum diff --git a/tools/source-mirrors.tsv b/tools/source-mirrors.tsv index 05967e69..a7c5f7de 100644 --- a/tools/source-mirrors.tsv +++ b/tools/source-mirrors.tsv @@ -12,9 +12,53 @@ # # Each row was checked when added: the mirror's file has the lock's hash, and its signature was # made by the key upstream's own is made by. +file https://ftp.osuosl.org/pub/lfs/lfs-packages/12.2/file-{VER}.tar.gz - +attr https://mirror.csclub.uwaterloo.ca/nongnu/attr/attr-{VER}.tar.gz - +attr https://sources.voidlinux.org/attr-{VER}/attr-{VER}.tar.gz - +pkgconf https://ftp.osuosl.org/pub/lfs/lfs-packages/12.2/pkgconf-{VER}.tar.xz - +pkgconf https://distfiles.alpinelinux.org/distfiles/v3.21/pkgconf-{VER}.tar.xz - +util-linux https://mirrors.mit.edu/kernel/linux/utils/util-linux/v2.42/util-linux-{VER}.tar.xz - +e2fsprogs https://mirror.math.princeton.edu/pub/kernel/linux/kernel/people/tytso/e2fsprogs/v{VER}/e2fsprogs-{VER}.tar.gz - +procps-ng https://master.dl.sourceforge.net/project/procps-ng/Production/procps-ng-{VER}.tar.xz - +procps-ng https://distfiles.alpinelinux.org/distfiles/edge/procps-ng-{VER}.tar.xz - +libpipeline https://mirror.csclub.uwaterloo.ca/nongnu/libpipeline/libpipeline-{VER}.tar.gz - +libpipeline https://ftp.osuosl.org/pub/lfs/lfs-packages/12.2/libpipeline-{VER}.tar.gz - +man-db https://mirror.csclub.uwaterloo.ca/nongnu/man-db/man-db-{VER}.tar.xz - +man-db https://ftp.osuosl.org/pub/lfs/lfs-packages/12.2/man-db-{VER}.tar.xz - +elfutils https://mirrors.kernel.org/sourceware/elfutils/{VER}/elfutils-{VER}.tar.bz2 - +elfutils https://ftp.osuosl.org/pub/lfs/lfs-packages/12.2/elfutils-{VER}.tar.bz2 - +perl https://cpan.metacpan.org/src/5.0/perl-{VER}.tar.xz - +perl https://ftp.osuosl.org/pub/CPAN/src/5.0/perl-{VER}.tar.xz - +skalibs https://distfiles.alpinelinux.org/distfiles/edge/skalibs-{VER}.tar.gz - +execline https://distfiles.alpinelinux.org/distfiles/edge/execline-{VER}.tar.gz - +s6 https://distfiles.alpinelinux.org/distfiles/edge/s6-{VER}.tar.gz - +s6-rc https://distfiles.alpinelinux.org/distfiles/edge/s6-rc-{VER}.tar.gz - +s6-linux-init https://distfiles.alpinelinux.org/distfiles/edge/s6-linux-init-{VER}.tar.gz - +cmake https://github.com/Kitware/CMake/releases/download/v{VER}/cmake-{VER}.tar.gz - +cmake-bin https://github.com/Kitware/CMake/releases/download/v{VER}/cmake-{VER}-linux-x86_64.tar.gz - +libaio https://ftp2.osuosl.org/pub/blfs/conglomeration/libaio/libaio-{VER}.tar.gz - +libaio https://deb.debian.org/debian/pool/main/liba/libaio/libaio_{VER}.orig.tar.gz - +lvm2 https://mirrors.kernel.org/sourceware/lvm2/LVM2.{VER}.tgz - +lvm2 https://ftp2.osuosl.org/pub/blfs/conglomeration/lvm2/LVM2.{VER}.tgz - +seatd https://ftp2.osuosl.org/pub/blfs/conglomeration/seatd/seatd-{VER}.tar.gz - +seatd https://deb.debian.org/debian/pool/main/s/seatd/seatd_{VER}.orig.tar.gz - +dwl https://distfiles.alpinelinux.org/distfiles/edge/dwl-v{VER}.tar.gz - +lynx https://invisible-island.net/archives/lynx/tarballs/lynx{VER}.tar.bz2 - +lynx https://ftp2.osuosl.org/pub/blfs/conglomeration/lynx/lynx{VER}.tar.bz2 - +glibc-fhs-patch https://ftp.osuosl.org/pub/lfs/lfs-packages/12.2/glibc-{VER}-fhs-1.patch - +glibc-fhs-patch https://ftp.lfs-matrix.net/pub/lfs/lfs-packages/12.2/glibc-{VER}-fhs-1.patch - +glib https://ftp2.osuosl.org/pub/blfs/conglomeration/glib/glib-{VER}.tar.xz - +at-spi2-core https://ftp2.osuosl.org/pub/blfs/conglomeration/at-spi2-core/at-spi2-core-{VER}.tar.xz - wayland https://deb.debian.org/debian/pool/main/w/wayland/wayland_{VER}.orig.tar.xz .asc wayland https://distfiles.alpinelinux.org/distfiles/edge/wayland-{VER}.tar.xz - wayland-protocols https://deb.debian.org/debian/pool/main/w/wayland-protocols/wayland-protocols_{VER}.orig.tar.xz .asc wayland-protocols https://distfiles.alpinelinux.org/distfiles/edge/wayland-protocols-{VER}.tar.xz - xkeyboard-config https://deb.debian.org/debian/pool/main/x/xkeyboard-config/xkeyboard-config_{VER}.orig.tar.xz .asc pixman https://deb.debian.org/debian/pool/main/p/pixman/pixman_{VER}.orig.tar.gz - +hicolor-icon-theme https://deb.debian.org/debian/pool/main/h/hicolor-icon-theme/hicolor-icon-theme_{VER}.orig.tar.xz .asc +hicolor-icon-theme https://distfiles.alpinelinux.org/distfiles/v3.22/hicolor-icon-theme-{VER}.tar.xz - +cairo https://ftp2.osuosl.org/pub/blfs/conglomeration/cairo/cairo-{VER}.tar.xz - +libglvnd https://sources.voidlinux.org/libglvnd-{VER}/libglvnd-v{VER}.tar.gz - +libdrm https://ftp2.osuosl.org/pub/blfs/conglomeration/libdrm/libdrm-{VER}.tar.xz - +# Void names its package after the series, so the row moves with a series change. +wlroots https://sources.voidlinux.org/wlroots0.19-{VER}/wlroots-{VER}.tar.gz - diff --git a/tools/tests/check-image-tree.sh b/tools/tests/check-image-tree.sh new file mode 100755 index 00000000..b14aa58a --- /dev/null +++ b/tools/tests/check-image-tree.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Tests for tools/check-image-tree.sh on a fixture sysroot and the upper directory an overlay left. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHECK="${ROOT}/tools/check-image-tree.sh" +PASS=0; FAIL=0 +ok() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } +bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } + +T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT +L="$T/lower"; U="$T/upper" +mkdir -p "$L/usr/bin" "$L/usr/share/info" "$L/etc/kryptik" "$L/tmp" +printf 'root\n' > "$L/usr/bin/bash"; chmod 755 "$L/usr/bin/bash" +printf 'index\n' > "$L/usr/share/info/dir" +check() { NO_COLOR=1 bash "$CHECK" "$L" "$U" 2>&1; } +# The upper directory a clean build leaves: new files under /usr, copied-up directories, the chroot's own marks. +fresh() { + rm -rf "$U"; mkdir -p "$U/usr/bin" "$U/usr/share/probe" "$U/etc/kryptik" "$U/tmp/build" "$U/kryptik-work" + printf 'probe\n' > "$U/usr/bin/kryptik-probe"; chmod 755 "$U/usr/bin/kryptik-probe" + printf 'data\n' > "$U/usr/share/probe/README" + ln -s kryptik-probe "$U/usr/bin/probe" + printf 'marker\n' > "$U/etc/kryptik/inside-chroot" + printf 'scratch\n' > "$U/tmp/build/object.o" + chmod --reference="$L/usr/bin" "$U/usr/bin"; chmod --reference="$L/etc/kryptik" "$U/etc/kryptik" + chmod --reference="$L/tmp" "$U/tmp" +} + +fresh +out="$(check)"; rc=$? +[[ "$rc" -eq 0 && "$out" == *"3 files and links"* ]] \ + && ok "new files under /usr pass; the chroot's marker and scratch are let be" || bad "clean tree: rc=$rc: $out" + +fresh; mkdir -p "$U/usr/share/info"; printf 'changed\n' > "$U/usr/share/info/dir" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"replaces the root's /usr/share/info/dir"* ]] \ + && ok "a file of the root's, rewritten, fails" || bad "replaced file: rc=$rc: $out" + +fresh; mkdir -p "$U/etc/probe"; printf 'conf\n' > "$U/etc/probe/probe.conf" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"makes /etc/probe outside /usr"* && "$out" == *"installs /etc/probe/probe.conf outside /usr"* ]] \ + && ok "a file outside /usr fails, with its directory" || bad "outside /usr: rc=$rc: $out" + +fresh; chmod 4755 "$U/usr/bin/kryptik-probe" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"/usr/bin/kryptik-probe is setuid or setgid"* ]] \ + && ok "a setuid bit fails" || bad "setuid: rc=$rc: $out" + +fresh; chmod 0777 "$U/usr/bin" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"changes the root's directory /usr/bin"* ]] \ + && ok "a root directory with another mode fails" || bad "directory mode: rc=$rc: $out" + +fresh; ln -s /etc/passwd "$U/usr/bin/passwd-link" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"/usr/bin/passwd-link links to /etc/passwd, outside /usr"* ]] \ + && ok "a link to a path outside /usr fails" || bad "absolute link out: rc=$rc: $out" + +fresh; ln -s ../../tmp/probe "$U/usr/bin/climb" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"/usr/bin/climb links to ../../tmp/probe, outside /usr"* ]] \ + && ok "a relative link that climbs out of /usr fails" || bad "relative link out: rc=$rc: $out" + +fresh; ln -s /usr/share/probe/README "$U/usr/bin/probe-readme" +out="$(check)"; rc=$? +[[ "$rc" -eq 0 ]] && ok "an absolute link that stays in /usr passes" || bad "absolute link in: rc=$rc: $out" + +fresh; rm -rf "${U:?}/usr" +out="$(check)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"installed nothing under /usr"* ]] \ + && ok "an image with nothing under /usr fails" || bad "empty image: rc=$rc: $out" + +# Built over another image, whose tree lies between: neither its files nor the root's may be replaced. +M="$T/under"; mkdir -p "$M/usr/lib/toolkit" "$M/usr/bin" +printf 'lib\n' > "$M/usr/lib/toolkit/libtk.so" +chmod --reference="$L/usr/bin" "$M/usr/bin" +lcheck() { NO_COLOR=1 bash "$CHECK" "$M:$L" "$U" 2>&1; } +fresh +out="$(lcheck)"; rc=$? +[[ "$rc" -eq 0 && "$out" == *"3 files and links"* ]] && ok "an image built over another adds its own files" || bad "layered clean: rc=$rc: $out" +fresh; mkdir -p "$U/usr/lib/toolkit"; chmod --reference="$M/usr/lib/toolkit" "$U/usr/lib/toolkit" +printf 'other\n' > "$U/usr/lib/toolkit/libtk.so" +out="$(lcheck)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"replaces /usr/lib/toolkit/libtk.so of an image under it"* ]] \ + && ok "a file of the image it is built over, rewritten, fails" || bad "layered replace: rc=$rc: $out" +fresh; mkdir -p "$U/usr/share/info"; printf 'changed\n' > "$U/usr/share/info/dir" +out="$(lcheck)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"replaces the root's /usr/share/info/dir"* ]] \ + && ok "the root's files stay protected under another image" || bad "layered root replace: rc=$rc: $out" +fresh; mkdir -p "$U/usr/lib/toolkit"; chmod 0700 "$U/usr/lib/toolkit" +out="$(lcheck)"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"changes the directory /usr/lib/toolkit of an image under it"* ]] \ + && ok "a directory of the image under it with another mode fails" || bad "layered directory mode: rc=$rc: $out" + +# A whiteout is a character device 0/0, which only root can make. +fresh +if mknod "$U/usr/bin/bash" c 0 0 2>/dev/null; then + out="$(check)"; rc=$? + [[ "$rc" -ne 0 && "$out" == *"removes the root's /usr/bin/bash"* ]] \ + && ok "a whiteout fails" || bad "whiteout: rc=$rc: $out" +else + printf ' SKIP a whiteout fails (making one needs root)\n' +fi + +# overlayfs's opaque mark is a trusted xattr, which only root can set. +opaque() { python3 -c 'import os, sys; os.setxattr(sys.argv[1], "trusted.overlay.opaque", b"y")' "$1" 2>/dev/null; } +fresh +if opaque "$U/usr/share/probe"; then + out="$(check)"; rc=$? + [[ "$rc" -eq 0 ]] && ok "a new directory overlayfs marked opaque passes: the root has nothing there" || bad "new opaque: rc=$rc: $out" + fresh; mkdir -p "$U/usr/share/info"; chmod --reference="$L/usr/share/info" "$U/usr/share/info"; opaque "$U/usr/share/info" + out="$(check)"; rc=$? + [[ "$rc" -ne 0 && "$out" == *"hides what the root has under /usr/share/info"* ]] \ + && ok "an opaque directory over one of the root's fails" || bad "opaque over the root's: rc=$rc: $out" +else + printf ' SKIP opaque directories (setting the mark needs root)\n' +fi + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] diff --git a/tools/tests/check-source-currency.sh b/tools/tests/check-source-currency.sh index 441ab9f6..d477ae9e 100755 --- a/tools/tests/check-source-currency.sh +++ b/tools/tests/check-source-currency.sh @@ -116,6 +116,12 @@ page "ftp.openbsd.org/pub/OpenBSD/OpenSSH/portable" \ page "openssl-library.org/source" \ "openssl-3.3.1.tar.gz" "openssl-3.6.2.tar.gz" "openssl-4.0.0.tar.gz" +# nasm: the index page's DOCTYPE names HTML 4.01, which is no release, and an rc directory is none either. +mkdir -p "${SERVE}/www.nasm.us/pub/nasm/releasebuilds" +printf '%s\n' '' \ + '3.01/ 3.02rc13/ 3.02/' \ + > "${SERVE}/www.nasm.us/pub/nasm/releasebuilds/index.html" + # glibc's release branch, read by git over HTTP from a plain info/refs, past the patch set's commit. refs() { mkdir -p "${SERVE}/sourceware.org/git/glibc.git/info"; printf '%s\trefs/heads/release/2.40/master\n' "$1" > "${SERVE}/sourceware.org/git/glibc.git/info/refs"; } refs 1111111111111111111111111111111111111111 @@ -187,6 +193,7 @@ openssh 10.5p1 https://ftp.openbsd.org/pub/OpenBSD/OpenSSH/portable/ope kernel-hardening-checker 0.6.17.1 https://github.com/a13xp0p0v/kernel-hardening-checker/archive/refs/tags/v0.6.17.1.tar.gz probe rule glibc-fhs-patch 2.40 https://www.linuxfromscratch.org/patches/lfs/12.2/glibc-2.40-fhs-1.patch none follows:glibc openssl 3.3.1 https://www.openssl.org/source/openssl-3.3.1.tar.gz probe rule +nasm 3.02 https://www.nasm.us/pub/nasm/releasebuilds/3.02/nasm-3.02.tar.xz none rule oddity 1.0 https://example.invalid/oddity/oddity-1.0.tar.gz probe telepathy norule 1.0 https://example.invalid/norule/norule-1.0.tar.gz probe rule ROWS @@ -253,6 +260,7 @@ expect_row kernel-hardening-checker 0.6.17.1 current # tags only, no releases expect_row glibc-fhs-patch "" deferred # follows the glibc pin expect_row glibc-branch 111111111111 BEHIND # the branch moved past the patch set's commit expect_row openssl 3.6.2 BEHIND # the pinned major line, not 4.0.0 +expect_row nasm 3.02 current # not the 4.01 of the page's DOCTYPE # A row the script cannot look up is UNKNOWN, never skipped. expect_row oddity "" UNKNOWN # a way to look that the script does not know diff --git a/tools/tests/fetch-sources.sh b/tools/tests/fetch-sources.sh index 285ce027..fb072388 100755 --- a/tools/tests/fetch-sources.sh +++ b/tools/tests/fetch-sources.sh @@ -296,6 +296,106 @@ else red "--lock did not state that it records whatever is present"; show fi +# --- a software image's sources --------------------------------------------- + +# Kept in sources/images/, the images' own cache, and named in sources/ by a link. +printf 'upstream payload for delta-4.0\n' > "${UPSTREAM}/delta-4.0.tar.gz" +IMANIFEST="${W}/image-manifest" +printf 'toolkit|delta|4.0|file://%s/delta-4.0.tar.gz|probe|listing\n' "$UPSTREAM" > "$IMANIFEST" +images_root() { + build_root alpha-1.0:good beta-2.0:good gamma-3.0:good + mkdir -p "${FAKE}/build/images" + printf '%s delta-4.0.tar.gz\n' "$(sha_of "${UPSTREAM}/delta-4.0.tar.gz")" > "${FAKE}/build/images/sources.lock" +} +run_images() { + KRYPTIK_ROOT="$FAKE" KRYPTIK_FETCH_SELFTEST=1 KRYPTIK_FETCH_MANIFEST="$MANIFEST" \ + KRYPTIK_FETCH_IMAGE_MANIFEST="$IMANIFEST" NO_COLOR=1 bash "$TOOL" "$@" > "$OUT" 2>&1 + RC=$? +} + +images_root +run_images +expect_pass "an image's source is fetched and verified against the images' lock" "4 package(s) verified" +if [[ -f "${FAKE}/sources/images/delta-4.0.tar.gz" && ! -L "${FAKE}/sources/images/delta-4.0.tar.gz" \ + && "$(readlink "${FAKE}/sources/delta-4.0.tar.gz")" == images/delta-4.0.tar.gz ]]; then + green "it is kept in sources/images/ and named in sources/ by a relative link" +else + red "the image's source is not in sources/images/ behind a link"; ls -la "${FAKE}/sources" "${FAKE}/sources/images" 2>&1 | sed 's/^/ /' +fi + +images_root +run_images --root +expect_pass "--root fetches the root's sources alone" "3 package(s) verified" +if [[ ! -e "${FAKE}/sources/images/delta-4.0.tar.gz" ]]; then + green "--root leaves the images' sources alone" +else + red "--root fetched an image's source" +fi + +# --root still moves one a cache left at the top, so the root's set is saved without it. +images_root +cp "${UPSTREAM}/delta-4.0.tar.gz" "${FAKE}/sources/delta-4.0.tar.gz" +run_images --root +if [[ "$RC" -eq 0 && -L "${FAKE}/sources/delta-4.0.tar.gz" && -f "${FAKE}/sources/images/delta-4.0.tar.gz" ]]; then + green "--root moves an image's source left at the top into sources/images/" +else + red "--root left an image's source at the top (exit ${RC})"; show +fi + +# A set cached before images/ existed has the image's file in sources/ itself. +images_root +cp "${UPSTREAM}/delta-4.0.tar.gz" "${FAKE}/sources/delta-4.0.tar.gz" +run_images +expect_pass "an image's source cached in sources/ itself still verifies" "1 cached" +if [[ -L "${FAKE}/sources/delta-4.0.tar.gz" && -f "${FAKE}/sources/images/delta-4.0.tar.gz" ]]; then + green "it moves into sources/images/, a link left in its place" +else + red "the image's source was not moved into sources/images/" +fi + +# --- a crate's own crates ---------------------------------------------------- + +# The crate's hash is locked; its Cargo.lock gives the hash of each crate it builds from. +CR="${W}/crates"; mkdir -p "${CR}/dep" "${W}/tool-1.0" +printf 'dep crate\n' > "${CR}/dep/dep-0.1.0.crate" +dep_sum="$(sha_of "${CR}/dep/dep-0.1.0.crate")" +crate_with() { # crate_with DEP-HASH: tool-1.0.crate whose Cargo.lock locks dep 0.1.0 to DEP-HASH + printf '[[package]]\nname = "dep"\nversion = "0.1.0"\nsource = "registry+https://github.com/rust-lang/crates.io-index"\nchecksum = "%s"\n\n[[package]]\nname = "tool"\nversion = "1.0"\n' "$1" > "${W}/tool-1.0/Cargo.lock" + tar -czf "${UPSTREAM}/tool-1.0.crate" -C "$W" tool-1.0 +} +CMANIFEST="${W}/crate-manifest" +printf 'tool|tool|1.0|file://%s/tool-1.0.crate|none|listing\n' "$UPSTREAM" > "$CMANIFEST" +crates_root() { + build_root alpha-1.0:good beta-2.0:good gamma-3.0:good + mkdir -p "${FAKE}/build/images" + printf '%s tool-1.0.crate\n' "$(sha_of "${UPSTREAM}/tool-1.0.crate")" > "${FAKE}/build/images/sources.lock" +} +run_crates() { + KRYPTIK_ROOT="$FAKE" KRYPTIK_FETCH_SELFTEST=1 KRYPTIK_FETCH_MANIFEST="$MANIFEST" \ + KRYPTIK_FETCH_IMAGE_MANIFEST="$CMANIFEST" KRYPTIK_CRATES="file://${CR}" NO_COLOR=1 bash "$TOOL" > "$OUT" 2>&1 + RC=$? +} + +crate_with "$dep_sum"; crates_root +run_crates +expect_pass "a crate's Cargo.lock brings the crates it names, each verified" "the 1 crates its Cargo.lock names, verified" +[[ -f "${FAKE}/sources/images/crates/dep-0.1.0.crate" ]] \ + && green "they land in sources/images/crates/" || red "the named crate is not in sources/images/crates/" + +crate_with 0000000000000000000000000000000000000000000000000000000000000000; crates_root +run_crates +expect_fail "a crate whose bytes are not what the Cargo.lock says is refused" "CHECKSUM MISMATCH for crate dep 0.1.0" + +KRYPTIK_ROOT="$FAKE" NO_COLOR=1 bash "$TOOL" --crates "${UPSTREAM}/tool-1.0.crate" > "$OUT" 2>&1 +[[ "$(cat "$OUT")" == "dep 0.1.0 0000000000000000000000000000000000000000000000000000000000000000" ]] \ + && green "--crates prints what a crate's lock names" || { red "--crates printed something else"; show; } + +if KRYPTIK_ROOT="$FAKE" KRYPTIK_CRATES="file://${CR}" NO_COLOR=1 bash "$TOOL" --list > "$OUT" 2>&1; then + red "a crates mirror was taken without the test's manifest" +else + grep -qF "KRYPTIK_CRATES replaces crates.io" "$OUT" && green "a crates mirror is refused outside the test" || { red "the crates mirror refusal did not say why"; show; } +fi + # --- arguments and the selftest gate ---------------------------------------- run --list @@ -317,24 +417,48 @@ fi # --- the shipped manifest and the shipped lockfile must agree --------------- # A row with no lock line is refused at fetch time; a lock line with no row is a hash nobody checks. +# The root's rows are in sources.lock, the software images' in build/images/sources.lock. bash "$TOOL" --list > "${W}/live-manifest" 2>/dev/null -awk '{n = $3; sub(/.*\//, "", n); print n}' "${W}/live-manifest" | sort -u > "${W}/mf" -awk '{print $2}' "${ROOT}/sources.lock" | sort -u > "${W}/lk" - -miss="$(comm -23 "${W}/mf" "${W}/lk" | tr '\n' ' ')" +bash "$TOOL" --list-root > "${W}/root-manifest" 2>/dev/null +names() { awk '{n = $3; sub(/.*\//, "", n); print n}' "$1" | sort -u; } +names "${W}/root-manifest" > "${W}/mf-root" +comm -23 <(names "${W}/live-manifest") "${W}/mf-root" > "${W}/mf-images" +awk '{print $2}' "${ROOT}/sources.lock" | sort -u > "${W}/lk-root" +awk '{print $2}' "${ROOT}/build/images/sources.lock" | sort -u > "${W}/lk-images" + +miss="$(comm -23 "${W}/mf-root" "${W}/lk-root" | tr '\n' ' ')$(comm -23 "${W}/mf-images" "${W}/lk-images" | tr '\n' ' ')" if [[ -z "${miss// /}" ]]; then - green "every source in the manifest has a sources.lock entry" + green "every source in the manifest has an entry in its lock" else red "manifest rows with no lock entry: ${miss}" fi -stale="$(comm -13 "${W}/mf" "${W}/lk" | tr '\n' ' ')" +stale="$(comm -13 "${W}/mf-root" "${W}/lk-root" | tr '\n' ' ')$(comm -13 "${W}/mf-images" "${W}/lk-images" | tr '\n' ' ')" if [[ -z "${stale// /}" ]]; then - green "every sources.lock entry corresponds to a manifest row" + green "every lock entry corresponds to a manifest row of its own set" else red "lock entries with no manifest row: ${stale}" fi +# Mozilla's key is taken from the KEY file beside the pinned release; a Firefox bump that left the row +# behind would keep importing a file that never learns a later revocation. +vf="$(sed -n 's/^V_FIREFOX=//p' "${ROOT}/build/images/versions.env")" +if [[ -n "$vf" ]] && grep -Eq "^14F26682D0916CDD81E37B6D61B7B526D98F0353[[:space:]]+origin[[:space:]]+https://archive[.]mozilla[.]org/pub/firefox/releases/${vf//./[.]}esr/KEY[[:space:]]" "${ROOT}/tools/key-provenance.tsv"; then + green "Mozilla's key row names the KEY file beside the pinned Firefox, ${vf}esr" +else + red "tools/key-provenance.tsv's Mozilla row does not name releases/${vf:-?}esr/KEY: a Firefox bump moves it too" +fi + +# The browser's Rust layer and kryptikd's compiler are one release: both locks hold the same three. +rust_shared="$(awk 'NR == FNR { if ($1 !~ /^#/ && NF == 2) want[$2] = $1; next } + ($2 in want) { n++; if (want[$2] != $1) bad = bad " " $2 } + END { print n + 0 bad }' "${ROOT}/build/config/rust.lock" "${ROOT}/build/images/sources.lock")" +if [[ "$rust_shared" == 3 ]]; then + green "build/config/rust.lock and the images' lock hold the same Rust tarballs" +else + red "the Rust tarballs the two locks share, and those whose hashes differ: ${rust_shared}" +fi + # An unset version leaves a field empty (a URL like gdbm-.tar.gz, a quiet 404) and the row short. blank="$(awk 'NF != 5 {print $1}' "${W}/live-manifest" | tr '\n' ' ')" if [[ -z "${blank// /}" ]]; then @@ -347,7 +471,7 @@ fi # A value no tool knows fails there too; here it fails before it ships. odd="$(awk ' - $4 !~ /^(gnu|kernel|sig|asc|stem\.sig|sums:[^\/]+\.(asc|sig)|probe|sha256|sha256\.txt|tag|none)$/ { print $1 " sig=" $4; next } + $4 !~ /^(gnu|kernel|sig|asc|stem\.sig|sums:[^\/]+\.(asc|sig)|probe|sha256|sha256\.txt|sha256sum|stem\.sha256sum|tag|none)$/ { print $1 " sig=" $4; next } $5 !~ /^(gnu|vdir|github|listing|rule|eol|follows:.+)$/ { print $1 " new=" $5 } ' "${W}/live-manifest" | tr '\n' ' ')" if [[ -z "${odd// /}" ]]; then diff --git a/tools/tests/image-licences.sh b/tools/tests/image-licences.sh index 51be50bc..9388b856 100755 --- a/tools/tests/image-licences.sh +++ b/tools/tests/image-licences.sh @@ -30,6 +30,18 @@ out="$(check)"; rc=$? rm -r "$I/usr/share/licenses/kryptik" out="$(check)"; rc=$? [[ "$rc" -ne 0 && "$out" == *"no licence files: kryptik"* ]] && ok "Kryptik's own licence is required too" || bad "no kryptik: rc=$rc: $out" +# A software image: its own sources, and no Kryptik licence of its own. +J="$T/toolkit"; mkdir -p "$J/usr/share/licenses/glib"; echo text > "$J/usr/share/licenses/glib/COPYING" +printf '#!/usr/bin/env bash\n[[ "$1 ${2:-}" == "--list-image toolkit" ]] && echo "glib 2.90.1 https://x/glib-2.90.1.tar.xz stem.sha256sum rule"; true\n' > "$R/tools/fetch-sources.sh" +icheck() { KRYPTIK_ROOT="$R" NO_COLOR=1 bash "$R/tools/check-image-licences.sh" --image "$1" "$2" 2>&1; } +out="$(icheck toolkit "$J")"; rc=$? +[[ "$rc" -eq 0 && "$out" == *"all 1 of image toolkit's sources"* ]] && ok "an image's sources covered, without Kryptik's own: it passes" || bad "image covered: rc=$rc: $out" +rm -r "$J/usr/share/licenses/glib" +out="$(icheck toolkit "$J")"; rc=$? +[[ "$rc" -ne 0 && "$out" == *"no licence files: glib"* ]] && ok "an image missing a source's licence fails, by name" || bad "image missing: rc=$rc: $out" +out="$(icheck probe "$T/nothing")"; rc=$? +[[ "$rc" -eq 0 && "$out" == *"built from no listed source"* ]] && ok "an image built from no listed source passes" || bad "no sources: rc=$rc: $out" + printf '#!/usr/bin/env bash\ntrue\n' > "$R/tools/fetch-sources.sh" out="$(check)"; rc=$? [[ "$rc" -ne 0 && "$out" == *"named no sources"* ]] && ok "an empty source list passes nothing" || bad "empty list: rc=$rc: $out" diff --git a/tools/tests/image-list.sh b/tools/tests/image-list.sh new file mode 100755 index 00000000..d1c71d9d --- /dev/null +++ b/tools/tests/image-list.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Tests for kryptik-update's check-images, offline, with throwaway ed25519 keys: an image list +# passes in its own namespace, signed by the release key, and as a KRYPTIK-IMAGES-1 alone. + +set -uo pipefail + +unset KRYPTIK_SOURCES KRYPTIK_WORK KRYPTIK_LOCK KRYPTIK_OUT KRYPTIK_ROOT + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +UPD="${ROOT}/tools/update/kryptik-update" + +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)); } + +command -v ssh-keygen >/dev/null 2>&1 || { echo "ssh-keygen required"; exit 1; } + +W="$(mktemp -d)" +trap 'rm -rf "$W"' EXIT + +for k in rel latest; do + ssh-keygen -q -t ed25519 -N '' -C "$k" -f "${W}/${k}" &2; exit 1; } +done +ANCHOR="${W}/anchor" +{ + printf 'kryptik-release namespaces="kryptik-release,kryptik-media,kryptik-image" %s\n' "$(cut -d' ' -f1,2 < "${W}/rel.pub")" + printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 < "${W}/latest.pub")" +} > "$ANCHOR" + +# The updater's own check, lifted out of the tool as release-manifest.sh lifts check-pointer. +{ + echo 'IMAGE_NAMESPACE=kryptik-image'; echo 'IMAGES_MAGIC=KRYPTIK-IMAGES-1' + echo "SIGNERS=${ANCHOR}" + echo 'say() { printf "%s\n" "$*"; }' + echo 'die() { printf "REFUSED: %s\n" "$*"; exit 1; }' + sed -n '/^verify_signed() {/,/^}/p' "$UPD" + sed -n '/^cmd_check_images() {/,/^}/p' "$UPD" + printf 'SNAP=%q\n' "${W}/snap"; echo 'mkdir -p "$SNAP"' + echo 'cmd_check_images "$1" && echo ACCEPTED' +} > "${W}/check-images.sh" +grep -q '^cmd_check_images() {' "${W}/check-images.sh" || { echo "FATAL: no cmd_check_images in ${UPD}" >&2; exit 1; } + +# A list in DIR signed with KEY in NAMESPACE, its first line FIRST. +list() { # list DIR KEY NAMESPACE [FIRST] + mkdir -p "$1" + printf '%s\nrelease: 1.2.0\nbase: %064d\nimage: probe 1 bytes=8192 sha256=%064d data-blocks=1 root-hash=%064d salt=%064d\n' \ + "${4:-KRYPTIK-IMAGES-1}" 0 0 0 0 > "$1/manifest" + ssh-keygen -Y sign -f "$2" -n "$3" "$1/manifest" /dev/null 2>&1 + [[ -s "$1/manifest.sig" ]] || { echo "FATAL: could not sign $1/manifest" >&2; exit 1; } +} +# Captured whole: under pipefail a refusal's exit 1 would fail a pipe to grep. +said() { bash "${W}/check-images.sh" "$1" 2>&1; } + +list "${W}/good" "${W}/rel" kryptik-image +out="$(said "${W}/good")" +[[ "$out" == *ACCEPTED* ]] && green "a list the release key signs in kryptik-image is accepted" \ + || red "a list signed as it should be was refused: ${out}" + +list "${W}/as-release" "${W}/rel" kryptik-release +out="$(said "${W}/as-release")" +[[ "$out" == *"REFUSED:"*"does NOT verify"* && "$out" != *ACCEPTED* ]] \ + && green "a list signed in kryptik-release is refused: a release manifest's signature does not pass for a list's" \ + || red "a list signed in kryptik-release was not refused: ${out}" + +list "${W}/by-latest" "${W}/latest" kryptik-image +out="$(said "${W}/by-latest")" +[[ "$out" == *"REFUSED:"*"does NOT verify"* && "$out" != *ACCEPTED* ]] \ + && green "a list the statement key signs is refused: the anchor gives kryptik-image to the release key alone" \ + || red "a list signed by the statement key was not refused: ${out}" + +list "${W}/manifest" "${W}/rel" kryptik-image KRYPTIK-MANIFEST-1 +out="$(said "${W}/manifest")" +[[ "$out" == *"REFUSED: the image list is not a KRYPTIK-IMAGES-1"* ]] \ + && green "a signed file that is not an image list is refused" \ + || red "a signed file of another kind was not refused as such: ${out}" + +# The same good list, under an anchor that grants the release key the older namespaces only. +sed -i 's/,kryptik-image"/"/' "$ANCHOR" +out="$(said "${W}/good")" +[[ "$out" == *"REFUSED:"* && "$out" != *ACCEPTED* ]] \ + && green "under an anchor without kryptik-image, the same list is refused" \ + || red "a list passed under an anchor that does not grant its namespace: ${out}" + +# kryptikd runs the verb as nobody; the tool refuses it as root, as it refuses the other checks. +if grep -q '^ check-manifest|check-pointer|check-release|check-images)$' "$UPD" \ + && grep -q '^ check-images) shift; cmd_check_images "\$@" ;;$' "$UPD"; then + green "check-images is one of the checks that never run as root, and the tool dispatches it" +else + red "check-images is missing from kryptik-update's checks or its dispatcher" +fi + +echo +if [[ "$FAIL" -gt 0 ]]; then + echo "${FAIL} of $((PASS + FAIL)) checks failed." + exit 1 +fi +echo "All ${PASS} checks passed." diff --git a/tools/tests/installer.sh b/tools/tests/installer.sh index 6039909c..983cdd57 100755 --- a/tools/tests/installer.sh +++ b/tools/tests/installer.sh @@ -191,6 +191,58 @@ else rm -f "$tmp" "$rec" fi +echo +echo "-- the medium's software images go through kryptikd before anything is written" +# A kryptikd that answers check-images as the medium in DIR has it: DIR/answer is "ok" or the refusal. +# What it checks is images.rs's (check_medium) and kryptik-update's (tools/tests/image-list.sh): an +# unsigned list, one signed in kryptik-release, a missing or short image. This is how the installer +# takes the answer, and where. +if ! declare -F medium_images_ok >/dev/null; then + red "could not read medium_images_ok from ${MEDIUM_ROOT}" +else + stub="$(mktemp -d)" + cat > "${stub}/kryptikd" <<'STUB' +#!/bin/sh +[ "$1" = check-images ] && [ "$#" -eq 4 ] || { echo "usage: kryptikd check-images DIR BASE OUT" >&2; exit 2; } +printf '%s\n' "$3" > "$2/base-asked" +a="$(cat "$2/answer")" +[ "$a" = ok ] && { echo checked > "$4/images.manifest"; echo "1 images of release 1.0.0 for this root"; exit 0; } +echo "kryptikd: check-images: $a" >&2 +exit 1 +STUB + chmod 0755 "${stub}/kryptikd" + med="$(mktemp -d)" + V_HASH="$(printf 'c%.0s' $(seq 64))" + echo ok > "${med}/answer" + chk="$(mktemp -d)" + ( PATH="${stub}:$PATH"; medium_images_ok "$med" "$chk" ) >/dev/null 2>&1 \ + && [[ "$(cat "${med}/base-asked" 2>/dev/null)" == "$V_HASH" && "$(cat "${chk}/images.manifest" 2>/dev/null)" == checked ]] \ + && green "a medium kryptikd accepts is taken, checked for the signed root, its checked list handed on" \ + || red "a medium kryptikd accepts was refused, checked for another root, or its list not handed on" + for why in "the medium's image list is refused: not signed as an image list" \ + "image browser: /media/images/browser.img holds 4096 bytes, and the list signs 12288"; do + echo "$why" > "${med}/answer" + out="$( (PATH="${stub}:$PATH"; medium_images_ok "$med" "$chk") 2>&1 )"; rc=$? + [[ "$rc" -ne 0 && "$out" == *"software images are refused: ${why}"* ]] \ + && green "a refusal stops the install with kryptikd's reason (${why%%:*})" \ + || red "a refusal was not passed on: rc=${rc} ${out}" + done + rm -rf "$stub" "$med" "$chk" +fi +grep -qF 'images_keep "$IMG_CHECKED/images.manifest" "$IMG_CHECKED/images.manifest.sig"' "$INSTALLER" \ + && green "the list kept is the one kryptikd checked, not the medium's read again" \ + || red "images_keep reads the list from the medium a second time" +if awk '/medium_images_ok "\$IMG_SRC" "\$IMG_CHECKED"/ && !c { c = NR } + /^sfdisk / && !w { w = NR } + END { exit !(c && w && c < w) }' "$INSTALLER"; then + green "the medium's images are checked before the partition table, the first write" +else + red "the medium's image check is not before the first write" +fi +grep -qE 'kryptik-efiboot kryptikd; do$' "$INSTALLER" \ + && green "kryptikd is among the tools checked up front" \ + || red "kryptikd is not among the tools checked up front" + echo echo "-- --slot-size is a number of MiB, no less than a slot needs" eval "$(sed -n '/^slot_size_ok()/,/^}/p' "$INSTALLER")" @@ -275,6 +327,15 @@ else fi grep -v '^ *#' "$INSTALLER" | grep -qw partx && red "the installer adds partitions with partx, which leaves them nameless" \ || green "the installer has no partx, whose partitions the kernel leaves nameless" +locked="$(grep -n '^flock -w [0-9]* -x 7 || die' "$INSTALLER" | cut -d: -f1)" +opened="$(grep -n '^exec 7<"\$TARGET_REAL"$' "$INSTALLER" | cut -d: -f1)" +[[ "$opened" =~ ^[0-9]+$ && "$locked" =~ ^[0-9]+$ && "$opened" -lt "$locked" && "$locked" -lt "$written" ]] \ + && green "the whole disk is locked before its table is written, so eudev's own re-read gives way" \ + || red "the disk is opened at line ${opened:-none} and locked at ${locked:-none}; the table is written from ${written:-none}" +# A tool that waited for the lock this process holds would never get it. +grep -v '^ *#' "$INSTALLER" | grep -qE -- '--lock|LOCK_BLOCK_DEVICE' \ + && red "a tool the installer runs asks for a lock on the disk it holds" \ + || green "no tool the installer runs asks for a lock on the disk it holds" grep -q '^kryptik_named kryptik-a | grep -qx "\$P2" || die' "$INSTALLER" \ && green "the written table is checked by the name the kernel gives, as the system will find it" \ || red "the installer does not check partition 2's name through kryptik_named" diff --git a/tools/tests/release-keys.sh b/tools/tests/release-keys.sh index 1c29b4e6..93b00992 100755 --- a/tools/tests/release-keys.sh +++ b/tools/tests/release-keys.sh @@ -72,7 +72,7 @@ make_medium() { # a complete key medium, as the ceremony leaves it ssh-keygen -q -t ed25519 -N '' -C 'kryptik-release (test)' -f "$M/kryptik-release" < /dev/null ssh-keygen -q -t ed25519 -N '' -C 'kryptik-latest (test)' -f "$M/kryptik-latest" < /dev/null { - printf 'kryptik-release namespaces="kryptik-release,kryptik-media" %s\n' "$(cut -d' ' -f1,2 < "$M/kryptik-release.pub")" + printf 'kryptik-release namespaces="kryptik-release,kryptik-media,kryptik-image" %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")" } > "$M/release-signers" openssl req -new -x509 -newkey rsa:2048 -nodes -days 30 -sha256 -subj "/CN=test Secure Boot key/" \ @@ -142,10 +142,12 @@ make_medium; sed -i 's/namespaces="kryptik-latest"/namespaces="kryptik-release"/ refused "an anchor that lets the statement key sign releases" "not held to its own namespace" make_medium; sed -i 's/namespaces="kryptik-latest"/namespaces="kryptik-latest,kryptik-media"/' "$M/release-signers" refused "an anchor that lets the statement key sign the media's checksums" "kryptik-latest is not held to its own namespaces" -make_medium; sed -i 's/namespaces="kryptik-release,kryptik-media"/namespaces="kryptik-release"/' "$M/release-signers" +make_medium; sed -i 's/namespaces="kryptik-release,kryptik-media,kryptik-image"/namespaces="kryptik-release"/' "$M/release-signers" refused "an anchor that leaves the media's checksums to no key" "kryptik-release is not held to its own namespaces" -make_medium; sed -i 's/namespaces="kryptik-release,kryptik-media"/namespaces="kryptik-release,kryptik-media,kryptik-latest"/' "$M/release-signers" +make_medium; sed -i 's/namespaces="kryptik-release,kryptik-media,kryptik-image"/namespaces="kryptik-release,kryptik-media,kryptik-image,kryptik-latest"/' "$M/release-signers" refused "an anchor that lets the release key sign statements" "kryptik-release is not held to its own namespaces" +make_medium; sed -i 's/namespaces="kryptik-release,kryptik-media,kryptik-image"/namespaces="kryptik-release,kryptik-media"/' "$M/release-signers" +refused "an anchor that leaves image lists to no key" "kryptik-release is not held to its own namespaces" 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" @@ -158,7 +160,7 @@ refused "a release key the anchor does not list" "kryptik-release.pub is not a k # While a key is replaced, the anchor lists the old one and the new one, and # the medium may hold either. make_medium; ssh-keygen -q -t ed25519 -N '' -C 'kryptik-release (next)' -f "${W}/next" < /dev/null -printf 'kryptik-release namespaces="kryptik-release,kryptik-media" %s\n' "$(cut -d' ' -f1,2 < "${W}/next.pub")" >> "$M/release-signers" +printf 'kryptik-release namespaces="kryptik-release,kryptik-media,kryptik-image" %s\n' "$(cut -d' ' -f1,2 < "${W}/next.pub")" >> "$M/release-signers" rm "$M/kryptik-release" "$M/kryptik-release.pub"; mv "${W}/next" "$M/kryptik-release"; mv "${W}/next.pub" "$M/kryptik-release.pub" keys production "$PW" "$M"; rc=$? if [[ "$rc" -eq 0 && "$(got RELEASE_KEY)" == "${M}/kryptik-release" ]]; then diff --git a/tools/tests/release-publish.sh b/tools/tests/release-publish.sh index 4982aabf..19567601 100755 --- a/tools/tests/release-publish.sh +++ b/tools/tests/release-publish.sh @@ -46,7 +46,7 @@ git -C "$T" tag v0.1.1 # --- the release key, and the anchor the image carries --------------------------- K="${W}/keys"; mkdir -p "$K" ssh-keygen -q -t ed25519 -N '' -C kryptik-release -f "${K}/kryptik-release" -printf 'kryptik-release namespaces="kryptik-release,kryptik-media" %s\n' "$(cut -d' ' -f1,2 "${K}/kryptik-release.pub")" > "${K}/release-signers" +printf 'kryptik-release namespaces="kryptik-release,kryptik-media,kryptik-image" %s\n' "$(cut -d' ' -f1,2 "${K}/kryptik-release.pub")" > "${K}/release-signers" # --- an export as make acceptance writes one -------------------------------------- make_export() { # make_export DIR VERSION ROLE VERDICT REVISION diff --git a/tools/tests/testctl.sh b/tools/tests/testctl.sh index 9fd6c8da..3eb37e07 100755 --- a/tools/tests/testctl.sh +++ b/tools/tests/testctl.sh @@ -21,7 +21,7 @@ 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-release namespaces="kryptik-release,kryptik-media,kryptik-image" %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" diff --git a/tools/tests/update-images.sh b/tools/tests/update-images.sh new file mode 100755 index 00000000..91468353 --- /dev/null +++ b/tools/tests/update-images.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# Tests for the image store (build/service-scripts/image-store.sh) and kryptik-update's staging of a +# payload's images, offline: images reach the store only for the root they come with and only as their +# list signs them, and the store keeps the lists of the roots in the two slots and the images they name. + +set -uo pipefail + +unset KRYPTIK_SOURCES KRYPTIK_WORK KRYPTIK_LOCK KRYPTIK_OUT KRYPTIK_ROOT + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +UPD="${ROOT}/tools/update/kryptik-update" +LIB="${ROOT}/build/service-scripts/image-store.sh" + +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)); } + +for t in zstd sha256sum; do + command -v "$t" >/dev/null 2>&1 || { echo "${t} required"; exit 1; } +done + +W="$(mktemp -d)" +trap 'rm -rf "$W"' EXIT +STORE="${W}/store" + +# The store's functions and apply's staging, lifted out as image-list.sh lifts check-images, with +# what apply has set by the time it calls them: the payload's path, its manifest's hashes and the copy. +{ + printf 'IMAGES=%q\n' "$STORE" + echo 'say() { printf "%s\n" "$*"; }' + echo 'die() { printf "REFUSED: %s\n" "$*"; exit 1; }' + echo 'uncache() { :; }' + echo 'payload_path() { echo "$dir/$1"; }' + echo 'manifest_hash() { sed -n '"'"'/^--$/,$p'"'"' "$m" | sed 1d | awk -v r="$1" '"'"'$3==r {print $1; exit}'"'"'; }' + sed -n '/^images_keep() {/,/^}/p' "$LIB" + sed -n '/^_images_fail() {/,/^}/p' "$LIB" + sed -n '/^images_prune() {/,/^}/p' "$LIB" + sed -n '/^stage_images() {/,/^}/p' "$UPD" + grep '^payload_image() ' "$UPD" +} > "${W}/lib.sh" +for fn in images_keep _images_fail images_prune stage_images payload_image; do + grep -q "^${fn}() " "${W}/lib.sh" || { echo "FATAL: ${fn} is missing from ${LIB} or ${UPD}" >&2; exit 1; } +done + +hex() { printf "%064d" 0 | tr 0 "$1"; } +R1="$(hex 1)"; R2="$(hex 2)"; R3="$(hex 3)" + +# A payload in DIR carrying the root BASE (what its list says) and an image NAME of CONTENT, the +# release manifest listing every file; the image's root hash is HASH. +payload() { # payload DIR BASE NAME CONTENT HASH + rm -rf "$1"; mkdir -p "$1" + printf '%s' "$4" > "${W}/raw" + zstd -q -f "${W}/raw" -o "$1/$3.img.zst" + printf 'KRYPTIK-IMAGES-1\nrelease: 1.2.0\nbase: %s\nimage: %s 1 bytes=%s sha256=%s data-blocks=1 root-hash=%s salt=%s\n' \ + "$2" "$3" "$(stat -c %s "${W}/raw")" "$(sha256sum "${W}/raw" | cut -c1-64)" "$5" "$(hex a)" > "$1/images.manifest" + printf 'a signature\n' > "$1/images.manifest.sig" + { + printf 'KRYPTIK-MANIFEST-1\n--\n' + for f in images.manifest images.manifest.sig "$3.img.zst"; do + printf '%s %s %s\n' "$(sha256sum "$1/$f" | cut -c1-64)" "$(stat -c %s "$1/$f")" "$f" + done + } > "$1/manifest" +} +# stage DIR ROOT-HASH: stage_images for that payload, its output and exit in OUT and RC. +stage() { + mkdir -p "${W}/snap" + OUT="$(dir="$1" m="$1/manifest" ROOT_HASH="$2" VERSION=1.2.0 SNAP="${W}/snap" bash -c ". '${W}/lib.sh'; stage_images" 2>&1)"; RC=$? +} + +payload "${W}/p1" "$R1" probe "the probe image" "$(hex b)" +stage "${W}/p1" "$R1" +if [[ "$RC" -eq 0 && "$(cat "${STORE}/probe/$(hex b).img")" == "the probe image" ]] \ + && cmp -s "${W}/p1/images.manifest" "${STORE}/${R1}.manifest" && [[ -f "${STORE}/${R1}.manifest.sig" ]]; then + green "a payload's image and its list are kept in the store, by root hash" +else + red "the payload's image was not kept as its list says (exit ${RC}): ${OUT}" +fi +stage "${W}/p1" "$R1" +[[ "$RC" -eq 0 && "$OUT" == *"image probe: already kept"* ]] && green "an image already kept is not written again" \ + || red "staging the same image twice did not find it kept: ${OUT}" + +stage "${W}/p1" "$R2" +[[ "$RC" -ne 0 && "$OUT" == *"REFUSED: the image list is for another root than the one it comes with"* ]] \ + && green "a list for another root than the payload carries is refused" || red "a list for another root was not refused: ${OUT}" + +payload "${W}/p2" "$R1" viewer "what the list signs" "$(hex c)" +printf 'something else' > "${W}/other" +zstd -q -f "${W}/other" -o "${W}/p2/viewer.img.zst" +# The release manifest still vouches for the file, as it would for a payload built wrong. +sed -i "s/^[0-9a-f]* [0-9]* viewer.img.zst\$/$(sha256sum "${W}/p2/viewer.img.zst" | cut -c1-64) $(stat -c %s "${W}/p2/viewer.img.zst") viewer.img.zst/" "${W}/p2/manifest" +stage "${W}/p2" "$R1" +if [[ "$RC" -ne 0 && "$OUT" == *"REFUSED: image viewer is not the bytes its list signs"* ]] \ + && [[ -z "$(find "$STORE" -name '*.new')" && ! -e "${STORE}/viewer/$(hex c).img" ]]; then + green "an image that does not decompress to its signed bytes is refused, and nothing of it is left" +else + red "a wrong image was kept or left behind (exit ${RC}): ${OUT}; $(find "$STORE" -name '*.new')" +fi + +payload "${W}/p3" "$R1" viewer "an image" "$(hex c)" +rm "${W}/p3/viewer.img.zst" +sed -i '/ viewer.img.zst$/d' "${W}/p3/manifest" +stage "${W}/p3" "$R1" +[[ "$RC" -ne 0 && "$OUT" == *"REFUSED: the list names image viewer, and the payload does not carry viewer.img.zst"* ]] \ + && green "a list naming an image the payload does not carry is refused" || red "a missing image was not refused: ${OUT}" + +payload "${W}/p4" "$R1" viewer "an image" "$(hex c)" +printf '\n' >> "${W}/p4/images.manifest" +stage "${W}/p4" "$R1" +[[ "$RC" -ne 0 && "$OUT" == *"REFUSED: the image list changed after the manifest was checked"* ]] \ + && green "a list that is not the one the release manifest hashed is refused" || red "a changed list was not refused: ${OUT}" + +payload "${W}/p5" "$R1" "../x" "an image" "$(hex c)" +stage "${W}/p5" "$R1" +[[ "$RC" -ne 0 && "$OUT" == *"REFUSED: the image list names '../x'"* ]] \ + && green "an image name that is not a plain name is refused" || red "a name with a path was not refused: ${OUT}" + +# The reader stops one byte past the signed size, so a file swapped after the manifest's check +# cannot decompress into the store without end. +head -c 300000 /dev/zero > "${W}/big" +zstd -q -f "${W}/big" -o "${W}/p1/huge.img.zst" +got="$(dir="${W}/p1" bash -c ". '${W}/lib.sh'; payload_image huge 1000" | wc -c)" +[[ "$got" -eq 1001 ]] && green "the payload's reader stops one byte past the size the list signs" \ + || red "the payload's reader gave ${got} bytes for an image signed as 1000" + +# A refusal partway takes the copies already made with it. +rm -rf "${W}/p6"; mkdir -p "${W}/p6" +printf 'first' > "${W}/raw-a"; printf 'second' > "${W}/raw-z" +zstd -q -f "${W}/raw-a" -o "${W}/p6/alpha.img.zst"; zstd -q -f "${W}/raw-z" -o "${W}/p6/zeta.img.zst" +{ + printf 'KRYPTIK-IMAGES-1\nrelease: 1.2.0\nbase: %s\n' "$R1" + printf 'image: alpha 1 bytes=5 sha256=%s data-blocks=1 root-hash=%s salt=%s\n' "$(sha256sum "${W}/raw-a" | cut -c1-64)" "$(hex 6)" "$(hex a)" + printf 'image: zeta 1 bytes=6 sha256=%s data-blocks=1 root-hash=%s salt=%s\n' "$(hex 0)" "$(hex 7)" "$(hex a)" +} > "${W}/p6/images.manifest" +printf 'a signature\n' > "${W}/p6/images.manifest.sig" +{ + printf 'KRYPTIK-MANIFEST-1\n--\n' + for f in images.manifest images.manifest.sig alpha.img.zst zeta.img.zst; do + printf '%s %s %s\n' "$(sha256sum "${W}/p6/$f" | cut -c1-64)" "$(stat -c %s "${W}/p6/$f")" "$f" + done +} > "${W}/p6/manifest" +stage "${W}/p6" "$R1" +if [[ "$RC" -ne 0 && "$OUT" == *"REFUSED: image zeta is not the bytes its list signs"* && "$OUT" == *"image alpha: kept and checked"* ]] \ + && [[ -z "$(find "$STORE" -name '*.new')" && ! -e "${STORE}/alpha/$(hex 6).img" ]]; then + green "a refusal partway removes the copies already made, and keeps none of them" +else + red "a refusal partway left copies behind (exit ${RC}): ${OUT}; $(find "$STORE" -name '*.new')" +fi + +# prune: three roots' lists, an image shared by two of them, and a leftover .new. +payload "${W}/q2" "$R2" probe "the probe image" "$(hex b)"; stage "${W}/q2" "$R2" +payload "${W}/q3" "$R3" viewer "only the third root's" "$(hex d)"; stage "${W}/q3" "$R3" +: > "${STORE}/probe/$(hex e).img.new" +( . "${W}/lib.sh"; images_prune "$STORE" "$R1" "$R2" ) +if [[ -f "${STORE}/${R1}.manifest" && -f "${STORE}/${R2}.manifest.sig" && ! -e "${STORE}/${R3}.manifest" && ! -e "${STORE}/${R3}.manifest.sig" ]] \ + && [[ -f "${STORE}/probe/$(hex b).img" && ! -e "${STORE}/viewer/$(hex d).img" && ! -e "${STORE}/probe/$(hex e).img.new" ]]; then + green "pruning keeps the two roots' lists and the images they name, and nothing else" +else + red "pruning kept or removed the wrong files: $(cd "$STORE" && find . -type f | sort | tr '\n' ' ')" +fi + +echo +if [[ "$FAIL" -gt 0 ]]; then + echo "${FAIL} of $((PASS + FAIL)) checks failed." + exit 1 +fi +echo "All ${PASS} checks passed." diff --git a/tools/tests/update-manifest-snapshot.sh b/tools/tests/update-manifest-snapshot.sh index 48edbf34..5e45cb8e 100755 --- a/tools/tests/update-manifest-snapshot.sh +++ b/tools/tests/update-manifest-snapshot.sh @@ -363,6 +363,44 @@ out="$(asroot refused verify_manifest "$T/signed" 0)" && ok "as root, kryptikd's refusal is apply's, in the checker's words" \ || bad "verify_manifest as root, refused: $(tail -2 <<<"$out" | tr '\n' ' ')" +# A software image the manifest lists is pinned as the root's files are: what stage_images +# decompresses after the check is the file that was hashed, whatever the directory holds by then. +# Three of them, as a release carries: with the update lock on fd 9, the third pin must not reopen 9. +# The call's output is redirected, so bash holds its own copy of it on 10 meanwhile, close-on-exec: +# a pin there would be one stat and zstd cannot read. +mkpayload pinned 2 +for i in browser probe toolkit; do printf 'the signed %s\n' "$i" | zstd -q -c > "pinned/$i.img.zst"; done +{ sed -n '1,/^--$/p' pinned/manifest | sed 's/^files: 4$/files: 7/' + sed -n '/^--$/,$p' pinned/manifest | sed 1d + for i in browser probe toolkit; do + printf '%s %s %s\n' "$(sha256sum "pinned/$i.img.zst" | cut -c1-64)" "$(stat -c %s "pinned/$i.img.zst")" "$i.img.zst" + done +} > pinned/manifest.new && mv pinned/manifest.new pinned/manifest +ssh-keygen -Y sign -f key -n kryptik-release pinned/manifest >/dev/null 2>&1 +mkdir -p "$T/snap-pinned" +cat > "$T/case-pinned.sh" <$T/update.lock +flock -n 9 && echo LOCKED +verify_payload $T/pinned 0 > /dev/null && echo VERIFIED +[ "\$(readlink /proc/self/fd/9)" = "$T/update.lock" ] && echo LOCK-KEPT +printf 'swapped in after the check\n' | zstd -q -c > $T/pinned/browser.img.zst.new +mv $T/pinned/browser.img.zst.new $T/pinned/browser.img.zst +zstd -dcq < "\$(payload_path browser.img.zst)" +EOF +out="$(bash "$T/case-pinned.sh" 2>&1)" +if [[ "$out" == *VERIFIED* && "$out" == *"the signed browser"* && "$out" != *swapped* ]]; then + ok "an image the manifest lists is read through the descriptor it was hashed on, not by its name" +else + bad "an image swapped after the check was read, or the payload did not verify: $(tr '\n' ' ' <<<"$out")" +fi +if [[ "$out" == *LOCKED* && "$out" == *LOCK-KEPT* ]]; then + ok "three pinned images leave the update lock's descriptor where it was" +else + bad "pinning the images reopened the update lock's descriptor: $(tr '\n' ' ' <<<"$out")" +fi + # The tool itself: the checks need neither root nor this system's disks. if [[ "$(id -u)" != 0 ]]; then out="$(sh "$TOOL" check-pointer "$T/ptr/latest" "$T/ptr/latest.sig" 2>&1)" diff --git a/tools/tests/verify-signatures.sh b/tools/tests/verify-signatures.sh index cdb85869..434f0aa7 100755 --- a/tools/tests/verify-signatures.sh +++ b/tools/tests/verify-signatures.sh @@ -502,6 +502,23 @@ fresh_root; run expect_fail "a file the signed list does not name fails" \ "does not match a digest in summed-SHA-256.txt" +# Like Node.js's SHASUMS256.txt: every release's list has the same name, so the one cached can be an +# earlier release's, and its signature with it. +for r in first second; do printf 'fixture payload for %s\n' "$r" > "${SRC}/${r}.tar.gz"; done +list_release() { # list_release NAME: upstream's list names NAME's file alone, signed afresh + sha256sum "${SRC}/$1.tar.gz" | sed "s# .*# $1.tar.gz#" > "${SRC}/SHASUMS256.txt" + fixgpg --yes --local-user good@example.test \ + --detach-sign -o "${SRC}/SHASUMS256.txt.sig" "${SRC}/SHASUMS256.txt" >/dev/null 2>&1 +} +list_release first +: > "${W}/manifest"; add_row first sums:SHASUMS256.txt.sig +fresh_root; run --strict +expect_pass "a list named alike for every release verifies" "verified: 1" +list_release second +: > "${W}/manifest"; add_row second sums:SHASUMS256.txt.sig +fresh_root; run --strict +expect_pass "an earlier release's cached list is fetched again, with its signature" "verified: 1" + # --- unaudited imported keys ------------------------------------------------ write_manifest unknown @@ -974,6 +991,11 @@ bad_prov "${UNKFPR} savannah https://savannah.example/project/release-gpgkeys. "a savannah locator on another host is refused" bad_prov "${UNKFPR} savannah https://savannah.gnu.org/project/memberlist.php?group=x 2026-09-11 unknown x" \ "a savannah locator that is not the project's release keyring is refused" +# An origin row says where a pinned key's material comes from, and nothing about who signs. +bad_prov "${UNKFPR} origin https://example.test/KEY 2026-10-13 unknown unknown fixture " \ + "an origin row for a key no pin names is refused" +bad_prov "14F26682D0916CDD81E37B6D61B7B526D98F0353 origin http://archive.mozilla.org/KEY 2026-10-13 firefox Mozilla" \ + "an origin locator that is not https is refused" # Shipped github rows need both the endpoint shape and the release-author tie. gh_bad=0 diff --git a/tools/throwaway-key-medium.sh b/tools/throwaway-key-medium.sh index d86f3b5a..84212724 100755 --- a/tools/throwaway-key-medium.sh +++ b/tools/throwaway-key-medium.sh @@ -19,7 +19,7 @@ ssh-keygen -q -t ed25519 -N '' -C kryptik-release -f "${m}/kryptik-release" < /d 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-release namespaces="kryptik-release,kryptik-media,kryptik-image" %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" diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index 4a2f8341..d1fd9789 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -9,13 +9,15 @@ # kryptik-update check-manifest DIR verify only: signature, role, version # kryptik-update check-pointer FILE SIG verify only: the pointer's signature # kryptik-update check-release DIR verify only: signature, role; any version +# kryptik-update check-images DIR verify only: an image list's signature # # --retry arm again a slot whose last trial failed # --recovery accept an older signed release (a downgrade) # # DIR holds manifest, manifest.sig, kryptik-root.img.zst (the root image, # compressed for the trip), kryptik-a.efi, kryptik-b.efi and root.json, and -# nothing else. +# the release's software images if it has any (images.manifest, its .sig and +# NAME.img.zst), and nothing else. set -eu PROG=kryptik-update @@ -26,6 +28,8 @@ NAMESPACE=kryptik-release MAGIC=KRYPTIK-MANIFEST-1 LATEST_NAMESPACE=kryptik-latest LATEST_MAGIC=KRYPTIK-LATEST-1 +IMAGE_NAMESPACE=kryptik-image +IMAGES_MAGIC=KRYPTIK-IMAGES-1 # On the verified root: never under /etc, which the state partition can shadow. SIGNERS=/usr/share/kryptik/trust/release-signers ROLE_FILE=/usr/share/kryptik/trust/required-role @@ -34,6 +38,8 @@ B=/var/lib/kryptik/boot ESP_MNT=/run/kryptik/update-esp LOCK=/run/kryptik/update.lock LOG=/var/log/kryptik/update.log +# Software images, by the root they are built for (docs/design/software-delivery.md). +IMAGES=/var/lib/kryptik/images # Where the manifest is copied. Never taken from the environment, since the # exit trap removes it; a variable only so the offline suite can set it. SNAP="" @@ -42,7 +48,7 @@ SNAP="" # outside their own directory. They read what the net zone sent, so they do not # run as root: kryptikd runs them as nobody, on copies. case "${1:-}" in - check-manifest|check-pointer|check-release) + check-manifest|check-pointer|check-release|check-images) [ "$(id -u)" != 0 ] || die "$1 does not run as root; kryptikd runs it as nobody" tools="ssh-keygen sha256sum cp awk sed sort head mktemp" ;; *) @@ -56,13 +62,14 @@ done # Only partitions on the root's own disk; an ambiguous label is refused, never # resolved by taking the first (devices.sh). case "${1:-}" in - check-manifest|check-pointer|check-release) ;; + check-manifest|check-pointer|check-release|check-images) ;; *) # shellcheck source=/dev/null . /usr/libexec/kryptik/devices.sh # The ESP's records, shown only in the shape Kryptik writes them, and their writers. # shellcheck source=/dev/null - . /usr/libexec/kryptik/esp-records.sh ;; + . /usr/libexec/kryptik/esp-records.sh + . /usr/libexec/kryptik/image-store.sh ;; esac running_slot() { sed -n 's/^slot=//p' /run/kryptik/boot-identity 2>/dev/null; } @@ -121,17 +128,22 @@ cmd_status() { # The release's four files are opened once and read only through those # descriptors, so what is hashed is what is written even if the directory # changes. Links are refused, as is a name replaced while being opened. -pin() { # pin FD NAME +pin() { # pin FD NAME, or pin - NAME for a descriptor bash picks; either is left in fd f="$dir/$2" { [ -f "$f" ] && [ ! -L "$f" ]; } || die "payload lacks $2, or it is not a regular file" - eval "exec $1< \"\$f\"" || die "cannot open $2" - [ "$(stat -c %d:%i "$f")" = "$(stat -L -c %d:%i "/proc/self/fd/$1")" ] || die "$2 changed while it was being opened" + if [ "$1" = - ]; then eval 'exec {fd}< "$f"'; else fd="$1"; eval "exec $fd< \"\$f\""; fi || die "cannot open $2" + [ "$(stat -c %d:%i "$f")" = "$(stat -L -c %d:%i "/proc/self/fd/$fd")" ] || die "$2 changed while it was being opened" } payload_path() { # NAME -> where to read it case "$1" in kryptik-root.img.zst) echo /proc/self/fd/3 ;; kryptik-a.efi) echo /proc/self/fd/4 ;; kryptik-b.efi) echo /proc/self/fd/5 ;; root.json) echo /proc/self/fd/6 ;; - *) echo "$dir/$1" ;; + *) + # An image verify_payload pinned (NAME=FD in PINNED), else the payload's file. + case " ${PINNED:-} " in + *" $1="*) _fd="${PINNED#* $1=}"; echo "/proc/self/fd/${_fd%% *}" ;; + *) echo "$dir/$1" ;; + esac ;; esac } @@ -139,6 +151,16 @@ payload_path() { # NAME -> where to read it verify_payload() { # verify_payload DIR RECOVERY -> sets VERSION ROOT_HASH verify_manifest "$1" "$2" pin 3 kryptik-root.img.zst; pin 4 kryptik-a.efi; pin 5 kryptik-b.efi; pin 6 root.json + # And each software image it lists, so the bytes hashed below are those stage_images + # decompresses. A name with a directory in it is refused below, before anything is read. + # bash (sh here) picks each descriptor: from 10 up, past the root's and the update lock's, + # and never one it holds itself, as it holds a redirected call's own output while it runs. + PINNED="" + for n in $(sed -n '/^--$/,$p' "$m" | sed '1d' | awk '$3 ~ /\.img\.zst$/ && $3 != "kryptik-root.img.zst" { print $3 }'); do + case "$n" in */*|..*) continue ;; esac + pin - "$n" + PINNED="$PINNED $n=$fd" + done # Nothing unlisted (a directory listing) before hashing every listed file # (gigabytes). Names are compared whole, as text, dotfiles included. @@ -299,6 +321,19 @@ cmd_check_pointer() { verify_signed "$SNAP/latest" "$SNAP/latest.sig" "$LATEST_NAMESPACE" pointer } +# Exit 0 if a copy of DIR's image list is signed by an enrolled key in the +# image namespace. What it lists, and whether it fits the running root, +# kryptikd judges (images.rs). +cmd_check_images() { + [ $# = 1 ] && [ -d "$1" ] || die "check-images needs the directory holding manifest and manifest.sig" + SNAP="${SNAP:-$(mktemp -d /run/kryptik/update-manifest.XXXXXX 2>/dev/null || mktemp -d)}" + chmod 0700 "$SNAP" + cp -- "$1/manifest" "$SNAP/manifest" || die "cannot copy the image list" + cp -- "$1/manifest.sig" "$SNAP/manifest.sig" || die "cannot copy the image list's signature" + [ "$(head -1 "$SNAP/manifest")" = "$IMAGES_MAGIC" ] || die "the image list is not a $IMAGES_MAGIC" + verify_signed "$SNAP/manifest" "$SNAP/manifest.sig" "$IMAGE_NAMESPACE" "image list" +} + # For the clock's floor (docs/design/time.md): the manifest a slot was applied # from, kept on the state partition, believed only as apply would believe it, # in any version order, and the created date its signed manifest names. @@ -330,6 +365,29 @@ unlist_slot() { # unlist_slot SLOT umount_esp } +# The payload's software images, before any slot is written: a copy of its +# list, read once and checked against the manifest (the payload directory is +# mutable), and each image kept in the store as the list signs it. +stage_images() { + [ -n "$(manifest_hash images.manifest)" ] || return 0 + [ -n "$(manifest_hash images.manifest.sig)" ] || die "the payload lists images.manifest without its signature" + l="$SNAP/images.manifest"; lsig="$SNAP/images.manifest.sig" + cp -- "$(payload_path images.manifest)" "$l" && cp -- "$(payload_path images.manifest.sig)" "$lsig" \ + || die "cannot copy the image list" + [ "$(sha256sum "$l" | cut -c1-64)" = "$(manifest_hash images.manifest)" ] \ + && [ "$(sha256sum "$lsig" | cut -c1-64)" = "$(manifest_hash images.manifest.sig)" ] \ + || die "the image list changed after the manifest was checked" + for n in $(sed -n 's/^image: \([^ ]*\) .*/\1/p' "$l"); do + [ -n "$(manifest_hash "$n.img.zst")" ] || die "the list names image $n, and the payload does not carry $n.img.zst" + done + images_keep "$l" "$lsig" "$IMAGES" "$ROOT_HASH" payload_image + say "the image list for $VERSION kept" +} + +# Image NAME as the payload carries it, decompressed, and never past the size its list signs and one +# byte: the file is read again after the manifest's check, and a changed one must not fill /var. +payload_image() { zstd -dcq < "$(payload_path "$1.img.zst")" 2>/dev/null | head -c "$(( $2 + 1 ))"; } # payload_image NAME BYTES + # Verify everything, then write only the inactive slot, read it back, install # its kernel and arm BootNext. BOOTX64.EFI is left to boot-success, which # commits once the new slot has come up. Zone data is never written. @@ -366,6 +424,7 @@ cmd_apply() { [ -b "$tdev" ] || die "kryptik-$target ($tdev) is not a block device" tsize="$(blockdev --getsize64 "$tdev")" [ "$tsize" -ge "$ROOT_BYTES" ] || die "slot $target is $tsize bytes; the root image needs $ROOT_BYTES" + stage_images # --- writes begin: the inactive slot only ------------------------------- unlist_slot "$target" @@ -402,6 +461,10 @@ cmd_apply() { # --- arm one trial boot -------------------------------------------------- arm_trial "$target" + # The running root's images stay for a rollback; those of the release this slot held go. + # A command line naming no root hash prunes nothing, rather than the running root's list. + running_hash="$(sed -n 's/.*"kroot,[^"]* sha256 \([0-9a-f]\{64\}\) .*/\1/p' /proc/cmdline)" + if [ -n "$running_hash" ]; then images_prune "$IMAGES" "$running_hash" "$ROOT_HASH"; fi printf '%s apply %s -> slot %s\n' "$(date -Iseconds 2>/dev/null)" "$VERSION" "$target" >> "$LOG" say "armed: the next boot tries slot $target ($VERSION) once. Reboot to try it." say "if it does not come up, the firmware falls back to the committed slot ($slot)." @@ -447,5 +510,6 @@ case "${1:-}" in check-manifest) shift; cmd_check_manifest "$@" ;; check-pointer) shift; cmd_check_pointer "$@" ;; check-release) shift; cmd_check_release "$@" ;; - *) sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;; + check-images) shift; cmd_check_images "$@" ;; + *) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;; esac diff --git a/tools/verify-provenance.sh b/tools/verify-provenance.sh index cd2c2ab0..10d83be6 100755 --- a/tools/verify-provenance.sh +++ b/tools/verify-provenance.sh @@ -72,10 +72,7 @@ fail() { err "[$1] $2"; FAIL_N=$((FAIL_N+1)); FAIL_LIST+=("[ unavail() { warn "[$1] UNVERIFIED: $2"; UNAVAIL_N=$((UNAVAIL_N+1)); UNAVAIL_LIST+=("[$1] $2"); report "$1" unverified "$2"; } prereq() { warn "[$1] CANNOT CHECK: $2"; PREREQ_N=$((PREREQ_N+1)); PREREQ_LIST+=("[$1] $2"); report "$1" uncheckable "$2"; } -lock_hash_for() { - [[ -f "$KRYPTIK_LOCK" ]] || return 1 - awk -v f="$1" '$2 == f { print $1; found=1 } END { exit !found }' "$KRYPTIK_LOCK" -} +lock_hash_for() { lock_entry "$1"; } # http_get URL DEST: curl's exit status decides; HTTP_CODE only tells a 404 from no answer. http_get() { @@ -236,10 +233,11 @@ verify_hm_tree() { fi } -# verify_published_sha256 NAME URL SUFFIX: a publisher's checksum, over the same TLS as the tarball. +# verify_published_sha256 NAME URL SUFFIX [SUM-URL]: a publisher's checksum, over the same TLS as the +# tarball, at URL+SUFFIX unless SUM-URL says where. verify_published_sha256() { local name="$1" url="$2" suffix="$3" - local sum="${url}${suffix}" + local sum="${4:-${url}${suffix}}" RSRC="$name" local file; file="$(basename "$url")" local body="${WORK}/${file}${suffix}" @@ -282,8 +280,11 @@ verify_published_sha256() { return fi + # The line naming the file where the list names files (GNOME's lists the news beside it), else + # the first digest. local expected - expected="$(awk 'NF{print $1; exit}' "$body" 2>/dev/null || true)" + expected="$(awk -v f="$file" 'NF >= 2 && ($2 == f || $2 == "*" f) { print $1; exit }' "$body" 2>/dev/null || true)" + [[ -n "$expected" ]] || expected="$(awk 'NF{print $1; exit}' "$body" 2>/dev/null || true)" if [[ ! "$expected" =~ ^[0-9a-fA-F]{64}$ ]]; then fail pub "${name}: ${sum} is not a sha256 digest (got $(head -c 80 "$body" | tr -d '\n' || true))" return @@ -312,7 +313,7 @@ MANIFEST="${WORK}/manifest" "${KRYPTIK_ROOT}/tools/fetch-sources.sh" --list > "$MANIFEST" \ || die "tools/fetch-sources.sh --list failed, so there is nothing to check against." mapfile -t TAG_ROWS < <(awk '$4 == "tag"' "$MANIFEST") -mapfile -t SUM_ROWS < <(awk '$4 == "sha256" || $4 == "sha256.txt"' "$MANIFEST") +mapfile -t SUM_ROWS < <(awk '$4 == "sha256" || $4 == "sha256.txt" || $4 == "sha256sum" || $4 == "stem.sha256sum"' "$MANIFEST") log "Signed tags" for row in "${TAG_ROWS[@]}"; do @@ -338,7 +339,10 @@ echo log "Publisher checksums" for row in "${SUM_ROWS[@]}"; do read -r name _ url sig _ <<< "$row" - verify_published_sha256 "$name" "$url" ".${sig}" + case "$sig" in + stem.sha256sum) verify_published_sha256 "$name" "$url" ".sha256sum" "${url%.tar.*}.sha256sum" ;; + *) verify_published_sha256 "$name" "$url" ".${sig}" ;; + esac done echo diff --git a/tools/verify-signatures.sh b/tools/verify-signatures.sh index 5799763f..55110f73 100755 --- a/tools/verify-signatures.sh +++ b/tools/verify-signatures.sh @@ -265,6 +265,27 @@ PINNED_FPRS=( # https://www.alsa-project.org/wiki/Download (retrieved 2026-10-13) states this fingerprint # and links the key, https://www.alsa-project.org/files/pub/gpg-release-key-v1.txt. "F04DF50737AC1A884C4B3D718380596DA6E59C91" # ALSA Release Team, alsa-lib + + # The software images' sources (build/images/versions.env). + # https://github.com/PCRE2Project/pcre2/blob/main/SECURITY.md (retrieved 2026-10-13) names it for + # 10.45 on, cross-signed by Philip Hazel's earlier key. It signs with a subkey GitHub's copy lacks. + "A95536204A3BB489715231282A98E77EB6F24CA8" # Nicholas Wilson, pcre2 + # cairographics.org names the signer, not the key; https://gitlab.gnome.org/ebassi.gpg serves this + # one under his GNOME account (retrieved 2026-10-13). It signs the checksum lists since 1.18.0. + "53EF3DC3B63E2899271BD26322E8091EEA11BBB7" # Emmanuele Bassi, cairo + # FreeType publishes only the long id BE6C3AAC63AD8E3F: docs/release in its repository since + # 2.12.0, and each release announcement. This key ends in it and has signed every release since. + "E30674707856409FF1948010BE6C3AAC63AD8E3F" # Werner Lemberg, freetype (long id alone: weaker) + # Mozilla's security blog of 2026-08-10 states it with its new signing subkey 827E6586...76767AA3, + # as does the KEY file beside each release. key-provenance.tsv takes the key from that file. + "14F26682D0916CDD81E37B6D61B7B526D98F0353" # Mozilla Software Releases, firefox + # https://static.rust-lang.org/rust-key.gpg.ascii (retrieved 2026-10-13), beside the releases. + "108F66205EAEB0AAA8DD5E1C85AB96E6FA1BE5FE" # Rust Language, rustc, cargo and rust-std + # https://releases.llvm.org/release-keys.asc (retrieved 2026-10-13), the release managers' keys. + "D574BD5D1D0E98895E3BF90044F2485E45D59042" # Tobias Hieta, LLVM's release builds + # https://github.com/nodejs/node#release-keys (retrieved 2026-10-15) lists the releasers' keys, which + # its release-keys repository serves. Each release is signed by one releaser; this one signs 24.22.0. + "C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C" # Richard Lau, Node.js ) # Primaries and subkeys: GOODSIG names a subkey, while pins and keys.manifest hold primaries. @@ -347,6 +368,16 @@ load_key_provenance() { fi ;; wkd) [[ "$l" == *@*.* ]] || why="a wkd locator must be an email address" ;; + origin) + if [[ "$l" == https://* ]] || [[ "${KRYPTIK_SIGCHECK_SELFTEST:-0}" == "1" && "$l" == file://* ]]; then + : + else + why="an origin locator must be an https URL" + fi + # It says where the key is taken from, never who signs: the pin says that. + [[ " ${PINNED_FPRS[*]} " == *" ${f} "* ]] \ + || why="an origin row is for a key pinned in PINNED_FPRS" + ;; *) why="unknown kind '${k}'" ;; esac fi @@ -413,7 +444,7 @@ import_provenance_keys_for() { tmp="$(mktemp)" case "${PROV_KIND[$i]}" in - korg|github|savannah) + korg|github|savannah|origin) if ! curl -fsSL --max-time 30 --retry 2 --retry-delay 5 -o "$tmp" "${PROV_LOC[$i]}" 2>/dev/null; then warn "${name}: could not fetch the published key from ${PROV_LOC[$i]}" PROV_MISSED[$i]=1 @@ -781,6 +812,11 @@ verify_sums() { local listname="${signame%.*}" local list="${SIGDIR}/${listname}" want got + # A list named alike for every release (Node.js's SHASUMS256.txt) may be cached from an earlier + # one: if it does not name FILE, it and its signature are fetched again. + if [[ -s "$list" && -z "$(listed_digest "$list" "$file")" ]]; then + rm -f "$list" "${SIGDIR}/${signame}" + fi if [[ ! -s "$list" ]] && ! quiet_fetch "${url%/*}/${listname}" "$list"; then rm -f "$list" warn "${name}: no ${listname} published upstream" @@ -862,7 +898,7 @@ while read -r name _ver url sig _; do # A kind this script does not know fails: skipping it would pass the row. case "$sig" in - gnu|kernel|sig|asc|stem.sig|sums:?*|probe|sha256|sha256.txt|tag|none) ;; + gnu|kernel|sig|asc|stem.sig|sums:?*|probe|sha256|sha256.txt|sha256sum|stem.sha256sum|tag|none) ;; *) err "${name}: the manifest declares no signature kind this script knows ('${sig}')" FAILED=$((FAILED + 1)); FAILED_LIST+=("${name} (unknown signature kind '${sig}')") @@ -887,8 +923,8 @@ while read -r name _ver url sig _; do stem.sig) verify_detached "$name" "${KRYPTIK_SOURCES}/${file}" "${url%.tar.*}.sig" ;; sums:*) verify_sums "$name" "$url" "$file" "${sig#sums:}" ;; probe) verify_any "$name" "$url" "$file" ;; - sha256|sha256.txt|tag) - what="the publisher's .${sig}" + sha256|sha256.txt|sha256sum|stem.sha256sum|tag) + what="the publisher's .${sig#stem.}" [[ "$sig" == tag ]] && what="the signed tag" warn "${name}: no OpenPGP signature upstream; ${what} is verify-provenance's" mark_unsigned "${name} (${what}, see verify-provenance)"