diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d4b340..347f690 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: name: Commit identity runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: # The whole history, because the check is over the whole history. fetch-depth: 0 @@ -50,7 +50,7 @@ jobs: name: Shell lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - name: Install shellcheck run: sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck @@ -82,7 +82,7 @@ jobs: # nothing here reports more than it has established. fail=0 skipped="" - for t in tools/test-*.sh; do + for t in tools/test-*.sh tools/test-*.py; do echo "=== $t" # The step's shell is `bash -e`: a bare non-zero exit ends the # step before rc is read, so the first suite that answered 77 took @@ -104,13 +104,14 @@ jobs: exit $fail - name: Shellcheck - # tools/vm/ and the launcher suite were outside this list until now, - # which left the two largest scripts in the tree ungated - including the - # one that decides whether a boot passed. Three real warnings came out - # of adding them. + # Every tracked file that begins with a shell's name, not a list of + # places. The list left out the boot services, the installer, the + # updater, the net zone and every installed-system suite. run: | - shellcheck -S warning -x build/lib/common.sh build/stages/*.sh \ - tools/*.sh tools/vm/*.sh compartments/tests/*.sh + mapfile -t scripts < <(git ls-files | xargs awk 'FNR==1 { if ($0 ~ /^#! ?\/(usr\/)?bin\/(env +)?(ba|da)?sh/) print FILENAME; nextfile }') + echo "${#scripts[@]} shell scripts" + [ "${#scripts[@]}" -ge 100 ] || { echo "the search for scripts found too few: it is broken"; exit 1; } + shellcheck -S warning -x "${scripts[@]}" - name: Executable bits are recorded run: | @@ -121,7 +122,8 @@ jobs: # was committed 100644 - so git skipped it silently on every commit # and the hook has never once run. fail=0 - for f in build/stages/*.sh tools/*.sh tools/vm/*.sh \ + for f in build/stages/*.sh tools/*.sh tools/test-*.py tools/vm/*.sh \ + tools/image/*.sh tools/image/*.py \ compartments/tests/*.sh tools/git-hooks/*; do mode=$(git ls-files -s "$f" | awk '{print $1}') if [ "$mode" != "100755" ]; then @@ -143,7 +145,7 @@ jobs: name: Source manifest runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - name: Manifest resolves run: ./tools/fetch-sources.sh --list @@ -164,7 +166,7 @@ jobs: # is missing, and the prune drops what the manifest no longer names. - name: Upstream sources (cached by sources.lock) id: sources - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: sources key: sources-${{ hashFiles('sources.lock') }} @@ -271,7 +273,7 @@ jobs: name: Compartment layer runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - name: kryptikd unit tests run: cd compartments/kryptikd && cargo test @@ -451,7 +453,7 @@ jobs: name: Kernel currency runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 # Every pinned series with a published support window must still be # inside it. OpenSSL 3.3 went out of support on 2026-04-09 and Kryptik @@ -459,6 +461,21 @@ jobs: - name: Pinned series are supported upstream run: ./tools/check-support-status.sh --strict + # Survey (network) then gate (no network). On a pull request the verdict + # is printed and does not fail the check: an upstream that released this + # morning is not the pull request's fault. The weekly run is the alarm. + - name: Pins behind upstream are reviewed + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ./tools/check-source-currency.sh --tsv > pin-survey.tsv + echo "survey: $(wc -l < pin-survey.tsv) sources" + if [ "${{ github.event_name }}" = "pull_request" ]; then + ./tools/check-pin-reviews.sh --survey pin-survey.tsv || echo "pull request: informational" + else + ./tools/check-pin-reviews.sh --survey pin-survey.tsv + fi + - name: Pinned kernel is longterm and not EOL run: ./tools/check-kernel-eol.sh diff --git a/.github/workflows/distro.yml b/.github/workflows/distro.yml index b922dc4..1a2f47d 100644 --- a/.github/workflows/distro.yml +++ b/.github/workflows/distro.yml @@ -45,6 +45,7 @@ env: # the cache below may hold a sysroot from an earlier version of a stage. KRYPTIK_STALE: rebuild NO_COLOR: "1" + RUST_TOOLCHAIN: "1.98.1" # What 00-host-check and the stages need on the host, every job alike. HOST_PACKAGES: >- build-essential bison flex texinfo gawk m4 patch perl python3 python3-pip @@ -58,7 +59,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 360 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - name: Working directories on the large disk run: | @@ -85,7 +86,7 @@ jobs: - name: Upstream sources (cached by sources.lock) id: sources - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: /mnt/kryptik/sources key: sources-${{ hashFiles('sources.lock') }} @@ -105,7 +106,7 @@ jobs: # own fingerprints); a partial hit on the prefix resumes where an # earlier run stopped, which is what makes a six-hour limit survivable. - name: Stage 01-02 sysroot (cached) - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: | /mnt/kryptik/work/sysroot @@ -135,7 +136,7 @@ jobs: tar --zstd -cf work-after-02.tar.zst -C work "${members[@]}" ls -la work-after-02.tar.zst - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: work-after-02 path: /mnt/kryptik/work-after-02.tar.zst @@ -144,7 +145,7 @@ jobs: - name: Stage logs if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: logs-toolchain path: /mnt/kryptik/work/logs @@ -157,7 +158,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 360 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - name: Working directories on the large disk run: | @@ -183,7 +184,7 @@ jobs: - name: Upstream sources (cached by sources.lock) id: sources - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: /mnt/kryptik/sources key: sources-${{ hashFiles('sources.lock') }} @@ -198,7 +199,7 @@ jobs: make sources ./tools/prune-sources.sh - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: work-after-02 path: /mnt/kryptik @@ -226,7 +227,7 @@ jobs: # producing the same sysroot as the run before them. - name: Stage 04-05 sysroot and kernel (cached) id: work05 - uses: actions/cache/restore@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: /mnt/kryptik/work-after-05.tar.zst key: work05-${{ hashFiles('build/stages/00-host-check.sh', 'build/stages/01-toolchain.sh', 'build/stages/02-temp-tools.sh', 'build/lib/**', 'build/patches/**', 'build/config/**', 'sources.lock') }}-${{ hashFiles('build/**', 'tools/**', 'compartments/**', 'compositor/**', 'sources.lock', 'Makefile') }} @@ -245,12 +246,18 @@ jobs: # The two static binaries stage 04 installs are built outside the # chroot, for musl, exactly as the build host did. - - uses: dtolnay/rust-toolchain@stable + # The compiler of the two binaries that ship is named, not 'stable', and + # the build step below refuses any other. rustup checks what it downloads + # against the channel's own manifest; a hash pinned in this repository, + # as cmake's is, is what the roadmap still asks for. + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # the stable branch, 2026-09-20 with: + toolchain: ${{ env.RUST_TOOLCHAIN }} targets: x86_64-unknown-linux-musl - name: kryptikd and kryptik-wlproxy, static run: | + rustc --version | grep -qF "rustc ${RUST_TOOLCHAIN} " || { echo "refusing to build what ships with $(rustc --version); the pin is ${RUST_TOOLCHAIN}"; exit 1; } (cd compartments/kryptikd && cargo build --locked --release --target x86_64-unknown-linux-musl) (cd compositor && cargo build --locked --release --target x86_64-unknown-linux-musl -p wlproxy --bin kryptik-wlproxy) cp /mnt/kryptik/cargo/x86_64-unknown-linux-musl/release/kryptikd /mnt/kryptik/kryptikd-musl @@ -285,7 +292,7 @@ jobs: # A cache that could not be saved (the repository's 10 GB is full, or # the service is having a day) costs the next run an hour, not this one # its verdict. - - uses: actions/cache/save@v5 + - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 if: steps.work05.outputs.cache-hit != 'true' continue-on-error: true with: @@ -316,12 +323,12 @@ jobs: # Never the private halves of the keys: the acceptance job needs the # certificate, its DER form and the variable stores, nothing that # signs. An artifact of a public repository is public. - sudo tar --zstd -cf work-after-media.tar.zst -C work --exclude='images/kryptik-root.img' --exclude='images/esp-*.img' --exclude='keys/sb/kryptik-sb.key' --exclude='keys/release/kryptik-release' . - if sudo tar --zstd -tf work-after-media.tar.zst | grep -E 'kryptik-sb\.key$|keys/release/kryptik-release$'; then echo "a private key is in the artifact"; exit 1; fi + sudo tar --zstd -cf work-after-media.tar.zst -C work --exclude='images/kryptik-root.img' --exclude='images/esp-*.img' --exclude='keys/sb/kryptik-sb.key' --exclude='keys/release/kryptik-release' --exclude='keys/release/kryptik-latest' . + if sudo tar --zstd -tf work-after-media.tar.zst | grep -E 'kryptik-sb\.key$|keys/release/kryptik-(release|latest)$'; then echo "a private key is in the artifact"; exit 1; fi sudo chown "$(id -u):$(id -g)" work-after-media.tar.zst ls -la work-after-media.tar.zst; df -h /mnt | tail -1 - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: work-after-media path: /mnt/kryptik/work-after-media.tar.zst @@ -330,7 +337,7 @@ jobs: - name: Stage logs if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: logs-system path: /mnt/kryptik/work/logs @@ -343,7 +350,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 360 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - name: Working directories on the large disk run: | @@ -366,7 +373,7 @@ jobs: sudo apt-get update -qq && sudo apt-get install -y -qq $HOST_PACKAGES sudo ln -sf /usr/bin/bash /bin/sh # virt-fw-vars, for the disposable Secure Boot variable stores. - sudo pip install --break-system-packages virt-firmware + sudo pip install --break-system-packages virt-firmware==26.9 virt-fw-vars --help > /dev/null # KVM on a hosted runner: the device exists; it is the permissions that @@ -380,12 +387,12 @@ jobs: ls -la /dev/kvm - name: Upstream sources (cached by sources.lock) - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: /mnt/kryptik/sources key: sources-${{ hashFiles('sources.lock') }} - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: work-after-media path: /mnt/kryptik @@ -413,7 +420,7 @@ jobs: - name: Acceptance report and per-item logs if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: acceptance-report path: | @@ -427,7 +434,7 @@ jobs: # the first version of this job uploaded that under the release name. - name: Tested images if: steps.acceptance.outcome == 'success' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: kryptik-release path: /mnt/kryptik/export diff --git a/LICENSE b/LICENSE index 8b9ebfd..2622420 100644 --- a/LICENSE +++ b/LICENSE @@ -12,9 +12,351 @@ This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. -TODO: append the full GPL-2.0 text here before first public release. -Canonical text: https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt - NOTE: This license covers Kryptik's own build scripts, configuration, and documentation only. Packages built and shipped by this system retain their -own upstream licenses. See docs/licensing.md. +own upstream licenses; tools/scan-licenses.sh records what each source +carries (docs/supply-chain.md). + +The full text follows, as published at +https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt + +------------------------------------------------------------------------------- + + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Moe Ghoul, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/Makefile b/Makefile index 4c67056..6f19632 100644 --- a/Makefile +++ b/Makefile @@ -99,11 +99,11 @@ CHROOT_ENV := KRYPTIK_ROOT="$(ROOT)" \ CHROOT_RUN := $(SUDO) env $(CHROOT_ENV) "$(CHROOTD)" -.PHONY: test help check check-kernel-eol sources lock verify verify-provenance \ +.PHONY: test help check check-kernel-eol check-pins test-pin-reviews sources lock verify verify-provenance \ vm-disk vm-disk-boot vm-restart vm-measure cli-test update-tree-test identity-test serve-test \ test-harness test-hardening test-artifacts audit-artifacts test-boot-success \ audit-artifacts-strict manifest verify-manifest test-manifest \ - test-s6-init smoke-userspace test-services test-netzone-time test-libc-unwind \ + test-s6-init smoke-userspace test-services test-netzone-time test-update-verify test-update-fetch test-libc-unwind \ sign-image verify-image test-image-signing test-installer test-mkdisk-guards \ install-test \ image image-boot \ @@ -152,6 +152,9 @@ help: @echo " make verify-provenance signed tags + publisher checksums for the rest" @echo " make validate-kernel check kernel fragment against pinned source" @echo " make check-kernel-eol fail if the pinned kernel is EOL or not LTS" + @echo " make check-pins survey every pin against its upstream (network), then" + @echo " fail on one that is behind without a current review in" + @echo " tools/pin-reviews.tsv. PINS_FLAGS=--no-held is what a release asks" @echo " make validate-kernel-hardened check the linux-hardened fragment" @echo " make check-kernel-hardening resolve the config against the pinned source as" @echo " stage 05 does, refuse a dropped fragment line, then run" @@ -185,6 +188,8 @@ help: @echo " make smoke-userspace RUN the built userland in the chroot (needs root)" @echo " make test-services validate the s6-rc service tree" @echo " make test-netzone-time the net zone's time measurement, under every shell here" + @echo " make test-update-verify what kryptik-update believes: a payload, a manifest, a pointer" + @echo " make test-update-fetch the net zone's update fetcher, against a local server and broker" @echo " make identity-test zone files, compositor colour table and zoneid audit agree" @echo " make test-libc-unwind prove the target libc can unwind (needs root)" @echo " make sign-image sign the disk image with a developer key" @@ -247,6 +252,14 @@ validate-kernel: check-kernel-eol: @"$(TOOLS)"/check-kernel-eol.sh +# The survey asks the network and judges nothing; the gate reads the survey +# and tools/pin-reviews.tsv and never the network. +PINS_SURVEY ?= $(KRYPTIK_WORK)/pin-survey.tsv +check-pins: + @mkdir -p "$(dir $(PINS_SURVEY))" + @"$(TOOLS)"/check-source-currency.sh --tsv > "$(PINS_SURVEY)" + @"$(TOOLS)"/check-pin-reviews.sh --survey "$(PINS_SURVEY)" $(PINS_FLAGS) + validate-kernel-hardened: @"$(TOOLS)"/validate-kernel-config.sh --hardened @@ -561,6 +574,9 @@ test-hardening: test-kernel-hardening: @"$(TOOLS)"/test-check-kernel-hardening.sh +test-pin-reviews: + @"$(TOOLS)"/test-check-pin-reviews.sh + test-artifacts: @"$(TOOLS)"/test-artifact-hardening.sh @@ -600,6 +616,17 @@ test-services: test-netzone-time: @"$(TOOLS)"/test-netzone-time.sh +# What kryptik-update believes, with its own functions and a real ssh-keygen: +# a payload whose manifest is swapped under it, and the two checks the update +# channel runs on a manifest and on a statement of what is current. +test-update-verify: + @"$(TOOLS)"/test-update-manifest-snapshot.sh + +# The net zone's half of the update channel: a faithful pipe, from the offsets +# zone 0 names, in pieces zone 0 takes, that stops when zone 0 says no. +test-update-fetch: + @"$(TOOLS)"/test-update-fetch.sh + # boot-success.sh's decision table (commit, refuse, fall back), driven on # the host with stand-ins for the services, the ESP and the firmware. test-boot-success: diff --git a/build/lib/kconfig-check.sh b/build/lib/kconfig-check.sh index 801baf9..69fcfdb 100644 --- a/build/lib/kconfig-check.sh +++ b/build/lib/kconfig-check.sh @@ -20,6 +20,46 @@ # So: every =value line must come out with that value, and every "is not set" # line must come out unset (or absent, which is the same thing). +# The .config, read once into KCONFIG_HAVE[option]=value. Both checks below ask +# it; asking the file instead cost two processes and a pass over the whole +# .config for every fragment line, some 750 forks a run. +declare -gA KCONFIG_HAVE=() +_kconfig_load() { # <.config> + local line + KCONFIG_HAVE=() + while IFS= read -r line; do + [[ "$line" =~ ^(CONFIG_[A-Za-z0-9_]+)=(.*)$ ]] || continue + [[ -v "KCONFIG_HAVE[${BASH_REMATCH[1]}]" ]] || KCONFIG_HAVE["${BASH_REMATCH[1]}"]="${BASH_REMATCH[2]}" + done < "$1" +} + +# What Kryptik's guarantees, and the suites that prove them, rest on. The +# fragment check holds a line that IS in a fragment to its value; this holds +# the lines that must be in one at all, so that deleting one is noticed. +# Built in or a module is the fragment's to say: a bool cannot come out =m, +# and a driver the built-in rule made a module is still there. +KCONFIG_CRITICAL="CONFIG_SECURITY_LANDLOCK CONFIG_SECCOMP_FILTER CONFIG_USER_NS +CONFIG_NET_NS CONFIG_EFI_STUB CONFIG_CMDLINE_BOOL CONFIG_CMDLINE_OVERRIDE +CONFIG_DM_INIT CONFIG_EFIVAR_FS CONFIG_OVERLAY_FS CONFIG_DRM_VIRTIO_GPU +CONFIG_NFT_MASQ CONFIG_DM_VERITY CONFIG_DM_CRYPT CONFIG_CRYPTO_XTS +CONFIG_FS_ENCRYPTION CONFIG_MODULE_SIG_FORCE CONFIG_SECURITY_LOCKDOWN_LSM +CONFIG_INIT_ON_ALLOC_DEFAULT_ON CONFIG_SLAB_CANARY +CONFIG_MITIGATION_PAGE_TABLE_ISOLATION" + +# kconfig_critical_check <.config>: 0 when every one of them is =y or =m. +kconfig_critical_check() { + local opt missing=0 + _kconfig_load "$1" + for opt in $KCONFIG_CRITICAL; do + if [[ "${KCONFIG_HAVE[$opt]:-}" == [ym] ]]; then + echo " ok ${opt}" + else + echo " MISSING ${opt}"; missing=$((missing + 1)) + fi + done + [[ "$missing" -eq 0 ]] +} + # kconfig_fragment_check <.config> ... # # Prints one line per fragment line that was not honoured and a summary line; @@ -28,15 +68,14 @@ kconfig_fragment_check() { local config="$1"; shift local frag line opt want got total=0 bad=0 local -a lines + _kconfig_load "$config" for frag in "$@"; do - # The fragment is read into memory first, so the loop's stdin stays - # free for the reads of the .config inside it. mapfile -t lines < "$frag" for line in "${lines[@]}"; do if [[ "$line" =~ ^(CONFIG_[A-Za-z0-9_]+)=(.*)$ ]]; then opt="${BASH_REMATCH[1]}"; want="${BASH_REMATCH[2]}" total=$((total + 1)) - got="$(sed -n "s/^${opt}=//p" "$config" | head -1)" + got="${KCONFIG_HAVE[$opt]:-}" if [[ "$got" != "$want" ]]; then printf ' DROPPED %-38s wanted %s, got %s [%s]\n' \ "$opt" "$want" "${got:-nothing}" "$(basename "$frag")" @@ -45,7 +84,7 @@ kconfig_fragment_check() { elif [[ "$line" =~ ^#[[:space:]]+(CONFIG_[A-Za-z0-9_]+)[[:space:]]+is[[:space:]]+not[[:space:]]+set$ ]]; then opt="${BASH_REMATCH[1]}" total=$((total + 1)) - got="$(sed -n "s/^${opt}=//p" "$config" | head -1)" + got="${KCONFIG_HAVE[$opt]:-}" if [[ "$got" == y || "$got" == m ]]; then printf ' FORCED ON %-38s =%s although requested off [%s]; find what selects it: grep -rn "select %s" .\n' \ "$opt" "$got" "$(basename "$frag")" "${opt#CONFIG_}" diff --git a/build/patches/util-linux-2.42.3/0001-libmount-RESOLVE_NO_SYMLINKS-is-0x04-and-hook_idmap-includes-it.patch b/build/patches/util-linux-2.42.3/0001-libmount-RESOLVE_NO_SYMLINKS-is-0x04-and-hook_idmap-includes-it.patch new file mode 100644 index 0000000..130fbda --- /dev/null +++ b/build/patches/util-linux-2.42.3/0001-libmount-RESOLVE_NO_SYMLINKS-is-0x04-and-hook_idmap-includes-it.patch @@ -0,0 +1,43 @@ +libmount: RESOLVE_NO_SYMLINKS is 0x04, and hook_idmap.c includes its definition + +Carried by Kryptik against util-linux 2.42.3; not upstream at the time of +writing. Two defects in the restricted-mount hardening, both visible only +where the C library's does not bring in (glibc +before 2.43): + + * libmount/src/hook_idmap.c uses RESOLVE_NO_SYMLINKS and includes nothing + that defines it, so it does not compile. + * include/fileutils.h defines a fallback for it, 0x02. In the kernel's ABI + 0x02 is RESOLVE_NO_MAGICLINKS; RESOLVE_NO_SYMLINKS is 0x04. context.c and + hook_mount.c compile with the fallback, so a restricted mount asks the + kernel to block magic links and not symlinks. + +fileutils.h now includes where configure found it, and its +fallback is the kernel's value; hook_idmap.c includes fileutils.h as the +other two do. + +--- a/include/fileutils.h ++++ b/include/fileutils.h +@@ -65,8 +65,11 @@ + extern int ul_openat_resolve(int dirfd, const char *path, int flags, + mode_t mode, unsigned long long resolve); + ++#ifdef HAVE_LINUX_OPENAT2_H ++# include ++#endif + #ifndef RESOLVE_NO_SYMLINKS +-# define RESOLVE_NO_SYMLINKS 0x02 ++# define RESOLVE_NO_SYMLINKS 0x04 + #endif + #ifndef RESOLVE_BENEATH + # define RESOLVE_BENEATH 0x08 +--- a/libmount/src/hook_idmap.c ++++ b/libmount/src/hook_idmap.c +@@ -26,6 +26,7 @@ + #include "namespace.h" + + #include "mountP.h" ++#include "fileutils.h" + + #ifdef HAVE_LINUX_NSFS_H + # include diff --git a/build/patches/util-linux-2.42.3/README.md b/build/patches/util-linux-2.42.3/README.md new file mode 100644 index 0000000..355b187 --- /dev/null +++ b/build/patches/util-linux-2.42.3/README.md @@ -0,0 +1,27 @@ +# util-linux 2.42.3: the restricted-mount flag, where libc does not define it + +Applied by `s_util_linux` in stage 04 through `apply_repo_patches`; +`SHA256SUMS` is verified before anything is applied. + +util-linux 2.42 makes a restricted mount refuse symlinks in its paths by +passing `RESOLVE_NO_SYMLINKS` to `openat2()`. On a C library whose `` +does not bring `` in (glibc before 2.43; Kryptik is on 2.40) +that has two defects: + +- `libmount/src/hook_idmap.c` uses the constant and includes nothing that + defines it. It does not compile: this is what stopped the build. +- `include/fileutils.h` defines a fallback of `0x02`. In the kernel's ABI + `0x02` is `RESOLVE_NO_MAGICLINKS`; `RESOLVE_NO_SYMLINKS` is `0x04`. + `context.c` compiled with the fallback, so a restricted mount asked the + kernel to block the wrong thing. + +The patch makes `fileutils.h` include the kernel header where configure found +it, corrects the fallback, and has `hook_idmap.c` include `fileutils.h` as +`context.c` and `hook_mount.c` do. Checked on a glibc 2.39 host: the released +tarball fails at `hook_idmap.c:335`; patched, it builds, and `context.c` +preprocesses to `0x04` where it had `0x02`. + +Kryptik's `mount` is not setuid, so its restricted mode is not reachable here; +the value is corrected because a carried patch should not leave a known-wrong +constant beside the line it fixes. Not upstream when this was written. Delete +this directory when a util-linux release carries the fix. diff --git a/build/patches/util-linux-2.42.3/SHA256SUMS b/build/patches/util-linux-2.42.3/SHA256SUMS new file mode 100644 index 0000000..08782e2 --- /dev/null +++ b/build/patches/util-linux-2.42.3/SHA256SUMS @@ -0,0 +1 @@ +fc9be07ae0bb2e7656d12e17cf64d21ce1982037516d777fe8b60348a096c8bb 0001-libmount-RESOLVE_NO_SYMLINKS-is-0x04-and-hook_idmap-includes-it.patch diff --git a/build/service-scripts/firstboot.sh b/build/service-scripts/firstboot.sh index 0620fd5..c0a146a 100755 --- a/build/service-scripts/firstboot.sh +++ b/build/service-scripts/firstboot.sh @@ -22,69 +22,74 @@ if [ -r /run/kryptik/state-degraded ]; then fi PRESEED=/var/lib/kryptik/firstboot.preseed -has_user() { awk -F: '$3>=1000 && $3<65534 {found=1} END {exit !found}' /etc/passwd; } +regular_user() { awk -F: '$3>=1000 && $3<65534 {print $1; exit}' /etc/passwd; } +has_password() { awk -F: -v u="$1" '$1==u && $2 ~ /^\$/ {ok=1} END {exit !ok}' /etc/shadow; } +# Done means a regular user and root can both authenticate, read from the +# account database and not from a marker: a setup cut short anywhere (the +# power, a failed passwd) is finished by the next boot instead of skipped. +# It used to stop at the first uid of 1000 or more, and root cannot log in, +# so a user made a moment before the power went was a machine nobody could use. +complete() { u="$(regular_user)"; [ -n "$u" ] && has_password "$u" && has_password root; } -create_user() { # create_user NAME [HASH] - name="$1"; hash="${2:-}" - case "$name" in - ''|*[!a-z0-9_-]*|-*) say "refusing user name '$name'"; return 1 ;; +create_user() { # create_user NAME + case "$1" in + ''|*[!a-z0-9_-]*|-*) say "refusing user name '$1'"; return 1 ;; esac getent group seat >/dev/null 2>&1 || groupadd -r seat getent group kryptik >/dev/null 2>&1 || groupadd -r kryptik - useradd -m -k /etc/skel -s /usr/bin/bash -G seat,kryptik,wheel "$name" || return 1 - if [ -n "$hash" ]; then - # The hash goes through stdin, never argv. - printf '%s:%s\n' "$name" "$hash" | chpasswd -e || return 1 - fi - say "created user '$name' (groups: seat kryptik wheel)" + useradd -m -k /etc/skel -s /usr/bin/bash -G seat,kryptik,wheel "$1" || return 1 + say "created user '$1' (groups: seat kryptik wheel)" } +if complete; then + rm -f "$PRESEED" + say "a user account exists; nothing to do" + exit 0 +fi + if [ -r "$PRESEED" ]; then name="$(sed -n 's/^user=//p' "$PRESEED" | head -1)" hash="$(sed -n 's/^password_hash=//p' "$PRESEED" | head -1)" rhash="$(sed -n 's/^root_password_hash=//p' "$PRESEED" | head -1)" if [ -n "$name" ] && [ -n "$hash" ]; then - if id "$name" >/dev/null 2>&1; then - say "preseed user '$name' already exists" - else - create_user "$name" "$hash" || say "preseed FAILED" - fi + id "$name" >/dev/null 2>&1 || create_user "$name" || say "preseed FAILED" + # The hashes go through stdin, never argv. + printf '%s:%s\n' "$name" "$hash" | chpasswd -e || say "preseed FAILED: the user's password was not set" else say "preseed file is incomplete; ignoring it" fi if [ -n "$rhash" ]; then printf 'root:%s\n' "$rhash" | chpasswd -e && say "root password set from the preseed" fi - rm -f "$PRESEED" + # Kept until it has done its work: a boot cut short above reads it again. + if complete; then rm -f "$PRESEED"; exit 0; fi fi -if has_user; then - say "a user account exists; nothing to do" - exit 0 -fi - -# Interactive: ask on tty1. Bounded by a timeout so a headless machine still -# reaches a login prompt (root is locked, so that prompt is only useful once a -# user exists - the setup can be repeated by running kryptik-firstboot as -# root from the recovery console). +# Interactive: ask on tty1 for whatever is still missing. Bounded by a timeout +# so a headless machine still reaches a login prompt. tty=/dev/tty1 [ -c "$tty" ] || tty=/dev/console -{ - echo - echo "===== Kryptik first-boot setup =====" - echo "No user account exists yet. Create the desktop user now." - printf 'User name: ' -} > "$tty" 2>&1 -name="" -if read -r -t 600 name < "$tty"; then - name="$(printf '%s' "$name" | tr -d '[:space:]')" - if create_user "$name" >"$tty" 2>&1; then - echo "Set a password for $name:" > "$tty" - passwd "$name" < "$tty" > "$tty" 2>&1 || say "passwd failed; run kryptik-firstboot again" - echo "Set the administrator (root) password, used by su:" > "$tty" - passwd root < "$tty" > "$tty" 2>&1 || say "root passwd failed; run kryptik-firstboot again" +name="$(regular_user)" +if [ -z "$name" ]; then + { + echo + echo "===== Kryptik first-boot setup =====" + echo "No user account exists yet. Create the desktop user now." + printf 'User name: ' + } > "$tty" 2>&1 + if ! read -r -t 600 name < "$tty"; then + say "no answer within 10 minutes; the next boot asks again" + exit 0 fi -else - say "no answer within 10 minutes; a user can be created later with kryptik-firstboot" + name="$(printf '%s' "$name" | tr -d '[:space:]')" + create_user "$name" > "$tty" 2>&1 || exit 0 +fi +if ! has_password "$name"; then + echo "Set a password for $name:" > "$tty" + passwd "$name" < "$tty" > "$tty" 2>&1 || say "passwd failed; the next boot asks again" +fi +if ! has_password root; then + echo "Set the administrator (root) password, used by su:" > "$tty" + passwd root < "$tty" > "$tty" 2>&1 || say "root passwd failed; the next boot asks again" fi exit 0 diff --git a/build/service-scripts/installer-run.sh b/build/service-scripts/installer-run.sh index afb90a6..75ef91b 100755 --- a/build/service-scripts/installer-run.sh +++ b/build/service-scripts/installer-run.sh @@ -67,8 +67,11 @@ fi # `... | sed` followed by rc=$? reads sed's status, which is how a missing # partitioner was once reported as rc=0. logf=/run/kryptik-install.log +# The state passphrase goes in on standard input (printf is a builtin), the +# one place it is ever written down being the control disk. +sp="$(testctl_get state_passphrase)" # shellcheck disable=SC2086 # preseed_args is deliberately word-split -/usr/sbin/kryptik-install --target "$target" --yes $preseed_args > "$logf" 2>&1 +printf '%s\n' "$sp" | /usr/sbin/kryptik-install --target "$target" --yes $preseed_args > "$logf" 2>&1 rc=$? sed 's/^/KRYPTIK_INSTALL: /' "$logf" say "rc=${rc}" @@ -93,12 +96,14 @@ if [ "$rc" -eq 0 ]; then say "verify: could not mount the ESP read-only" fi st="$(blkid -t PARTLABEL=kryptik-state -o device 2>/dev/null | grep "^${target}" | head -1)" - if [ -n "$st" ] && mount -o ro "$st" /run/verify 2>/dev/null; then + if [ -n "$st" ] && printf '%s' "$sp" | cryptsetup open --readonly --type luks2 --key-file=- "$st" kryptik-verify-state 2>/dev/null \ + && mount -o ro /dev/mapper/kryptik-verify-state /run/verify 2>/dev/null; then say "verify: state_marker=$([ -e /run/verify/.kryptik-state ] && echo yes || echo no)" say "verify: install_json=$([ -r /run/verify/lib/kryptik/install.json ] && echo yes || echo no)" say "verify: preseed=$([ -r /run/verify/lib/kryptik/firstboot.preseed ] && echo present || echo none)" umount /run/verify fi + cryptsetup close kryptik-verify-state 2>/dev/null || true slot_a="$(blkid -t PARTLABEL=kryptik-a -o device 2>/dev/null | grep "^${target}" | head -1)" if [ -n "$slot_a" ]; then say "verify: slot_a_sha256=$(head -c "$(cat /etc/kryptik/root-image-bytes 2>/dev/null || echo 0)" "$slot_a" | sha256sum | cut -c1-64)" diff --git a/build/service-scripts/sysinit.sh b/build/service-scripts/sysinit.sh index 8ced4b0..792db2a 100755 --- a/build/service-scripts/sysinit.sh +++ b/build/service-scripts/sysinit.sh @@ -1,6 +1,12 @@ #!/bin/sh -e # Idempotent on purpose: s6-rc may run this again after a runlevel change. +# The console is this script's while it runs, because it may ask for the state +# passphrase there and two readers on one terminal lose keystrokes: +# kryptik-console holds the getty back until this has finished, however it ends. +echo running > /run/kryptik-sysinit +trap 'echo finished > /run/kryptik-sysinit' EXIT + [ -r /etc/hostname ] && hostname "$(cat /etc/hostname)" || true # The names under /etc the overlay's upper layer may carry: the account @@ -38,6 +44,25 @@ prune_etc_upper() { # prune_etc_upper UPPER QUARANTINE return 0 } +# Ask for the state passphrase on the console, three times at most. Echo goes +# off before the prompt is printed and stty never discards input, so an answer +# that arrives the moment the prompt appears is not lost. printf is a builtin: +# the passphrase reaches cryptsetup on a descriptor, never as an argument. +unlock_state() { # unlock_state DEVICE -> /dev/mapper/kryptik-state + try=1 + while [ "$try" -le 3 ] && [ ! -b /dev/mapper/kryptik-state ]; do + stty -echo < /dev/console 2>/dev/null || true + printf 'sysinit: passphrase for the state partition (try %s of 3): ' "$try" > /dev/console + IFS= read -r pass < /dev/console || pass="" + stty echo < /dev/console 2>/dev/null || true + echo > /dev/console + printf '%s' "$pass" | cryptsetup open --type luks2 --key-file=- "$1" kryptik-state 2>/dev/null || true + try=$((try + 1)) + done + pass="" + [ -b /dev/mapper/kryptik-state ] +} + # The kernel mounts devtmpfs itself (CONFIG_DEVTMPFS_MOUNT=y); these are the # rest, each guarded because stage 2 init may already have done it. mountpoint -q /proc || mount -t proc proc /proc -o nosuid,noexec,nodev @@ -140,9 +165,15 @@ if ! mountpoint -q /var; then state_dev="$(kryptik_part kryptik-state)" if [ ! -b "$state_dev" ]; then STATE=degraded; STATE_REASON="${state_dev} is not a block device" - elif mount -t ext4 -o nosuid,nodev,noatime "$state_dev" "$state_mnt" 2>/run/kryptik/state-mount.err; then + elif ! cryptsetup isLuks --type luks2 "$state_dev" 2>/dev/null; then + # Never mounted as found: a plain filesystem put in the encrypted + # one's place would otherwise be believed without a question. + STATE=degraded; STATE_REASON="${state_dev} carries no LUKS2 header" + elif ! unlock_state "$state_dev"; then + STATE=degraded; STATE_REASON="${state_dev} was not unlocked in three tries; reboot to try again" + elif mount -t ext4 -o nosuid,nodev,noatime /dev/mapper/kryptik-state "$state_mnt" 2>/run/kryptik/state-mount.err; then STATE=persistent - echo "sysinit: state partition ${state_dev} mounted (disk ${root_disk})" + echo "sysinit: state partition ${state_dev} unlocked and mounted (disk ${root_disk})" else STATE=degraded; STATE_REASON="mount of ${state_dev} failed: $(tr '\n' ' ' < /run/kryptik/state-mount.err)" fi @@ -212,9 +243,9 @@ rmdir "$state_mnt" 2>/dev/null || true # the release trust anchor (kryptik-update) /usr/share/kryptik/trust # all of which sit on the verified root. What remains under /etc is what # must be mutable: accounts and passwords, hostname, the local user's -# session hooks. Their protection is the state partition's, and that -# partition is not encrypted in this developer tier - a stated limitation, -# not tamper protection. +# session hooks. Their protection is the state partition's: encrypted, so an +# offline reader learns nothing, and not authenticated, so an offline writer +# can still damage it. That is why the list above stays. if ! mountpoint -q /etc; then mkdir -p /var/lib/kryptik/etc/upper /var/lib/kryptik/etc/work if mount -t overlay overlay \ diff --git a/build/stages/04-base-system.sh b/build/stages/04-base-system.sh index cd037d3..33ae005 100755 --- a/build/stages/04-base-system.sh +++ b/build/stages/04-base-system.sh @@ -163,6 +163,18 @@ native_build() { # --- packages that need more than ./configure ------------------------------ +# util-linux with the patch set in build/patches (see its README): 2.42.3 does +# not compile against a glibc older than 2.43, and gets one flag wrong there. +s_util_linux() { + local src; src="$(unpack "util-linux-${V_UTIL_LINUX}.tar.xz" "util-linux-${V_UTIL_LINUX}")" + cd "$src" + apply_repo_patches "util-linux-${V_UTIL_LINUX}" + ./configure --prefix=/usr --libdir=/usr/lib --runstatedir=/run --disable-chfn-chsh --disable-login --disable-nologin --disable-su --disable-setpriv --disable-runuser --disable-pylibmount --disable-liblastlog2 --disable-static --without-python + make + make install +} + + # Locale generation, using the localedef already installed by stage 01/02. # # Split out from the glibc rebuild and placed FIRST because of a dependency @@ -928,9 +940,23 @@ s_release_trust() { chmod 0600 "$keydir/kryptik-release" echo "generated a new developer release signing key" fi + # A second key, for one thing: signing the update channel's statement of + # what is current (docs/design/update-channel.md). It is honoured in the + # kryptik-latest namespace and nowhere else, and the release key is + # honoured in kryptik-release and nowhere else, so the key that has to be + # at hand on a schedule can never sign a release, and the key that signs + # releases never has to be. An owner who wants one key for both lists + # the release key on the second line instead; nothing else changes. + if [[ ! -f "$keydir/kryptik-latest" ]]; then + ssh-keygen -q -t ed25519 -N "" -C "kryptik-latest (developer)" -f "$keydir/kryptik-latest" + chmod 0600 "$keydir/kryptik-latest" + echo "generated a new developer key for statements of what is current" + fi install -d -m 0755 /usr/share/kryptik/trust - printf 'kryptik-release namespaces="kryptik-release" %s\n' "$(cut -d' ' -f1,2 "$keydir/kryptik-release.pub")" \ - > /usr/share/kryptik/trust/release-signers + { + printf 'kryptik-release namespaces="kryptik-release" %s\n' "$(cut -d' ' -f1,2 "$keydir/kryptik-release.pub")" + printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 "$keydir/kryptik-latest.pub")" + } > /usr/share/kryptik/trust/release-signers chmod 0644 /usr/share/kryptik/trust/release-signers # Developer tier: the updater accepts development-role manifests. A # production image changes this file (and its key), deliberately. @@ -957,6 +983,25 @@ s_release_trust() { echo "FAIL: a foreign key verified against the anchor"; rm -rf "$t"; return 1 fi echo "ok: a foreign key is refused" + # The two keys, each in its own namespace and refused in the other's: + # what makes the statement key safe to keep where a timer can reach it. + local who ns other + for who in kryptik-release kryptik-latest; do + ns="$who"; [[ "$who" == kryptik-release ]] && other=kryptik-latest || other=kryptik-release + rm -f "$t/$who.sig" + printf 'probe of %s\n' "$who" > "$t/$who" + ssh-keygen -Y sign -f "$keydir/$who" -n "$ns" "$t/$who" < /dev/null >/dev/null 2>&1 \ + && ssh-keygen -Y verify -f /usr/share/kryptik/trust/release-signers -I "$who" -n "$ns" -s "$t/$who.sig" < "$t/$who" >/dev/null 2>&1 \ + || { echo "FAIL: the $who key does not verify in its own namespace"; rm -rf "$t"; return 1; } + rm -f "$t/$who.sig" + ssh-keygen -Y sign -f "$keydir/$who" -n "$other" "$t/$who" < /dev/null >/dev/null 2>&1 + # A refusal only counts when there was a signature to refuse. + [[ -s "$t/$who.sig" ]] || { echo "FAIL: could not sign the probe of $who in $other"; rm -rf "$t"; return 1; } + if ssh-keygen -Y verify -f /usr/share/kryptik/trust/release-signers -I "$who" -n "$other" -s "$t/$who.sig" < "$t/$who" >/dev/null 2>&1; then + echo "FAIL: the $who key verified in the $other namespace"; rm -rf "$t"; return 1 + fi + done + echo "ok: each key verifies in its own namespace and is refused in the other's" rm -rf "$t" } @@ -972,6 +1017,8 @@ s_netzone() { # The SNTP query the net zone measures the clock with (docs/design/time.md). install -D -m 0755 "${KRYPTIK_ROOT}/tools/net/sntp-offset.py" /usr/libexec/kryptik/sntp-offset.py python3 -m py_compile /usr/libexec/kryptik/sntp-offset.py || { echo "sntp-offset.py does not compile under the target python"; return 1; } + install -D -m 0755 "${KRYPTIK_ROOT}/tools/net/update-fetch.py" /usr/libexec/kryptik/update-fetch.py + python3 -m py_compile /usr/libexec/kryptik/update-fetch.py || { echo "update-fetch.py does not compile under the target python"; return 1; } rm -rf /usr/libexec/kryptik/__pycache__ for t in dhcpcd nft dnsmasq ip; do command -v "$t" >/dev/null 2>&1 && echo " ok $t" || { echo " MISSING $t"; return 1; } @@ -1036,6 +1083,13 @@ s_console() { dev="$1" +# sysinit may be asking for the state passphrase on this console. It gets 30 s +# to start (a broken service database must still end in a console); once it +# has, the console is its own until it ends. +n=0 +until [ -e /run/kryptik-sysinit ] || [ "$n" -ge 150 ]; do sleep 0.2; n=$((n + 1)); done +while [ "$(cat /run/kryptik-sysinit 2>/dev/null)" = running ]; do sleep 0.2; done + if [ -z "$dev" ]; then # /sys/class/tty/console/active lists the kernel-preferred console last. # With both video and serial consoles that is "tty0 ttyS0", so taking the @@ -1594,7 +1648,7 @@ s_boot_check() { chk "sysctl fragments" /usr/lib/kryptik/sysctl.d chk "zone definitions" /usr/lib/kryptik/zones/work.toml chk "zone policies" /usr/lib/kryptik/zones/policy/work.seccomp - chk "device helper" /usr/libexec/kryptik/devices.sh + chk "device helper" /usr/libexec/kryptik/devices.sh x chk "boot scripts" /usr/libexec/kryptik/sysinit.sh x chk "test control helper" /usr/libexec/kryptik/testctl.sh chk "boot-success" /usr/libexec/kryptik/boot-success.sh x @@ -2308,7 +2362,7 @@ PACKAGES=( "zlib" "s_zlib" "python" "s_python" "texinfo" "native_build texinfo-${V_TEXINFO}.tar.xz texinfo-${V_TEXINFO}" - "util-linux" "native_build util-linux-${V_UTIL_LINUX}.tar.xz util-linux-${V_UTIL_LINUX} --libdir=/usr/lib --runstatedir=/run --disable-chfn-chsh --disable-login --disable-nologin --disable-su --disable-setpriv --disable-runuser --disable-pylibmount --disable-liblastlog2 --disable-static --without-python" + "util-linux" "s_util_linux" "glibc" "s_glibc" "bzip2" "s_bzip2" "xz" "s_xz_native" @@ -2512,7 +2566,7 @@ PACKAGES=( # kryptik-update, which refuses to start without kryptik-efiboot. "efiboot" "s_efiboot $(sha256_of "${KRYPTIK_ROOT}/tools/efi/kryptik-efiboot.c" 2>/dev/null || echo none)" "updater" "s_updater $(sha256_of "${KRYPTIK_ROOT}/tools/update/kryptik-update" 2>/dev/null || echo none) $(sha256_of "${KRYPTIK_ROOT}/tools/update/kryptik-recover" 2>/dev/null || echo none)" - "netzone" "s_netzone $(sha256_of "${KRYPTIK_ROOT}/tools/net/netzone-init.sh" 2>/dev/null || echo none)-$(sha256_of "${KRYPTIK_ROOT}/tools/net/sntp-offset.py" 2>/dev/null || echo none)" + "netzone" "s_netzone $(sha256_of "${KRYPTIK_ROOT}/tools/net/netzone-init.sh" 2>/dev/null || echo none)-$(sha256_of "${KRYPTIK_ROOT}/tools/net/sntp-offset.py" 2>/dev/null || echo none)-$(sha256_of "${KRYPTIK_ROOT}/tools/net/update-fetch.py" 2>/dev/null || echo none)" "installer" "s_installer $(sha256_of "${KRYPTIK_ROOT}/tools/install/kryptik-install.sh" 2>/dev/null || echo none)" # The path and the binary's content hash are arguments so that both are # part of this step's fingerprint; see s_kryptikd. @@ -2565,6 +2619,18 @@ require_inside_chroot "stage 04" "system" # temporary tools and nothing built with them can claim to be unchanged. stage_depends_on "tt-" verify +# The signing keys live under ${KRYPTIK_WORK}/keys, outside the sysroot and +# outside any cache of it, on purpose. A work tree restored from such a cache +# has release-trust stamped as built and no keys; the anchor in the restored +# sysroot then names keys that no longer exist, and stage 06 would sign with +# ones the image does not trust, or find none. So, as for the kernel tree: +# no keys, no stamp. The step then makes both and writes the anchor again. +if [[ -f "${STAMPS}/${STAMP_PREFIX}release-trust" ]] && \ + [[ ! -f "${KRYPTIK_WORK}/keys/release/kryptik-release" || ! -f "${KRYPTIK_WORK}/keys/release/kryptik-latest" ]]; then + warn "release-trust is stamped as built but a signing key under ${KRYPTIK_WORK}/keys/release is gone; the step runs again." + rm -f "${STAMPS}/${STAMP_PREFIX}release-trust" +fi + unwired=0 for ((i = 0; i < ${#PACKAGES[@]}; i += 2)); do name="${PACKAGES[i]}" diff --git a/build/stages/05-kernel.sh b/build/stages/05-kernel.sh index 1420495..c2d45f2 100755 --- a/build/stages/05-kernel.sh +++ b/build/stages/05-kernel.sh @@ -281,38 +281,15 @@ s_config() { echo " ok CONFIG_EXTRA_FIRMWARE names $(wc -w <<<"$fw") microcode files under ${ucode}" # merge_config.sh silently drops symbols whose dependencies are unmet, so - # verify the ones that carry Kryptik's actual guarantees actually survived. + # the ones that carry Kryptik's guarantees are asked for by name. The list + # and the rule are in build/lib/kconfig-check.sh, where the host resolver + # asks the same: this was a private list that took only =y, and it failed + # the first kernel whose GPU driver the built-in rule had made a module, + # two and a half hours into a build CI's config check had passed. echo echo "--- verifying critical options survived ---" - local missing=0 opt - for opt in CONFIG_SECURITY_LANDLOCK \ - CONFIG_SECCOMP_FILTER \ - CONFIG_USER_NS \ - CONFIG_NET_NS \ - CONFIG_EFI_STUB \ - CONFIG_CMDLINE_BOOL \ - CONFIG_CMDLINE_OVERRIDE \ - CONFIG_DM_INIT \ - CONFIG_EFIVAR_FS \ - CONFIG_OVERLAY_FS \ - CONFIG_DRM_VIRTIO_GPU \ - CONFIG_NFT_MASQ \ - CONFIG_DM_VERITY \ - CONFIG_DM_CRYPT \ - CONFIG_CRYPTO_XTS \ - CONFIG_FS_ENCRYPTION \ - CONFIG_MODULE_SIG_FORCE \ - CONFIG_SECURITY_LOCKDOWN_LSM \ - CONFIG_INIT_ON_ALLOC_DEFAULT_ON \ - CONFIG_SLAB_CANARY \ - CONFIG_MITIGATION_PAGE_TABLE_ISOLATION; do - if grep -q "^${opt}=y" .config; then - echo " ok ${opt}" - else - echo " MISSING ${opt}" - missing=$((missing + 1)) - fi - done + local missing=0 + kconfig_critical_check .config || missing=1 # Every line of every fragment, not just the ones listed above: an =value # line must come out with that value, an "is not set" line must come out @@ -346,7 +323,7 @@ s_config() { if [[ "$missing" -gt 0 ]]; then echo - echo "${missing} critical option(s) did not survive config resolution." + echo "Critical option(s) did not survive config resolution (MISSING, above)." echo "These are not optional - the zone model and boot integrity" echo "depend on them. Investigate before building." return 1 diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index 3f8767d..cc6a711 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -453,6 +453,28 @@ s_payload() { "${KRYPTIK_ROOT}/tools/release-manifest.sh" verify --signers "$signers" --principal kryptik-release \ --root "$out" --exact --strict "$out/manifest" ls -la "$out" + + # What a release host serves beside the payload (docs/design/update-channel.md): + # the signed statement that this release is current. Outside the payload + # directory, because `apply` refuses a payload that holds anything its + # manifest does not list. `base` is relative, so the same two files serve + # from wherever the channel is. `not-a-pointer` is the same statement + # signed by the release key in the manifest's namespace, which the image + # must refuse; the update suite serves it to prove that on the real chain. + [[ -f "$keydir/kryptik-latest" ]] || { echo "no statement key at ${keydir}; stage 04 (release-trust) makes it"; return 1; } + local chan="${IMG}/channel-${KRYPTIK_VERSION}" + rm -rf "$chan"; mkdir -p "$chan" + "${KRYPTIK_ROOT}/tools/release-manifest.sh" pointer --key "$keydir/kryptik-latest" \ + --manifest "$out/manifest" --signers "$signers" --base "${KRYPTIK_VERSION}/" --out "$chan/latest" + cp "$chan/latest" "$chan/not-a-pointer" + ssh-keygen -Y sign -f "$keydir/kryptik-release" -n kryptik-release "$chan/not-a-pointer" < /dev/null >/dev/null 2>&1 \ + || { echo "could not sign the control statement"; return 1; } + ssh-keygen -Y verify -f "$signers" -I kryptik-latest -n kryptik-latest -s "$chan/latest.sig" < "$chan/latest" >/dev/null \ + || { echo "FAIL: the image's anchor does not verify the statement this build just signed"; return 1; } + if ssh-keygen -Y verify -f "$signers" -I kryptik-release -n kryptik-latest -s "$chan/not-a-pointer.sig" < "$chan/not-a-pointer" >/dev/null 2>&1; then + echo "FAIL: the image's anchor accepts a statement signed by the release key"; return 1 + fi + ls -la "$chan" } # The release record under ${KRYPTIK_OUT}: the small things (hashes, root diff --git a/compartments/kryptikd/fuzz-corpus/broker-requests b/compartments/kryptikd/fuzz-corpus/broker-requests index 373de5d..7d55c35 100644 --- a/compartments/kryptikd/fuzz-corpus/broker-requests +++ b/compartments/kryptikd/fuzz-corpus/broker-requests @@ -16,3 +16,9 @@ time-offset +3599.999999 16 time-offset 1e9 4 time-offset nan 4 steal +update-poll +update-latest 300 120 +update-latest 8193 1 +update-put manifest 0 64 +update-put kryptik-root.img 1048576 1048576 +update-put ../kryptik-root.img 0 5 diff --git a/compartments/kryptikd/probes/boundary-checks.sh b/compartments/kryptikd/probes/boundary-checks.sh index 1e23667..1a6155c 100755 --- a/compartments/kryptikd/probes/boundary-checks.sh +++ b/compartments/kryptikd/probes/boundary-checks.sh @@ -244,6 +244,20 @@ MATCH="does not hold the network" check "the clock's verb is refused from a zone " MATCH="is not an offset in seconds" check "a time claim outside the grammar is refused at parse time" 0 /usr/bin/python3 -c "$BRK" "time-offset 1e9 4 " +# The update channel (docs/design/update-channel.md): a release is brought by +# the zone that holds the network and by no other. From this zone all three +# verbs are refused by who is asking, the two that carry bytes before a byte +# of them is read, and a request outside the grammar before that. +MATCH="does not hold the network" check "a statement of what is current is refused from a zone that does not hold the network" 0 /usr/bin/python3 -c "$BRK" "update-latest 5 3 +helloabc" +MATCH="does not hold the network" check "asking whether a release is wanted is refused from a zone that does not hold the network" 0 /usr/bin/python3 -c "$BRK" "update-poll +" +MATCH="does not hold the network" check "a piece of a release is refused from a zone that does not hold the network" 0 /usr/bin/python3 -c "$BRK" "update-put kryptik-root.img 0 5 +hello" +MATCH="must be a single path component" check "a piece of a release named with a path is refused at parse time" 0 /usr/bin/python3 -c "$BRK" "update-put ../kryptik-root.img 0 5 +hello" +MATCH="is not 1 to 1048576 bytes" check "a piece of a release larger than one piece is refused at parse time" 0 /usr/bin/python3 -c "$BRK" "update-put kryptik-root.img 0 1048577 +" MATCH="^ok text/plain 5 hello$" check "clipboard-set then clipboard-get round-trips" 0 /bin/sh -c "python3 -c '$BRK' 'clipboard-set text/plain 5 hello' >/dev/null && python3 -c '$BRK' 'clipboard-get '" diff --git a/compartments/kryptikd/src/broker.rs b/compartments/kryptikd/src/broker.rs index a8681ca..6a5a581 100755 --- a/compartments/kryptikd/src/broker.rs +++ b/compartments/kryptikd/src/broker.rs @@ -181,6 +181,10 @@ const REQUEST_DEADLINE: Duration = Duration::from_secs(5); /// clipboard-get\n -> ok \n | empty\n /// time-offset \n -> ok ignored | slewed | stepped | stepped after consent\n /// (from the zone that holds the network, and no other) +/// update-latest \n -> ok current | ok available \n +/// update-poll\n -> idle | fetch need ...\n +/// update-put \n -> ok / | ok complete\n +/// (the same zone, and no other) /// anything else -> error: \n /// ``` #[derive(Debug, PartialEq)] @@ -195,6 +199,13 @@ pub enum Request { /// `time-offset `: the net zone's claim about how far /// the machine's clock is from the network's (docs/design/time.md). TimeOffset(crate::time::Claim), + /// The update channel's three verbs (docs/design/update-channel.md), from + /// the zone that holds the network and no other. `update-latest` is + /// followed by the pointer and then its signature, `update-put` by `len` + /// bytes of the named file. + UpdateLatest { plen: usize, slen: usize }, + UpdatePoll, + UpdatePut { name: String, offset: u64, len: usize }, Unknown(String), } @@ -257,6 +268,25 @@ pub fn parse_request(line: &str) -> Result { ("transfer", _) => Err("usage: transfer , with the file as one SCM_RIGHTS descriptor".into()), ("time-offset", [secs, sources]) => crate::time::parse_claim(&format!("{secs} {sources}")).map(Request::TimeOffset), ("time-offset", _) => Err("usage: time-offset ".into()), + ("update-latest", [plen, slen]) => { + let size = |w: &str, what: &str| match w.parse::() { + Ok(n) if (1..=crate::update::POINTER_MAX).contains(&n) => Ok(n), + _ => Err(format!("{what} length {w:?} is not 1 to {} bytes", crate::update::POINTER_MAX)), + }; + Ok(Request::UpdateLatest { plen: size(plen, "pointer")?, slen: size(slen, "signature")? }) + } + ("update-latest", _) => Err("usage: update-latest , then the two".into()), + ("update-poll", []) => Ok(Request::UpdatePoll), + ("update-poll", _) => Err("usage: update-poll".into()), + ("update-put", [name, offset, len]) => { + check_transfer_name(name)?; + let offset: u64 = offset.parse().map_err(|_| format!("bad offset {offset:?}"))?; + match len.parse::() { + Ok(len) if (1..=crate::update::PUT_MAX).contains(&len) => Ok(Request::UpdatePut { name: name.to_string(), offset, len }), + _ => Err(format!("length {len:?} is not 1 to {} bytes", crate::update::PUT_MAX)), + } + } + ("update-put", _) => Err("usage: update-put , then the bytes".into()), ("", _) => Err("empty request".into()), _ => Ok(Request::Unknown(verb.to_string())), } @@ -281,6 +311,47 @@ fn handle_time_offset(zone: &Zone, claim: &crate::time::Claim) -> crate::time::O ) } +/// UPDATES +/// +/// The zone that holds the network hands over a statement of what is +/// current, asks whether a release is wanted, and streams one in pieces +/// (docs/design/update-channel.md). Everything it sends is a hostile zone's +/// word: `update.rs` decides what is believed and what is stored, and +/// `kryptik-update` verifies every signature. What is decided here is only +/// who may speak: that one zone, like `time-offset`, because no other zone +/// has anywhere to have fetched a release from. +fn update_refusal(zone: &Zone) -> Option { + (zone.network != NetworkMode::Nic) + .then(|| format!("zone {:?} does not hold the network; only the zone that does may bring an update", zone.name)) +} + +/// For a zone `update_refusal` has passed, with the request's whole payload. +fn handle_update(req: &Request, payload: &[u8]) -> Result { + use crate::update as up; + let dir = Path::new(up::STATE_DIR); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64); + match req { + Request::UpdateLatest { plen, .. } => { + // Read here and not above the match: a release arrives as some + // three thousand update-put requests, which need neither. + let (role, running) = (up::required_role(), up::running_version()); + let (pointer, sig) = payload.split_at(*plen); + up::latest(dir, &up::tool_checks(), now, &role, &running, pointer, sig).map(|s| match s { + up::Standing::Current => "ok current".to_string(), + up::Standing::Available(v) => format!("ok available {v}"), + }) + } + Request::UpdatePoll => { + let (role, running) = (up::required_role(), up::running_version()); + up::forget_if_installed(dir, &running); + let conf = std::fs::read_to_string(up::CONF).unwrap_or_default(); + Ok(up::channel_from(&conf).map_or("idle".to_string(), |channel| up::poll(dir, &channel, &role, &running))) + } + Request::UpdatePut { name, offset, .. } => up::put(dir, &up::tool_checks(), now, name, *offset, payload).map(|r| format!("ok {r}")), + _ => Err("not an update verb".into()), + } +} + /// The same with the clock, the state directory and the floor named, which /// is what the tests do. fn time_offset_in( @@ -750,6 +821,33 @@ pub fn serve_connection(fd: RawFd, s: &Served) -> io::Result> { done => reply(fd, &format!("{}\n", done.reply())), } } + Ok(req @ (Request::UpdateLatest { .. } | Request::UpdatePoll | Request::UpdatePut { .. })) => { + let len = match &req { + Request::UpdateLatest { plen, slen } => plen + slen, + Request::UpdatePut { len, .. } => *len, + _ => 0, + }; + // Who is asking is settled before a byte of payload is read. + let outcome = match update_refusal(s.zone) { + Some(why) => Err(why), + None => match read_more(fd, &mut rest, len, started) { + Err(e) => Err(format!("payload: {e}")), + Ok(()) if rest.len() < len => Err(format!("payload short: {} of {len} bytes", rest.len())), + Ok(()) => handle_update(&req, &rest[..len]), + }, + }; + // A release is some thousands of pieces; the log gets a line for + // what ends something, not for every piece. + match &outcome { + Ok(r) if verb == "update-poll" || (verb == "update-put" && !r.contains("complete")) => {} + Ok(r) => crate::spawn::log_line(&format!("kryptikd[zone {zone}]: {verb}: {r}")), + Err(why) => crate::spawn::log_line(&format!("kryptikd[zone {zone}]: {verb}: refused: {why}")), + } + match outcome { + Ok(r) => reply(fd, &format!("{r}\n")), + Err(why) => reply(fd, &format!("error: {why}\n")), + } + } Ok(Request::ClipboardGet) => match clipboard_read(entry) { Ok(Some((mime, bytes))) => { reply(fd, &format!("ok {mime} {}\n", bytes.len())); @@ -1555,6 +1653,47 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// The update channel's verbs on the wire: lengths within their bounds, + /// a name that is one path component, and nothing else. + #[test] + fn the_update_verbs_parse_within_their_bounds() { + use crate::update::{POINTER_MAX, PUT_MAX}; + assert_eq!(parse_request("update-latest 300 120"), Ok(Request::UpdateLatest { plen: 300, slen: 120 })); + assert_eq!(parse_request("update-poll"), Ok(Request::UpdatePoll)); + assert_eq!( + parse_request(&format!("update-put kryptik-root.img 1048576 {PUT_MAX}")), + Ok(Request::UpdatePut { name: "kryptik-root.img".into(), offset: 1048576, len: PUT_MAX }) + ); + assert!(parse_request("update-put manifest.sig 0 120").is_ok()); + let over_pointer = format!("update-latest {} 120", POINTER_MAX + 1); + let over_put = format!("update-put root.json 0 {}", PUT_MAX + 1); + for bad in [ + "update-latest", "update-latest 300", "update-latest 0 120", "update-latest 300 0", "update-latest -1 120", over_pointer.as_str(), + "update-poll now", + "update-put", "update-put root.json 0", "update-put root.json 0 0", "update-put root.json -1 10", "update-put root.json x 10", + "update-put ../root.json 0 10", "update-put a/b 0 10", "update-put .hidden 0 10", over_put.as_str(), + ] { + assert!(parse_request(bad).is_err(), "{bad:?} was accepted"); + } + } + + /// An update is brought by the zone that holds the network and by no + /// other: a zone with no network has nowhere to have fetched one from, + /// and a routed zone's would be the net zone's at one remove. + #[test] + fn only_the_zone_that_holds_the_network_may_bring_an_update() { + let zone_of = |mode: &str, extra: &str| { + Zone::from_str(&format!( + "[zone]\nname = \"t\"\n[network]\nmode = \"{mode}\"\n{extra}[storage]\nmode = \"ephemeral\"\nsize = \"64M\"\n[ui]\nborder_color = \"#123456\"\n" + )) + .unwrap() + }; + for mode in ["none", "routed"] { + assert!(update_refusal(&zone_of(mode, "")).is_some_and(|w| w.contains("does not hold the network")), "{mode}"); + } + assert_eq!(update_refusal(&zone_of("nic", "bridge = \"kryptik0\"\n")), None); + } + #[test] fn a_zone_with_no_identity_can_never_be_identified() { // "legacy" has no uid_base: nothing maps to it, so the broker can diff --git a/compartments/kryptikd/src/main.rs b/compartments/kryptikd/src/main.rs index a8c3f17..ccadd93 100644 --- a/compartments/kryptikd/src/main.rs +++ b/compartments/kryptikd/src/main.rs @@ -28,6 +28,7 @@ mod seccomp; mod serve; mod spawn; mod time; +mod update; mod volume; mod wifi; mod zone; diff --git a/compartments/kryptikd/src/netlink.rs b/compartments/kryptikd/src/netlink.rs index 070ed0f..af0dcf5 100755 --- a/compartments/kryptikd/src/netlink.rs +++ b/compartments/kryptikd/src/netlink.rs @@ -184,6 +184,10 @@ impl Msg { } } +/// The most reply payload one request may gather. The largest real one is a +/// generic-netlink family description, a few hundred bytes. +const MAX_REPLY: usize = 64 * 1024; + /// One request/ack exchange on a fresh NETLINK_ROUTE socket. fn transact(msg: Vec, what: &str) -> io::Result<()> { transact_on(NETLINK_ROUTE, msg, what).map(|_| ()) @@ -200,6 +204,22 @@ fn transact_on(proto: libc::c_int, msg: Vec, what: &str) -> io::Result() as libc::socklen_t, + ) + }; + if rc < 0 { + return Err(io::Error::last_os_error()); + } let sent = unsafe { libc::send(fd, msg.as_ptr() as *const libc::c_void, msg.len(), 0) }; if sent < 0 { return Err(io::Error::last_os_error()); @@ -225,6 +245,9 @@ fn transact_on(proto: libc::c_int, msg: Vec, what: &str) -> io::Result { + if len < 20 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "netlink error without a code")); + } let code = i32::from_ne_bytes(buf[off + 16..off + 20].try_into().unwrap()); return if code == 0 { Ok(replies) @@ -236,6 +259,9 @@ fn transact_on(proto: libc::c_int, msg: Vec, what: &str) -> io::Result return Ok(replies), + _ if replies.len() + len > MAX_REPLY => { + return Err(io::Error::new(io::ErrorKind::InvalidData, "netlink reply too large")); + } _ => replies.extend_from_slice(&buf[off + 16..off + len]), } off += align4(len); diff --git a/compartments/kryptikd/src/rootfs.rs b/compartments/kryptikd/src/rootfs.rs index 309e0d4..3720a09 100644 --- a/compartments/kryptikd/src/rootfs.rs +++ b/compartments/kryptikd/src/rootfs.rs @@ -281,6 +281,9 @@ pub const ETC_RO_FILES: &[&str] = &[ // The time sources the nic zone asks (docs/design/time.md): a list of // server names, no secret, and absent on a system that keeps the default. "/etc/kryptik/time.conf", + // Where the nic zone looks for releases (docs/design/update-channel.md): + // one address, no secret, and absent on a system with no channel. + "/etc/kryptik/update.conf", ]; pub const ETC_RO_DIRS: &[&str] = &["/etc/alternatives", "/etc/ssl/certs", "/etc/pki/tls/certs"]; @@ -528,14 +531,16 @@ pub fn pivot_into( populate_dev(root)?; // Private /tmp. Without this a zone shares the host's, which is a - // cross-zone channel and a classic symlink-attack surface. + // cross-zone channel and a classic symlink-attack surface. Sized like + // every other tmpfs here: unsized, it is bounded by half the host's + // memory, and a zone without [limits] has no cgroup to stop it. let tmp_dir = mkdir("tmp")?; mount_raw( "tmpfs", &tmp_dir, Some("tmpfs"), (libc::MS_NOSUID | libc::MS_NODEV) as libc::c_ulong, - Some("mode=1777"), + Some("mode=1777,size=256m"), "mount(tmp)", )?; diff --git a/compartments/kryptikd/src/serve.rs b/compartments/kryptikd/src/serve.rs index 0956fda..0c65735 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -1056,6 +1056,44 @@ fn handle(cfg: &ServeConfig, conn: UnixStream) -> Option { } eprintln!("kryptikd serve: uid {uid} stopped zone {zone:?}"); } + // The update channel, from the person's side (update.rs). `fetch` is + // the asking without which the net zone is told `idle`; `apply` hands + // the staged directory to kryptik-update, which verifies all of it + // again before it writes a slot, and takes as long as that takes. + "update-status" | "update-fetch" | "update-apply" => { + use crate::update as up; + let dir = std::path::Path::new(up::STATE_DIR); + let running = up::running_version(); + let done = match verb { + "update-status" => { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64); + Ok(up::status(dir, now, &running)) + } + "update-fetch" => up::want(dir, &running).map(|v| format!("{v} will be fetched when the net zone next asks; `kryptik update status` shows it arriving\n")), + _ => up::complete_stage(dir).and_then(|stage| { + let out = std::process::Command::new(up::TOOL) + .arg("apply") + .arg(&stage) + .env_clear() + .env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin") + .stdin(std::process::Stdio::null()) + .output() + .map_err(|e| format!("{}: {e}", up::TOOL))?; + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) + } else { + Err(String::from_utf8_lossy(&out.stderr).lines().last().unwrap_or("kryptik-update apply failed").to_string()) + } + }), + }; + match done { + Ok(text) => { + eprintln!("kryptikd serve: {verb}"); + reply(&conn, &format!("ok\n{text}")); + } + Err(e) => reply(&conn, &format!("error: {e}\n")), + } + } "wifi-list" => match crate::wifi::list(&cfg.wifi_dir) { Ok(names) => { let mut out = String::new(); diff --git a/compartments/kryptikd/src/spawn.rs b/compartments/kryptikd/src/spawn.rs index 3a6976a..86ff25e 100644 --- a/compartments/kryptikd/src/spawn.rs +++ b/compartments/kryptikd/src/spawn.rs @@ -278,23 +278,116 @@ pub(crate) fn log_line(line: &str) { } } +/// The most one launch may add to the launcher's log from what its zone prints, +/// and the longest single line. Past the first the output is read and dropped, +/// so a program that prints for ever neither blocks nor fills the state +/// partition. +const ZONE_OUTPUT_MAX: usize = 1 << 20; +const ZONE_LINE_MAX: usize = 1024; + +/// What a daemon-launched zone prints, on its way into the launcher's log. +/// +/// The zone used to hold the log itself as its stdout and stderr. O_APPEND +/// stops neither ftruncate nor fallocate, both of which the zone's seccomp +/// filter allows, so a zone could erase the launcher's record of what crossed +/// its boundary, write lines that read as the launcher's, and fill the state +/// partition in one call. It holds a pipe now, and the launcher is its log's +/// only writer: every line carries the zone's mark, control bytes (terminal +/// escapes among them) are replaced, and the total is bounded. +struct ZoneOutput { + fd: RawFd, + mark: String, + line: Vec, + left: usize, +} + +impl ZoneOutput { + fn new(fd: RawFd, zone: &str) -> Self { + ZoneOutput { fd, mark: format!("zone {zone}| "), line: Vec::new(), left: ZONE_OUTPUT_MAX } + } + + fn flush(&mut self, emit: &mut dyn FnMut(&str)) { + if !self.line.is_empty() { + emit(&format!("{}{}", self.mark, String::from_utf8_lossy(&self.line))); + self.line.clear(); + } + } + + fn take(&mut self, data: &[u8], emit: &mut dyn FnMut(&str)) { + for &b in data { + if self.left == 0 { + return; + } + self.left -= 1; + match b { + b'\n' => self.flush(emit), + b'\t' | 0x20..=0x7e | 0x80..=0xff => self.line.push(b), + _ => self.line.push(b'?'), + } + if self.line.len() >= ZONE_LINE_MAX { + self.flush(emit); + } + if self.left == 0 { + self.flush(emit); + emit(&format!("{}(output past {ZONE_OUTPUT_MAX} bytes is not logged)", self.mark)); + } + } + } + + /// Read what is there. False once every writer has gone. + fn pump(&mut self, emit: &mut dyn FnMut(&str)) -> bool { + let mut buf = [0u8; 4096]; + loop { + let n = unsafe { libc::read(self.fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; + if n > 0 { + self.take(&buf[..n as usize], emit); + } else if n == 0 { + return false; + } else if errno() != libc::EINTR { + return true; // EAGAIN: nothing more for now + } + } + } +} + +/// However the launcher stops listening (the zone ended, or the launch failed +/// before it began), what the zone's side already said is not lost. +impl Drop for ZoneOutput { + fn drop(&mut self) { + let mut emit = |line: &str| log_line(line); + self.pump(&mut emit); + self.flush(&mut emit); + unsafe { libc::close(self.fd) }; + } +} + /// Supervise the child while answering the zone broker requests: poll the -/// listening socket with a short timeout, serve what arrives, and reap the -/// child when it exits. Signals forwarded by the handlers interrupt the -/// poll, which just loops. -fn serve_until_exit(pid: libc::pid_t, listen_fd: RawFd, s: &broker::Served) -> Result { +/// listening socket (and the zone's output, when the launcher relays it) with +/// a short timeout, serve what arrives, and reap the child when it exits. +/// Signals forwarded by the handlers interrupt the poll, which just loops. +fn serve_until_exit(pid: libc::pid_t, listen_fd: RawFd, s: &broker::Served, mut out: Option) -> Result { let zone = s.zone.name.as_str(); + let mut emit = |line: &str| log_line(line); loop { let mut status: libc::c_int = 0; let r = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) }; if r == pid { - return Ok(status); + return Ok(status); // dropping `out` relays what is left in the pipe } if r < 0 && errno() != libc::EINTR { return Err(SpawnError::Syscall { call: "waitpid", errno: errno() }); } - let mut pfd = libc::pollfd { fd: listen_fd, events: libc::POLLIN, revents: 0 }; - let n = unsafe { libc::poll(&mut pfd, 1, 200) }; + let mut pfds = [ + libc::pollfd { fd: listen_fd, events: libc::POLLIN, revents: 0 }, + libc::pollfd { fd: out.as_ref().map_or(-1, |o| o.fd), events: libc::POLLIN, revents: 0 }, + ]; + let n = unsafe { libc::poll(pfds.as_mut_ptr(), 2, 200) }; + if n > 0 && pfds[1].revents & (libc::POLLIN | libc::POLLHUP) != 0 { + if out.as_mut().is_some_and(|o| !o.pump(&mut emit)) { + out = None; // every writer has gone + } + } + let pfd = pfds[0]; if n > 0 && pfd.revents & libc::POLLIN != 0 { match broker::serve_one(listen_fd, s) { Ok(Some(verb)) => log_line(&format!("kryptikd[zone {zone}]: broker served {verb:?}")), @@ -882,6 +975,20 @@ pub fn run_in_zone( // Same outcome, and only the parent ever writes the registry. let initpid = SyncPipe::new()?; + // Launched by the daemon, this process's stdout and stderr are its log. + // The zone's side of the fork gets a pipe in their place (ZoneOutput). At + // a terminal (kryptik shell) the zone keeps the terminal, as it must. + let mut zone_out: Option<(RawFd, RawFd)> = None; + if opts.ready_fd.is_some() { + let mut p = [0 as RawFd; 2]; + if unsafe { libc::pipe2(p.as_mut_ptr(), libc::O_CLOEXEC) } != 0 { + return Err(SpawnError::Syscall { call: "pipe2", errno: errno() }); + } + // The read end only: a zone's stdout must block like anyone's. + unsafe { libc::fcntl(p[0], libc::F_SETFL, libc::O_NONBLOCK) }; + zone_out = Some((p[0], p[1])); + } + let parent_pid = unsafe { libc::getpid() }; let pid = unsafe { libc::fork() }; if pid < 0 { @@ -890,6 +997,16 @@ pub fn run_in_zone( if pid == 0 { // --- intermediate ------------------------------------------------------ + // First, before anything on this side can print: nothing from here + // down holds the log. dup2 clears close-on-exec on 1 and 2 only. + if let Some((r, w)) = zone_out { + unsafe { + libc::dup2(w, 1); + libc::dup2(w, 2); + libc::close(w); + libc::close(r); + } + } placed.close_write(); ready.close_read(); mapped.close_write(); @@ -909,6 +1026,10 @@ pub fn run_in_zone( } // --- parent -------------------------------------------------------------- + let zone_out = zone_out.map(|(r, w)| { + unsafe { libc::close(w) }; + ZoneOutput::new(r, &zone.name) + }); placed.close_read(); ready.close_write(); mapped.close_read(); @@ -1066,7 +1187,7 @@ pub fn run_in_zone( max_bytes: broker::TRANSFER_MAX, resolve_dest: &broker::registry_target, }; - let status = serve_until_exit(pid, broker_fd, &served)?; + let status = serve_until_exit(pid, broker_fd, &served, zone_out)?; unsafe { libc::close(broker_fd) }; let _ = std::fs::remove_file(&broker_path); // The zone is gone (its pid namespace with it): unmount its data and @@ -1879,6 +2000,40 @@ mod tests { use super::*; use crate::zone::Zone; + /// A relay with no descriptor behind it: only `take` and `flush` are used. + fn relayed(chunks: &[&[u8]]) -> Vec { + let mut lines = Vec::new(); + let mut o = std::mem::ManuallyDrop::new(ZoneOutput::new(-1, "work")); + for c in chunks { + o.take(c, &mut |l| lines.push(l.to_string())); + } + o.flush(&mut |l| lines.push(l.to_string())); + lines + } + + #[test] + fn what_a_zone_prints_is_marked_made_harmless_and_bounded() { + // Every line carries the mark, so none reads as the launcher's own, + // and a line split across reads is still one line. + assert_eq!( + relayed(&[b"kryptikd[zone work]: broker served \"steal\"\nhal", b"f\n"]), + ["zone work| kryptikd[zone work]: broker served \"steal\"", "zone work| half"] + ); + // Terminal escapes and carriage returns do not reach whoever reads the log. + assert_eq!(relayed(&[b"\x1b[2Jgone\rtab\there\n"]), ["zone work| ?[2Jgone?tab\there"]); + // A line with no end is cut, not held in memory for ever. + let long = vec![b'a'; ZONE_LINE_MAX * 2 + 5]; + let got = relayed(&[&long]); + assert_eq!(got.iter().map(|l| l.len() - "zone work| ".len()).collect::>(), [ZONE_LINE_MAX, ZONE_LINE_MAX, 5]); + // Past the bound nothing more is logged, and it says so once. + let flood = vec![b'x'; ZONE_OUTPUT_MAX + 4096]; + let got = relayed(&[&flood, b"more\n"]); + let logged: usize = got.iter().filter(|l| !l.contains("is not logged")).map(|l| l.len() - "zone work| ".len()).sum(); + assert_eq!(logged, ZONE_OUTPUT_MAX); + assert_eq!(got.iter().filter(|l| l.contains("is not logged")).count(), 1); + assert!(!got.iter().any(|l| l.contains("more"))); + } + fn z(mode: &str) -> Zone { let bridge = if mode == "nic" { "bridge = \"kryptik0\"\n" } else { "" }; Zone::from_str(&format!( diff --git a/compartments/kryptikd/src/update.rs b/compartments/kryptikd/src/update.rs new file mode 100644 index 0000000..b4031b1 --- /dev/null +++ b/compartments/kryptikd/src/update.rs @@ -0,0 +1,826 @@ +//! The update channel's rules (docs/design/update-channel.md): what zone 0 +//! believes about a statement of what is current, and which bytes it will +//! take from the net zone for a release it has been asked to fetch. +//! +//! Nothing here verifies a signature. `kryptik-update check-pointer` and +//! `check-manifest` do that, with the code that verifies a release handed +//! over on a disk, and only text they have verified reaches these functions. +//! What is decided here is everything a signature cannot say: that a +//! statement is for this image's role, that it is not older than one already +//! accepted, how stale it is, and that a byte offered for staging is one the +//! signed manifest provides for, at the place it belongs. + +use std::cmp::Ordering; +use std::io::Write as _; +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; +use std::path::{Path, PathBuf}; + +pub const POINTER_MAGIC: &str = "KRYPTIK-LATEST-1"; +/// The pointer and its signature, each. +pub const POINTER_MAX: usize = 8 * 1024; +/// The manifest and its signature, each. +pub const MANIFEST_MAX: u64 = 64 * 1024; +/// A pointer older than this is reported as stale: the release process +/// re-issues it on a schedule, so its age is the only sign of a withheld one. +pub const STALE_AFTER_SECS: i64 = 30 * 86400; +/// How far ahead of this machine's clock a statement may be dated. One that +/// is dated further ahead is refused: accepted, it would make every honest +/// statement after it a replay until its date arrived, and would never read +/// as stale. A day covers a release host's clock and this one disagreeing. +pub const MAX_AHEAD_SECS: i64 = 86400; +/// One pointer is considered per hour; the rest are refused unread. +pub const POINTER_INTERVAL_SECS: u64 = 3600; + +/// A statement of what is current, after its signature has verified. +#[derive(Debug, Clone, PartialEq)] +pub struct Pointer { + pub role: String, + pub version: String, + /// Seconds since the epoch. + pub issued: i64, + pub manifest_sha256: String, + pub base: String, +} + +fn is_version(s: &str) -> bool { + !s.is_empty() && s.len() <= 32 && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'+' | b'-' | b'~')) +} + +/// The pointer's text: the magic line, then each key exactly once. A key it +/// does not know is refused rather than skipped, so a newer format cannot be +/// half-understood by an older system. +pub fn parse_pointer(text: &str) -> Result { + let mut lines = text.lines(); + if lines.next() != Some(POINTER_MAGIC) { + return Err(format!("not a {POINTER_MAGIC}")); + } + let (mut role, mut version, mut issued, mut sha, mut base) = (None, None, None, None, None); + for line in lines { + let (k, v) = line.split_once(": ").ok_or_else(|| format!("not a `key: value` line: {line:?}"))?; + let slot = match k { + "role" => &mut role, + "version" => &mut version, + "issued" => &mut issued, + "manifest-sha256" => &mut sha, + "base" => &mut base, + _ => return Err(format!("unknown key {k:?}")), + }; + if slot.replace(v.to_string()).is_some() { + return Err(format!("{k} is given twice")); + } + } + let need = |o: Option, k: &str| o.ok_or_else(|| format!("no {k}")); + let (role, version, issued, sha, base) = + (need(role, "role")?, need(version, "version")?, need(issued, "issued")?, need(sha, "manifest-sha256")?, need(base, "base")?); + if !is_version(&version) { + return Err(format!("{version:?} is not a version")); + } + let issued = crate::time::parse_iso8601(&issued).ok_or_else(|| format!("issued {issued:?} is not a date"))?; + if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) { + return Err("manifest-sha256 is not 64 lowercase hex digits".into()); + } + if base.is_empty() || base.len() > 512 || !base.bytes().all(|b| (0x21..=0x7e).contains(&b)) { + return Err("base must be 1 to 512 printable characters without spaces".into()); + } + Ok(Pointer { role, version, issued, manifest_sha256: sha, base }) +} + +/// Where a release's files are fetched from. An absolute base is taken as it +/// is; a relative one is resolved against the channel address from the +/// verified root, never against anything the net zone reports. Only a +/// development image may be pointed at plain http. Where the bytes come from +/// decides nothing about what they must be - the pointer carries the +/// manifest's hash - so this is about not leaking the request, not trust. +pub fn resolve_base(channel: &str, base: &str, role: &str) -> Result { + let mut url = if base.contains("://") { + base.to_string() + } else { + if base.starts_with('/') || base.split('/').any(|c| c == "..") { + return Err(format!("relative base {base:?} must stay under the channel address")); + } + format!("{}/{}", channel.trim_end_matches('/'), base) + }; + if !url.ends_with('/') { + url.push('/'); + } + match url.split_once("://").map(|(scheme, _)| scheme) { + Some("https") => Ok(url), + Some("http") if role == "development" => Ok(url), + Some("http") => Err("a production image does not fetch over plain http".into()), + _ => Err(format!("{url:?} is neither https nor http")), + } +} + +/// Versions compare the way `sort -V` orders them for the release tool: runs +/// of digits as numbers, everything else byte by byte. +pub fn version_cmp(a: &str, b: &str) -> Ordering { + let (a, b) = (a.as_bytes(), b.as_bytes()); + let (mut i, mut j) = (0, 0); + while i < a.len() && j < b.len() { + if a[i].is_ascii_digit() && b[j].is_ascii_digit() { + let run = |s: &[u8], from: usize| (from..s.len()).find(|&k| !s[k].is_ascii_digit()).unwrap_or(s.len()); + let (ie, je) = (run(a, i), run(b, j)); + let strip = |s: &[u8]| s.iter().position(|&c| c != b'0').map_or(&s[s.len()..], |p| &s[p..]).to_vec(); + let (x, y) = (strip(&a[i..ie]), strip(&b[j..je])); + match x.len().cmp(&y.len()).then_with(|| x.cmp(&y)) { + Ordering::Equal => {} + o => return o, + } + (i, j) = (ie, je); + } else { + match a[i].cmp(&b[j]) { + Ordering::Equal => {} + o => return o, + } + (i, j) = (i + 1, j + 1); + } + } + (a.len() - i).cmp(&(b.len() - j)) +} + +/// What an accepted pointer says about this machine. +#[derive(Debug, PartialEq)] +pub enum Standing { + /// It names the running release, or an older one. + Current, + Available(String), +} + +/// Whether zone 0 accepts a verified pointer. The signature said who wrote +/// it; this says whether it is for this image and whether it is a replay: +/// an `issued` earlier than the newest one already accepted is refused +/// however valid its signature, and so is one dated more than +/// `MAX_AHEAD_SECS` after `now`. +pub fn accept_pointer(p: &Pointer, required_role: &str, running: &str, newest_issued: Option, now: i64) -> Result { + if p.role != required_role { + return Err(format!("the pointer's role is '{}'; this image requires '{required_role}'", p.role)); + } + if p.issued > now.saturating_add(MAX_AHEAD_SECS) { + return Err("dated more than a day after this machine's clock: refused, or set the clock".into()); + } + if let Some(seen) = newest_issued { + if p.issued < seen { + return Err("older than a statement this machine has already accepted: a replay".into()); + } + } + Ok(if version_cmp(&p.version, running) == Ordering::Greater { Standing::Available(p.version.clone()) } else { Standing::Current }) +} + +/// How many whole days old the newest accepted pointer is, and whether that +/// is past the bound. A clock behind the pointer reads as zero days. +pub fn staleness(now: i64, issued: i64) -> (i64, bool) { + let age = (now - issued).max(0); + (age / 86400, age > STALE_AFTER_SECS) +} + +/// One file of a release, from the verified manifest. +#[derive(Debug, Clone, PartialEq)] +pub struct Entry { + pub name: String, + pub size: u64, +} + +/// The file list `kryptik-update check-manifest` prints for a manifest it +/// has verified: `file ` per line, other lines ignored. Names +/// are held to the rule for anything that crosses the broker. +pub fn parse_file_list(text: &str) -> Result, String> { + let mut out: Vec = Vec::new(); + for line in text.lines() { + let Some(rest) = line.strip_prefix("file ") else { continue }; + let (size, name) = rest.split_once(' ').ok_or_else(|| format!("not `file `: {line:?}"))?; + let size: u64 = size.parse().map_err(|_| format!("{size:?} is not a size"))?; + crate::broker::check_transfer_name(name)?; + if name == "manifest" || name == "manifest.sig" || out.iter().any(|e| e.name == name) { + return Err(format!("the manifest lists {name:?}, which it cannot")); + } + out.push(Entry { name: name.to_string(), size }); + } + if out.is_empty() { + return Err("the manifest lists no files".into()); + } + Ok(out) +} + +pub fn total_bytes(files: &[Entry]) -> u64 { + files.iter().fold(0, |sum, e| sum.saturating_add(e.size)) +} + +/// Whether `len` bytes offered for `name` at `offset` may be written, given +/// how many bytes of it are already held. `files` is `None` until the +/// manifest and its signature have verified, and until then only those two +/// are taken: whole, from byte zero, small. After that: a listed name, at +/// exactly the offset held (so a broken download resumes and nothing is +/// written twice or out of order), never past the signed size. +pub fn may_put(files: Option<&[Entry]>, name: &str, offset: u64, len: u64, held: u64) -> Result<(), String> { + if len == 0 { + return Err("nothing to put".into()); + } + let end = offset.checked_add(len).ok_or("offset and length overflow")?; + if name == "manifest" || name == "manifest.sig" { + if files.is_some() { + return Err(format!("{name} has been verified; it is not replaced")); + } + if offset != 0 || end > MANIFEST_MAX { + return Err(format!("{name} is put whole, from byte 0, in at most {MANIFEST_MAX} bytes")); + } + return Ok(()); + } + let files = files.ok_or("nothing is accepted before the manifest and its signature have verified")?; + let e = files.iter().find(|e| e.name == name).ok_or_else(|| format!("the signed manifest does not list {name:?}"))?; + if offset != held { + return Err(format!("{name}: {held} bytes are held; the next byte wanted is {held}, not {offset}")); + } + if end > e.size { + return Err(format!("{name}: the signed manifest gives it {} bytes; {end} would be past that", e.size)); + } + Ok(()) +} + +/// What is still missing and from which byte: the answer to `update-poll`. +pub fn still_needed(files: &[Entry], held: impl Fn(&str) -> u64) -> Vec<(String, u64)> { + files.iter().filter_map(|e| { let h = held(&e.name); (h < e.size).then(|| (e.name.clone(), h)) }).collect() +} + +// --- what zone 0 keeps, and what the broker's three verbs do with it ------- +// +// Under `STATE_DIR`, root's and nobody else's: +// +// pointer the newest statement accepted, as it was signed +// considered when a statement was last looked at (the rate limit) +// wanted the version the person asked for (`kryptik update fetch`) +// files `check-manifest`'s output for it, once it has verified +// incoming// the staged release: the directory `apply` is given +// +// Every function takes the directory and the two checks, so the tests run +// them against a temporary directory with checks of their own. + + +pub const STATE_DIR: &str = "/var/lib/kryptik/update"; +pub const TOOL: &str = "/usr/sbin/kryptik-update"; +pub const ROLE_FILE: &str = "/usr/share/kryptik/trust/required-role"; +pub const CONF: &str = "/etc/kryptik/update.conf"; +/// The most one `update-put` carries. A release crosses in pieces this size, +/// each one request the launcher answers between two looks at its zone, so +/// the zone's supervision is never further away than one piece. +pub const PUT_MAX: usize = 1 << 20; + +/// The two things only a signature can say, as functions so that a test can +/// stand in for `kryptik-update`. `pointer` verifies a statement and its +/// signature; `manifest` verifies the manifest and signature in a directory +/// and returns what `check-manifest` printed. +pub struct Checks<'a> { + pub pointer: &'a dyn Fn(&Path, &Path) -> Result<(), String>, + pub manifest: &'a dyn Fn(&Path) -> Result, +} + +fn run_tool(args: &[&std::ffi::OsStr]) -> Result { + let out = std::process::Command::new(TOOL) + .args(args) + .env_clear() + .env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin") + .stdin(std::process::Stdio::null()) + .output() + .map_err(|e| format!("{TOOL}: {e}"))?; + if out.status.success() { + return Ok(String::from_utf8_lossy(&out.stdout).into_owned()); + } + let err = String::from_utf8_lossy(&out.stderr); + Err(err.lines().last().unwrap_or("refused").trim_start_matches("kryptik-update: ").to_string()) +} + +/// The checks the installed system uses: `kryptik-update`, with the trust +/// anchor on the verified root and nothing from this process's environment. +pub fn tool_checks() -> Checks<'static> { + Checks { + pointer: &|p, s| run_tool(&["check-pointer".as_ref(), p.as_os_str(), s.as_os_str()]).map(|_| ()), + manifest: &|d| run_tool(&["check-manifest".as_ref(), d.as_os_str()]), + } +} + +pub fn required_role() -> String { + std::fs::read_to_string(ROLE_FILE).map(|s| s.trim().to_string()).unwrap_or_else(|_| "development".into()) +} + +pub fn running_version() -> String { + let text = std::fs::read_to_string("/etc/os-release").unwrap_or_default(); + text.lines().find_map(|l| l.strip_prefix("VERSION_ID=")).map(|v| v.trim_matches('"').to_string()).unwrap_or_default() +} + +/// `channel =
` from the configuration on the verified root. +pub fn channel_from(conf: &str) -> Option { + conf.lines().find_map(|l| { + let (k, v) = l.split_once('=')?; + (k.trim() == "channel" && !v.trim().is_empty()).then(|| v.trim().to_string()) + }) +} + +/// A directory that is this process's own and nobody else's, made or found. +/// One that was already there is looked at, not believed: a link, another +/// owner or a mode that lets anyone else in is refused. +fn private_dir(p: &Path) -> Result<(), String> { + use std::os::unix::fs::MetadataExt; + match std::fs::DirBuilder::new().recursive(true).mode(0o700).create(p) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(format!("{}: {e}", p.display())), + } + let m = std::fs::symlink_metadata(p).map_err(|e| format!("{}: {e}", p.display()))?; + if !m.is_dir() || m.uid() != unsafe { libc::geteuid() } || m.mode() & 0o077 != 0 { + return Err(format!("{}: not a directory of this user's alone", p.display())); + } + Ok(()) +} + +/// Written whole and renamed into place, readable by root alone. +fn put_file(path: &Path, bytes: &[u8]) -> Result<(), String> { + let tmp = path.with_extension("tmp"); + let _ = std::fs::remove_file(&tmp); + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).custom_flags(libc::O_NOFOLLOW).open(&tmp) + .map_err(|e| format!("{}: {e}", tmp.display()))?; + f.write_all(bytes).and_then(|_| f.sync_all()).map_err(|e| format!("{}: {e}", tmp.display()))?; + std::fs::rename(&tmp, path).map_err(|e| format!("{}: {e}", path.display()))?; + // The name is the directory's to remember: without this the rename can be + // lost to a power cut although the file's own bytes were synced. + let parent = path.parent().unwrap_or(Path::new(".")); + std::fs::File::open(parent).and_then(|d| d.sync_all()).map_err(|e| format!("{}: {e}", parent.display())) +} + +fn stored_pointer(dir: &Path) -> Option { + parse_pointer(&std::fs::read_to_string(dir.join("pointer")).ok()?).ok() +} + +fn wanted(dir: &Path) -> Option { + let v = std::fs::read_to_string(dir.join("wanted")).ok()?.trim().to_string(); + is_version(&v).then_some(v) +} + +fn staging(dir: &Path, version: &str) -> PathBuf { + dir.join("incoming").join(version) +} + +fn held(stage: &Path, name: &str) -> u64 { + std::fs::symlink_metadata(stage.join(name)).ok().filter(|m| m.is_file()).map_or(0, |m| m.len()) +} + +fn verified_files(dir: &Path, version: &str) -> Option> { + let text = std::fs::read_to_string(dir.join("files")).ok()?; + (text.lines().next() == Some(&format!("version: {version}"))).then(|| parse_file_list(&text).ok()).flatten() +} + +/// `update-latest`: a statement of what is current and its signature, from +/// the net zone. One is looked at per interval, whatever becomes of it, so a +/// hostile zone cannot make zone 0 verify signatures all day. +pub fn latest(dir: &Path, checks: &Checks, now: i64, role: &str, running: &str, pointer: &[u8], sig: &[u8]) -> Result { + private_dir(dir)?; + let last: Option = std::fs::read_to_string(dir.join("considered")).ok().and_then(|s| s.trim().parse().ok()); + if last.is_some_and(|t| (now - t).unsigned_abs() < POINTER_INTERVAL_SECS) { + return Err(format!("one statement is considered every {} minutes", POINTER_INTERVAL_SECS / 60)); + } + put_file(&dir.join("considered"), now.to_string().as_bytes())?; + let text = std::str::from_utf8(pointer).map_err(|_| "the pointer is not text".to_string())?; + // What it says is judged only after who said it: a parse error must not + // tell an unsigned sender anything a signed one would not also see. + let scratch = dir.join("checking"); + let _ = std::fs::remove_dir_all(&scratch); + private_dir(&scratch)?; + let verdict = put_file(&scratch.join("latest"), pointer) + .and_then(|_| put_file(&scratch.join("latest.sig"), sig)) + .and_then(|_| (checks.pointer)(&scratch.join("latest"), &scratch.join("latest.sig"))); + let _ = std::fs::remove_dir_all(&scratch); + verdict?; + let p = parse_pointer(text)?; + let standing = accept_pointer(&p, role, running, stored_pointer(dir).map(|q| q.issued), now)?; + put_file(&dir.join("pointer"), pointer)?; + Ok(standing) +} + +/// `kryptik update fetch`: the person asks for the release the newest +/// accepted statement names. Nothing is fetched that was not asked for. +pub fn want(dir: &Path, running: &str) -> Result { + let p = stored_pointer(dir).ok_or("no statement of what is current has been accepted yet")?; + if version_cmp(&p.version, running) != Ordering::Greater { + return Err(format!("{} is the newest release known, and this machine runs {running}", p.version)); + } + if wanted(dir).as_deref() != Some(p.version.as_str()) { + let _ = std::fs::remove_file(dir.join("files")); + let _ = std::fs::remove_dir_all(dir.join("incoming")); + } + put_file(&dir.join("wanted"), p.version.as_bytes())?; + Ok(p.version) +} + +/// What is wanted, where from, and what of it is still missing: `None` when +/// nothing is, which the broker says as `idle`. +fn outstanding(dir: &Path, channel: &str, role: &str, running: &str) -> Option<(Pointer, String, Vec<(String, u64)>)> { + let version = wanted(dir)?; + let p = stored_pointer(dir).filter(|p| p.version == version)?; + if version_cmp(&version, running) != Ordering::Greater { + return None; + } + let base = resolve_base(channel, &p.base, role).ok()?; + let stage = staging(dir, &version); + let need = match verified_files(dir, &version) { + Some(files) => still_needed(&files, |n| held(&stage, n)), + None => ["manifest", "manifest.sig"].iter().filter(|n| held(&stage, n) == 0).map(|n| (n.to_string(), 0)).collect(), + }; + Some((p, base, need)) +} + +/// `update-poll`: the net zone asks, because nothing can call it. +pub fn poll(dir: &Path, channel: &str, role: &str, running: &str) -> String { + match outstanding(dir, channel, role, running) { + Some((p, base, need)) if !need.is_empty() => { + let list: Vec = need.iter().map(|(n, o)| format!("{n} {o}")).collect(); + format!("fetch {} {base} need {}", p.version, list.join(" ")) + } + _ => "idle".into(), + } +} + +fn free_bytes(path: &Path) -> Option { + use std::os::unix::ffi::OsStrExt; + let c = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?; + let mut st: libc::statvfs = unsafe { std::mem::zeroed() }; + (unsafe { libc::statvfs(c.as_ptr(), &mut st) } == 0).then(|| st.f_bavail as u64 * st.f_frsize as u64) +} + +/// `update-put`: bytes for the release that is wanted, under `may_put`'s +/// rule. When the manifest and its signature are both there they are +/// verified, held to the hash the accepted pointer announced, and measured +/// against the room there is; only then is anything they list accepted. +pub fn put(dir: &Path, checks: &Checks, now: i64, name: &str, offset: u64, bytes: &[u8]) -> Result { + let version = wanted(dir).ok_or("no release has been asked for")?; + let p = stored_pointer(dir).filter(|p| p.version == version).ok_or("the release asked for is not the one the newest statement names")?; + let stage = staging(dir, &version); + let files = verified_files(dir, &version); + may_put(files.as_deref(), name, offset, bytes.len() as u64, held(&stage, name))?; + private_dir(&stage)?; + let path = stage.join(name); + if files.is_none() { + let _ = std::fs::remove_file(&path); + } + let mut f = std::fs::OpenOptions::new().append(true).create(true).mode(0o600).custom_flags(libc::O_NOFOLLOW).open(&path) + .map_err(|e| format!("{name}: {e}"))?; + f.write_all(bytes).map_err(|e| format!("{name}: {e}"))?; + drop(f); + if let Some(files) = files { + let size = files.iter().find(|e| e.name == name).map_or(0, |e| e.size); + let have = held(&stage, name); + return Ok(if have == size { format!("{name} complete") } else { format!("{name} {have}/{size}") }); + } + if held(&stage, "manifest") == 0 || held(&stage, "manifest.sig") == 0 { + return Ok(format!("{name} complete")); + } + let refuse = |why: String| -> Result { + let _ = std::fs::remove_dir_all(&stage); + Err(why) + }; + // One refused manifest per interval, as one statement is looked at per + // interval: a refusal clears the stage, so without this a hostile zone + // could feed the verifier's parser a new pair as fast as it could send. + let refused: Option = std::fs::read_to_string(dir.join("refused")).ok().and_then(|s| s.trim().parse().ok()); + if refused.is_some_and(|t| (now - t).unsigned_abs() < POINTER_INTERVAL_SECS) { + return refuse(format!("a manifest was refused less than {} minutes ago", POINTER_INTERVAL_SECS / 60)); + } + let listing = match (checks.manifest)(&stage) { + Ok(l) => l, + Err(why) => { + put_file(&dir.join("refused"), now.to_string().as_bytes())?; + return refuse(why); + } + }; + if listing.lines().next() != Some(&format!("version: {version}")) { + return refuse(format!("the manifest is not for {version}")); + } + if !listing.lines().any(|l| l.strip_prefix("sha256: ") == Some(p.manifest_sha256.as_str())) { + return refuse("the manifest is not the one the statement of what is current announced".into()); + } + let files = match parse_file_list(&listing) { + Ok(f) => f, + Err(why) => return refuse(why), + }; + let (need, free) = (total_bytes(&files), free_bytes(&stage).unwrap_or(0)); + if need > free { + return refuse(format!("the release is {need} bytes and there is room for {free}")); + } + put_file(&dir.join("files"), listing.as_bytes())?; + Ok(format!("{name} complete; the manifest verifies, {} file(s), {need} bytes", files.len())) +} + +/// `kryptik update status`, as lines for a person. +pub fn status(dir: &Path, now: i64, running: &str) -> String { + let mut out = format!("running {running}\n"); + match stored_pointer(dir) { + None => out.push_str("newest unknown: no statement of what is current has been accepted\n"), + Some(p) => { + let (days, stale) = staleness(now, p.issued); + out.push_str(&format!("newest {} (stated {days} day(s) ago)\n", p.version)); + if stale { + out.push_str(&format!( + " no statement from the release key for {days} days: either nothing has been published,\n or something is keeping it from this machine\n" + )); + } + } + } + match wanted(dir) { + None => out.push_str("staged nothing asked for\n"), + Some(v) => { + let stage = staging(dir, &v); + match verified_files(dir, &v) { + None => out.push_str(&format!("staged {v}: waiting for its manifest\n")), + Some(files) => { + let have: u64 = files.iter().map(|e| held(&stage, &e.name).min(e.size)).sum(); + let total = total_bytes(&files); + let word = if have == total { "complete; `kryptik update apply` installs it" } else { "arriving" }; + out.push_str(&format!("staged {v}: {have} of {total} bytes, {word}\n")); + } + } + } + } + out +} + +/// The staged release's directory when every byte of it has arrived: what +/// `kryptik update apply` hands to `kryptik-update apply`. +pub fn complete_stage(dir: &Path) -> Result { + let v = wanted(dir).ok_or("no release has been asked for")?; + let files = verified_files(dir, &v).ok_or_else(|| format!("{v}: its manifest has not arrived"))?; + let stage = staging(dir, &v); + match still_needed(&files, |n| held(&stage, n)).first() { + None => Ok(stage), + Some((name, at)) => Err(format!("{v}: {name} has {at} bytes so far; the release is still arriving")), + } +} + +/// Once the machine runs what was staged, the staging area has no job. +pub fn forget_if_installed(dir: &Path, running: &str) { + if wanted(dir).is_some_and(|v| version_cmp(&v, running) != Ordering::Greater) { + for f in ["wanted", "files"] { + let _ = std::fs::remove_file(dir.join(f)); + } + let _ = std::fs::remove_dir_all(dir.join("incoming")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + fn pointer_text(version: &str, issued: &str) -> String { + format!("{POINTER_MAGIC}\nrole: production\nversion: {version}\nissued: {issued}\nmanifest-sha256: {SHA}\nbase: {version}/\n") + } + + #[test] + fn a_pointer_parses_and_anything_else_does_not() { + let p = parse_pointer(&pointer_text("1.0.3", "2027-03-02T14:05:00+00:00")).unwrap(); + assert_eq!((p.role.as_str(), p.version.as_str(), p.base.as_str()), ("production", "1.0.3", "1.0.3/")); + assert_eq!(p.issued, crate::time::parse_iso8601("2027-03-02T14:05:00Z").unwrap()); + let good = pointer_text("1.0.3", "2027-03-02T14:05:00Z"); + for (what, bad) in [ + ("a manifest's magic", good.replace(POINTER_MAGIC, "KRYPTIK-MANIFEST-1")), + ("a key twice", format!("{good}role: development\n")), + ("an unknown key", format!("{good}mirror: https://elsewhere/\n")), + ("no issued", good.replace("issued: 2027-03-02T14:05:00Z\n", "")), + ("a date that is not one", good.replace("2027-03-02T14:05:00Z", "yesterday")), + ("a short hash", good.replace(SHA, &SHA[..63])), + ("an uppercase hash", good.replace(SHA, &SHA.to_uppercase())), + ("a version with a space", good.replace("version: 1.0.3", "version: 1.0 3")), + ("a base with a space", good.replace("base: 1.0.3/", "base: 1.0.3/ x")), + ] { + assert!(parse_pointer(&bad).is_err(), "{what} was accepted"); + } + } + + #[test] + fn versions_order_as_the_release_tool_orders_them() { + for (a, b) in [("1.0.3", "1.0.10"), ("1.9", "1.10"), ("1.0", "1.0.1"), ("0.9.9", "1.0"), ("1.0-rc1", "1.0-rc2"), ("1.02", "1.3")] { + assert_eq!(version_cmp(a, b), Ordering::Less, "{a} < {b}"); + assert_eq!(version_cmp(b, a), Ordering::Greater, "{b} > {a}"); + } + assert_eq!(version_cmp("1.0.3", "1.0.3"), Ordering::Equal); + assert_eq!(version_cmp("1.01", "1.1"), Ordering::Equal); + } + + #[test] + fn a_pointer_is_accepted_for_this_role_and_never_backwards() { + let p = parse_pointer(&pointer_text("1.0.3", "2027-03-02T14:05:00Z")).unwrap(); + assert_eq!(accept_pointer(&p, "production", "1.0.2", None, p.issued), Ok(Standing::Available("1.0.3".into()))); + assert_eq!(accept_pointer(&p, "production", "1.0.3", None, p.issued), Ok(Standing::Current)); + // An older release named by a newer statement is not an update. + assert_eq!(accept_pointer(&p, "production", "1.1.0", None, p.issued), Ok(Standing::Current)); + assert!(accept_pointer(&p, "development", "1.0.2", None, p.issued).unwrap_err().contains("role")); + // The same statement again is fine: that is what a re-issue looks + // like to a machine that polls more often than the schedule. + assert!(accept_pointer(&p, "production", "1.0.2", Some(p.issued), p.issued).is_ok()); + assert!(accept_pointer(&p, "production", "1.0.2", Some(p.issued + 1), p.issued).unwrap_err().contains("replay")); + // Dated ahead of the clock: a day is tolerated, more is refused, so + // one bad date cannot make every later statement a replay. + assert!(accept_pointer(&p, "production", "1.0.2", None, p.issued - MAX_AHEAD_SECS).is_ok()); + assert!(accept_pointer(&p, "production", "1.0.2", None, p.issued - MAX_AHEAD_SECS - 1).unwrap_err().contains("clock")); + } + + #[test] + fn a_pointer_goes_stale_after_the_bound_and_not_before() { + assert_eq!(staleness(1000 + STALE_AFTER_SECS, 1000), (30, false)); + assert_eq!(staleness(1001 + STALE_AFTER_SECS, 1000), (30, true)); + assert_eq!(staleness(500, 1000), (0, false)); + } + + #[test] + fn a_base_resolves_against_the_verified_channel_only() { + let ch = "https://updates.example/stable"; + assert_eq!(resolve_base(ch, "1.0.3/", "production").unwrap(), "https://updates.example/stable/1.0.3/"); + assert_eq!(resolve_base(&format!("{ch}/"), "1.0.3", "production").unwrap(), "https://updates.example/stable/1.0.3/"); + assert_eq!(resolve_base(ch, "https://mirror.example/k/1.0.3/", "production").unwrap(), "https://mirror.example/k/1.0.3/"); + assert!(resolve_base(ch, "../other/1.0.3/", "production").is_err()); + assert!(resolve_base(ch, "/etc/", "production").is_err()); + assert!(resolve_base(ch, "http://mirror.example/1.0.3/", "production").is_err()); + assert!(resolve_base(ch, "http://10.0.2.2:8080/1.0.3/", "development").is_ok()); + assert!(resolve_base(ch, "file:///var/lib/", "development").is_err()); + } + + fn files() -> Vec { + parse_file_list("version: 1.0.3\nfile 1000 kryptik-root.img\nfile 40 kryptik-a.efi\nfile 40 kryptik-b.efi\nfile 9 root.json\n").unwrap() + } + + #[test] + fn the_file_list_is_the_verified_manifests_and_nothing_odd() { + assert_eq!(total_bytes(&files()), 1089); + for bad in ["file 10 ../x\n", "file 10 .hidden\n", "file ten x\n", "file 10 manifest\n", "file 1 a\nfile 2 a\n", "version: 1\n", "file 10 a b\n"] { + assert!(parse_file_list(bad).is_err(), "{bad:?} was accepted"); + } + } + + #[test] + fn nothing_large_is_taken_before_the_manifest_has_verified() { + assert!(may_put(None, "manifest", 0, 4096, 0).is_ok()); + assert!(may_put(None, "manifest.sig", 0, MANIFEST_MAX, 0).is_ok()); + assert!(may_put(None, "manifest", 0, MANIFEST_MAX + 1, 0).is_err()); + assert!(may_put(None, "manifest", 1, 10, 0).is_err()); + assert!(may_put(None, "kryptik-root.img", 0, 10, 0).unwrap_err().contains("before the manifest")); + // And once it has, the manifest is what was verified, for good. + assert!(may_put(Some(&files()), "manifest", 0, 10, 0).is_err()); + } + + #[test] + fn bytes_are_taken_only_where_the_signed_manifest_provides_for_them() { + let f = files(); + assert!(may_put(Some(&f), "kryptik-root.img", 0, 1000, 0).is_ok()); + assert!(may_put(Some(&f), "kryptik-root.img", 600, 400, 600).is_ok()); + assert!(may_put(Some(&f), "kryptik-root.img", 600, 401, 600).unwrap_err().contains("past that")); + assert!(may_put(Some(&f), "kryptik-root.img", 0, 10, 600).unwrap_err().contains("600 bytes are held")); + assert!(may_put(Some(&f), "kryptik-root.img", 700, 10, 600).is_err()); + assert!(may_put(Some(&f), "kryptik-root.img", 1000, 1, 1000).is_err()); + assert!(may_put(Some(&f), "stowaway", 0, 1, 0).unwrap_err().contains("does not list")); + assert!(may_put(Some(&f), "root.json", 0, 0, 0).is_err()); + assert!(may_put(Some(&f), "root.json", u64::MAX, 2, u64::MAX).is_err()); + } + + #[test] + fn a_poll_names_what_is_missing_and_from_which_byte() { + let held = |n: &str| match n { "kryptik-root.img" => 600, "kryptik-a.efi" => 40, _ => 0 }; + assert_eq!( + still_needed(&files(), held), + vec![("kryptik-root.img".to_string(), 600), ("kryptik-b.efi".to_string(), 0), ("root.json".to_string(), 0)] + ); + assert!(still_needed(&files(), |_| u64::MAX).is_empty()); + } + + // --- the state, against a directory of the test's own --- + + fn scratch(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("kryptik-update-test-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d + } + + const LISTING: &str = "version: 1.0.3\nsha256: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\nfile 10 kryptik-root.img\nfile 4 root.json\n"; + + fn yes() -> Checks<'static> { + Checks { pointer: &|_, _| Ok(()), manifest: &|_| Ok(LISTING.to_string()) } + } + + /// An hour after the statements these tests use were issued. + const T0: i64 = 1_804_000_000; + const CH: &str = "https://updates.example/stable"; + + #[test] + fn a_statement_is_stored_only_when_it_verifies_is_new_and_is_due() { + let d = scratch("latest"); + let p = pointer_text("1.0.3", "2027-03-02T14:05:00Z"); + let no = Checks { pointer: &|_, _| Err("the pointer signature does NOT verify".into()), manifest: &|_| Err("unused".into()) }; + assert!(latest(&d, &no, T0, "production", "1.0.2", p.as_bytes(), b"sig").unwrap_err().contains("does NOT verify")); + assert!(stored_pointer(&d).is_none(), "an unverified statement was stored"); + // Looking at that one used the interval up, whoever sent it. + assert!(latest(&d, &yes(), T0 + 60, "production", "1.0.2", p.as_bytes(), b"sig").unwrap_err().contains("every 60 minutes")); + let t1 = T0 + POINTER_INTERVAL_SECS as i64; + assert_eq!(latest(&d, &yes(), t1, "production", "1.0.2", p.as_bytes(), b"sig"), Ok(Standing::Available("1.0.3".into()))); + assert_eq!(stored_pointer(&d).unwrap().version, "1.0.3"); + assert!(!d.join("checking").exists(), "the scratch copy outlived the check"); + // Last year's statement, validly signed, an interval later: a replay. + let old = pointer_text("1.0.1", "2026-03-02T14:05:00Z"); + let t2 = t1 + POINTER_INTERVAL_SECS as i64; + assert!(latest(&d, &yes(), t2, "production", "1.0.2", old.as_bytes(), b"sig").unwrap_err().contains("replay")); + assert_eq!(stored_pointer(&d).unwrap().version, "1.0.3"); + // Next year's, validly signed: stored, it would make every honest + // statement until then a replay. It is refused and nothing changes. + let ahead = pointer_text("9.9.9", "2028-03-02T14:05:00Z"); + let t3 = t2 + POINTER_INTERVAL_SECS as i64; + assert!(latest(&d, &yes(), t3, "production", "1.0.2", ahead.as_bytes(), b"sig").unwrap_err().contains("clock")); + assert_eq!(stored_pointer(&d).unwrap().version, "1.0.3"); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_release_is_staged_in_the_order_that_bounds_it() { + let d = scratch("stage"); + let p = pointer_text("1.0.3", "2027-03-02T14:05:00Z"); + // Nothing asked for: nothing polled for, nothing taken. + assert_eq!(poll(&d, CH, "production", "1.0.2"), "idle"); + assert!(want(&d, "1.0.2").unwrap_err().contains("no statement")); + latest(&d, &yes(), T0, "production", "1.0.2", p.as_bytes(), b"sig").unwrap(); + assert_eq!(poll(&d, CH, "production", "1.0.2"), "idle", "fetching began before the person asked"); + assert!(put(&d, &yes(), T0, "manifest", 0, b"m").unwrap_err().contains("no release has been asked for")); + assert_eq!(want(&d, "1.0.2").unwrap(), "1.0.3"); + assert!(want(&d, "1.0.3").unwrap_err().contains("newest release known")); + + assert_eq!(poll(&d, CH, "production", "1.0.2"), "fetch 1.0.3 https://updates.example/stable/1.0.3/ need manifest 0 manifest.sig 0"); + assert!(put(&d, &yes(), T0, "kryptik-root.img", 0, b"0123456789").unwrap_err().contains("before the manifest")); + assert_eq!(put(&d, &yes(), T0, "manifest", 0, b"the manifest").unwrap(), "manifest complete"); + assert_eq!(poll(&d, CH, "production", "1.0.2"), "fetch 1.0.3 https://updates.example/stable/1.0.3/ need manifest.sig 0"); + assert!(put(&d, &yes(), T0, "manifest.sig", 0, b"its signature").unwrap().contains("the manifest verifies, 2 file(s), 14 bytes")); + + assert_eq!(poll(&d, CH, "production", "1.0.2"), "fetch 1.0.3 https://updates.example/stable/1.0.3/ need kryptik-root.img 0 root.json 0"); + assert!(put(&d, &yes(), T0, "manifest", 0, b"another").unwrap_err().contains("not replaced")); + assert!(put(&d, &yes(), T0, "stowaway", 0, b"x").unwrap_err().contains("does not list")); + assert_eq!(put(&d, &yes(), T0, "kryptik-root.img", 0, b"01234").unwrap(), "kryptik-root.img 5/10"); + // The connection dropped; the net zone is told where to resume, and + // anything else is refused without a byte being written. + assert_eq!(poll(&d, CH, "production", "1.0.2"), "fetch 1.0.3 https://updates.example/stable/1.0.3/ need kryptik-root.img 5 root.json 0"); + assert!(put(&d, &yes(), T0, "kryptik-root.img", 0, b"01234").unwrap_err().contains("5 bytes are held")); + assert!(put(&d, &yes(), T0, "kryptik-root.img", 5, b"567890").unwrap_err().contains("past that")); + assert!(complete_stage(&d).unwrap_err().contains("still arriving")); + assert_eq!(put(&d, &yes(), T0, "kryptik-root.img", 5, b"56789").unwrap(), "kryptik-root.img complete"); + assert_eq!(put(&d, &yes(), T0, "root.json", 0, b"{ }").unwrap(), "root.json complete"); + assert_eq!(poll(&d, CH, "production", "1.0.2"), "idle"); + let stage = complete_stage(&d).unwrap(); + assert_eq!(std::fs::read(stage.join("kryptik-root.img")).unwrap(), b"0123456789"); + let mut names: Vec = std::fs::read_dir(&stage).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect(); + names.sort(); + assert_eq!(names, ["kryptik-root.img", "manifest", "manifest.sig", "root.json"], "apply refuses a directory holding anything else"); + assert!(status(&d, T0, "1.0.2").contains("1.0.3: 14 of 14 bytes, complete")); + + // Once the machine runs it, the staging area is gone. + forget_if_installed(&d, "1.0.2"); + assert!(stage.exists()); + forget_if_installed(&d, "1.0.3"); + assert!(!stage.exists() && wanted(&d).is_none()); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_manifest_that_is_not_the_one_announced_is_thrown_away() { + for (tag, listing, why) in [ + ("hash", LISTING.replace("sha256: 0", "sha256: f"), "announced"), + ("version", LISTING.replace("version: 1.0.3", "version: 1.0.4"), "not for 1.0.3"), + ("room", LISTING.replace("file 10 ", "file 18446744073709551000 "), "there is room for"), + ] { + let d = scratch(tag); + let p = pointer_text("1.0.3", "2027-03-02T14:05:00Z"); + latest(&d, &yes(), T0, "production", "1.0.2", p.as_bytes(), b"sig").unwrap(); + want(&d, "1.0.2").unwrap(); + let listing_for = move |_: &Path| Ok::(listing.clone()); + let checks = Checks { pointer: &|_, _| Ok(()), manifest: &listing_for }; + put(&d, &checks, T0, "manifest", 0, b"m").unwrap(); + assert!(put(&d, &checks, T0, "manifest.sig", 0, b"s").unwrap_err().contains(why), "{tag}"); + assert!(!staging(&d, "1.0.3").exists(), "{tag}: the refused manifest was kept"); + assert_eq!(poll(&d, CH, "production", "1.0.2"), "fetch 1.0.3 https://updates.example/stable/1.0.3/ need manifest 0 manifest.sig 0", "{tag}"); + let _ = std::fs::remove_dir_all(&d); + } + let d = scratch("unsigned"); + let p = pointer_text("1.0.3", "2027-03-02T14:05:00Z"); + latest(&d, &yes(), T0, "production", "1.0.2", p.as_bytes(), b"sig").unwrap(); + want(&d, "1.0.2").unwrap(); + let no = Checks { pointer: &|_, _| Ok(()), manifest: &|_| Err("the manifest signature does NOT verify".into()) }; + put(&d, &no, T0, "manifest", 0, b"m").unwrap(); + assert!(put(&d, &no, T0, "manifest.sig", 0, b"s").unwrap_err().contains("does NOT verify")); + // The next pair is not even looked at until the interval has passed, + // whatever it is; after it, a manifest that verifies is taken. + put(&d, &yes(), T0 + 60, "manifest", 0, b"m").unwrap(); + assert!(put(&d, &yes(), T0 + 60, "manifest.sig", 0, b"s").unwrap_err().contains("minutes ago")); + let later = T0 + POINTER_INTERVAL_SECS as i64; + put(&d, &no, later, "manifest", 0, b"m").unwrap(); + assert!(put(&d, &no, later, "manifest.sig", 0, b"s").unwrap_err().contains("does NOT verify")); + assert!(put(&d, &no, T0, "kryptik-root.img", 0, b"x").unwrap_err().contains("before the manifest")); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn the_channel_address_is_read_from_the_configuration() { + assert_eq!(channel_from("# where releases are\nchannel = https://updates.example/stable\n").as_deref(), Some(CH)); + assert_eq!(channel_from("channel =\n"), None); + assert_eq!(channel_from("interval = 1\n"), None); + } +} diff --git a/compartments/tests/serve.sh b/compartments/tests/serve.sh index bbddd3f..14429f6 100755 --- a/compartments/tests/serve.sh +++ b/compartments/tests/serve.sh @@ -245,13 +245,20 @@ if [[ "$(ask 'status\n')" == "end" ]]; then pass "S5e the daemon still answers a # --- launches ------------------------------------------------------------------------------- head_ "launches" -r="$(ask "run alpha\narg /bin/sh\narg -c\narg echo $MARK; sleep 15\nend\n")" +# Between its two lines the command truncates its own stdout. That was the +# launcher's log once, O_APPEND does not stop ftruncate, and the first line +# and everything the launcher had written went with it. +r="$(ask "run alpha\narg /bin/sh\narg -c\narg echo $MARK; python3 -c 'import os; os.ftruncate(1, 0)' 2>/dev/null; echo after-$MARK; sleep 15\nend\n")" if [[ "$r" == ok\ [0-9]* ]]; then pass "S6a run alpha replies ok once the zone is up" s="$(ask 'status\n')" if [[ "$s" == *"running alpha"* ]]; then pass "S6b status shows alpha running after ok"; else fail "S6b status after ok: $s"; fi ok=0; for _ in $(seq 1 40); do grep -q "$MARK" "$ZLOG" 2>/dev/null && { ok=1; break; }; sleep 0.1; done if [[ "$ok" -eq 1 ]]; then pass "S6c the zone ran the command (its log shows $MARK)"; else fail "S6c no $MARK in $ZLOG"; fi + for _ in $(seq 1 40); do grep -q "after-$MARK" "$ZLOG" 2>/dev/null && break; sleep 0.1; done + if grep -q "^zone alpha| $MARK\$" "$ZLOG" && grep -q "^zone alpha| after-$MARK\$" "$ZLOG"; then + pass "S6c2 what the zone printed is in the log under its mark, and its attempt to empty the log emptied nothing" + else fail "S6c2 the log after the zone tried to empty it:"; sed 's/^/ /' "$ZLOG" | tail -6; fi r2="$(ask 'run alpha\narg /bin/true\nend\n')" if [[ "$r2" == "error:"* ]]; then pass "S6d a second launch of a running zone is refused: ${r2%$'\n'}"; else fail "S6d second launch: $r2"; fi r3="$(ask 'stop alpha\n')" diff --git a/compositor/wlproxy/src/main.rs b/compositor/wlproxy/src/main.rs index 9a7dc56..33bba52 100755 --- a/compositor/wlproxy/src/main.rs +++ b/compositor/wlproxy/src/main.rs @@ -22,9 +22,31 @@ mod wire; use std::os::unix::io::{AsRawFd, IntoRawFd, RawFd}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; +use std::time::{Duration, Instant}; use session::{Dir, Session}; +/// The lines a zone's clients can cause, held to a rate: the log is a file in +/// the session's runtime directory, and connecting in a loop would fill it. +struct Log { zone: String, since: Instant, lines: u32, dropped: u32 } +impl Log { + const PER_SECOND: u32 = 20; + fn line(&mut self, text: std::fmt::Arguments) { + if self.since.elapsed() >= Duration::from_secs(1) { + if self.dropped > 0 { + eprintln!("kryptik-wlproxy[{}]: {} lines not logged", self.zone, self.dropped); + } + (self.since, self.lines, self.dropped) = (Instant::now(), 0, 0); + } + if self.lines < Self::PER_SECOND { + self.lines += 1; + eprintln!("kryptik-wlproxy[{}]: {text}", self.zone); + } else { + self.dropped += 1; + } + } +} + /// Stop reading a side when the other side's unsent queue is this full. const HIGH_WATER: usize = policy::MAX_PENDING_BYTES / 2; @@ -101,6 +123,11 @@ fn main() { let mut sessions: Vec = Vec::new(); let mut next_id = 1u64; let mut served = 0u64; + let mut log = Log { zone: o.zone.clone(), since: Instant::now(), lines: 0, dropped: 0 }; + // After a failed accept the listener is left alone until this passes: the + // connection that failed is still pending, so polling it again at once + // is a loop at full speed (a client can exhaust descriptors to get there). + let mut accept_after = Instant::now(); loop { // Build the poll set: the listener, then each session's two sockets. // @@ -114,7 +141,9 @@ fn main() { // bounds on the very first client. let polled = sessions.len(); let mut fds: Vec = Vec::with_capacity(1 + 2 * polled); - fds.push(libc::pollfd { fd: listener.as_raw_fd(), events: if polled < o.max_clients { libc::POLLIN } else { 0 }, revents: 0 }); + let pause = accept_after.saturating_duration_since(Instant::now()); + let accepting = polled < o.max_clients && pause.is_zero(); + fds.push(libc::pollfd { fd: listener.as_raw_fd(), events: if accepting { libc::POLLIN } else { 0 }, revents: 0 }); for l in &sessions { let mut ce = 0i16; let mut se = 0i16; @@ -126,7 +155,8 @@ fn main() { fds.push(libc::pollfd { fd: l.s.server.fd, events: se, revents: 0 }); } debug_assert_eq!(fds.len(), 1 + 2 * polled); - let n = unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as _, -1) }; + let timeout = if pause.is_zero() { -1 } else { pause.as_millis() as libc::c_int + 1 }; + let n = unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as _, timeout) }; if n < 0 { let e = std::io::Error::last_os_error(); if e.kind() == std::io::ErrorKind::Interrupted { continue; } @@ -163,10 +193,10 @@ fn main() { Ok(()) }; if let Err(why) = step(&mut l.s, ce, se) { - eprintln!( - "kryptik-wlproxy[{}]: client #{} ended: {why} (forwarded {} requests, {} events; hid {} globals; rewrote {} identities)", - o.zone, l.id, l.s.forwarded_c2s, l.s.forwarded_s2c, l.s.hidden_count, l.s.rewritten - ); + log.line(format_args!( + "client #{} ended: {why} (forwarded {} requests, {} events; hid {} globals; rewrote {} identities)", + l.id, l.s.forwarded_c2s, l.s.forwarded_s2c, l.s.hidden_count, l.s.rewritten + )); l.s.refuse(&why); closed.push(idx); } @@ -190,17 +220,20 @@ fn main() { let sfd = up.into_raw_fd(); set_nonblocking(cfd); set_nonblocking(sfd); - eprintln!("kryptik-wlproxy[{}]: client #{next_id} connected", o.zone); + log.line(format_args!("client #{next_id} connected")); sessions.push(Live { s: Session::new(&o.zone, cfd, sfd), id: next_id }); next_id += 1; } Err(e) => { - eprintln!("kryptik-wlproxy[{}]: compositor at {} refused: {e}", o.zone, o.upstream.display()); + log.line(format_args!("compositor at {} refused: {e}", o.upstream.display())); drop(client); } }, Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} - Err(e) => eprintln!("kryptik-wlproxy[{}]: accept: {e}", o.zone), + Err(e) => { + log.line(format_args!("accept: {e}")); + accept_after = Instant::now() + Duration::from_secs(1); + } } } } diff --git a/compositor/wlproxy/src/session.rs b/compositor/wlproxy/src/session.rs index d203a0b..501439b 100644 --- a/compositor/wlproxy/src/session.rs +++ b/compositor/wlproxy/src/session.rs @@ -43,7 +43,6 @@ pub enum SessionError { TooMuchPending(Dir), TooManyFds, Io(io::Error), - PeerClosed(Dir), } impl std::fmt::Display for SessionError { @@ -53,14 +52,13 @@ impl std::fmt::Display for SessionError { SessionError::UnknownObject(id) => write!(f, "message for unknown object {id}"), SessionError::DuplicateObject(id) => write!(f, "object id {id} is already live"), SessionError::UnknownOpcode { interface, opcode } => write!(f, "{interface} has no opcode {opcode}"), - SessionError::HiddenInterface(i) => write!(f, "bind of an interface not advertised to this zone: {i}"), + SessionError::HiddenInterface(i) => write!(f, "bind of an interface not advertised to this zone: {i:?}"), SessionError::VersionTooHigh { interface, asked, max } => write!(f, "{interface} version {asked} asked, {max} allowed"), SessionError::IdOutOfRange { id, dir } => write!(f, "object id {id} is not in the {dir:?} range"), SessionError::TooManyObjects => write!(f, "too many live objects"), SessionError::TooMuchPending(d) => write!(f, "too much unsent data ({d:?})"), SessionError::TooManyFds => write!(f, "too many queued descriptors"), SessionError::Io(e) => write!(f, "{e}"), - SessionError::PeerClosed(d) => write!(f, "peer closed ({d:?})"), } } } @@ -460,9 +458,10 @@ impl Session { if iface.name == "wl_registry" && h.opcode == WL_REGISTRY_GLOBAL_REMOVE { let mut r = ArgReader::new(&msg[HEADER_LEN..]); let gname = r.u32()?; - if self.globals.remove(&gname).is_none() { - forward = false; // was hidden; the client never saw it - } + // Once per registry the client holds, so the entry + // stays: every one of them is told, and a bind that + // races the removal is the compositor's to answer. + forward = self.globals.contains_key(&gname); // hidden: never seen } if h.object == WL_DISPLAY && h.opcode == WL_DISPLAY_DELETE_ID { let mut r = ArgReader::new(&msg[HEADER_LEN..]); @@ -515,9 +514,6 @@ impl Session { self.server.close_all(); } - pub fn object_count(&self) -> usize { - self.objects.len() - } pub fn has_object(&self, id: u32) -> bool { self.objects.contains_key(&id) } @@ -610,6 +606,29 @@ mod tests { assert!(matches!(pump_all(&mut s), Err(SessionError::VersionTooHigh { .. }))); } + #[test] + fn a_removed_global_is_told_to_every_registry_and_a_hidden_one_to_none() { + let (mut s, mut c, mut sv) = make(); + c.write_all(&get_registry(2)).unwrap(); + c.write_all(&get_registry(3)).unwrap(); + pump_all(&mut s).unwrap(); + for reg in [2, 3] { + sv.write_all(&global(reg, 1, "wl_shm", 2)).unwrap(); + sv.write_all(&global(reg, 2, "zwlr_screencopy_manager_v1", 3)).unwrap(); + } + pump_all(&mut s).unwrap(); + read_all(&mut c); + let remove = |reg: u32, name: u32| MessageWriter::new(reg, WL_REGISTRY_GLOBAL_REMOVE).u32(name).finish().unwrap(); + // The compositor sends one event per registry. The first used to + // consume the entry, so the second registry was never told. + for reg in [2, 3] { + sv.write_all(&remove(reg, 1)).unwrap(); + sv.write_all(&remove(reg, 2)).unwrap(); + } + pump_all(&mut s).unwrap(); + assert_eq!(read_all(&mut c), [remove(2, 1), remove(3, 1)].concat()); + } + #[test] fn bindings_must_match_the_advertised_interface_and_version() { for (name, version) in [("wl_shm", 1), ("wl_compositor", 2), ("wl_compositor", 0)] { diff --git a/docs/BOOT_INSTALL_RECOVER.md b/docs/BOOT_INSTALL_RECOVER.md index 3ecf82d..4543bb4 100644 --- a/docs/BOOT_INSTALL_RECOVER.md +++ b/docs/BOOT_INSTALL_RECOVER.md @@ -82,7 +82,11 @@ disk that could be installed and never updated is refused. It writes, in order: partition 1 `kryptik-esp` (the medium's ESP, with the slot A kernel as the boot file), 2 `kryptik-a` (the verified root image, read back and hashed against the medium's record), 3 `kryptik-b` (empty; the first update fills -it), 4 `kryptik-state` (ext4: users, zone volumes, updates). It ends with +it), 4 `kryptik-state` (LUKS2 with ext4 inside: users, zone volumes, +updates). Before its first write it asks twice for the passphrase of the +state partition, which the system then asks for at every boot. There is no +escrow: without the passphrase, or without the partition's header, the +state is lost. It ends with `KRYPTIK_INSTALL: rc=0`. Then: ```sh @@ -99,7 +103,9 @@ installed system ignores it. ## 3. First boot and daily use -On the first boot the system runs `kryptik-firstboot` on the first console: +Every boot asks for the state passphrase on the console, three times at +most, before anything else starts; root changes it with `kryptik state +passphrase`. On the first boot the system runs `kryptik-firstboot` on the first console: it asks for a user name and password, and for root's password (root can still not log in at a terminal; the password is for `su` from the user's session). With a preseed on the control disk it creates that user instead. If the @@ -132,11 +138,11 @@ when the zone's policy allows the direction and you answer yes to the question the chrome shows. **Degraded boot.** If the system cannot find exactly one `kryptik-state` -partition on its own disk, or cannot mount it, it boots degraded: it says +partition on its own disk, or cannot unlock or mount it, it boots degraded: it says so on the console, creates no account, starts no desktop and refuses updates. Nothing on the disk is written in that state. Fix the cause -(a cloned disk attached, a relabelled partition, a damaged filesystem) and -boot again. +(a cloned disk attached, a relabelled partition, a damaged filesystem or +header) and boot again; after three wrong passphrases, just boot again. ## 4. Update @@ -179,6 +185,10 @@ kryptik-recover --disk /dev/sdY --commit-slot a # the other slot is intact: kryptik-recover --disk /dev/sdY --restore-slot a # the slot's root is damaged: rewrite it from this medium ``` +`--backup-state-header FILE` and `--restore-state-header FILE` save and put +back the state partition's LUKS2 header. Keep a backup somewhere that is not +this disk: a damaged header with no backup is a lost state partition. + `--restore-slot` writes the medium's own root image and kernel into the slot, exactly as the installer does, then commits it. The state partition is not touched: users and zone volumes survive. The result is the medium's diff --git a/docs/design/state-encryption.md b/docs/design/state-encryption.md index 2cf445e..3ac1a5e 100644 --- a/docs/design/state-encryption.md +++ b/docs/design/state-encryption.md @@ -1,6 +1,6 @@ # The state partition, encrypted -Status: design. Nothing here is built. It is the "state partition is +Status: built, waiting for its first acceptance run. It is the "state partition is encrypted" item of [version 1.0](../roadmap.md#version-10), written down before the code because three of its choices were a person's to make. They were decided on 2026-09-20, each as recommended (marked **Decided**). Builds on @@ -50,6 +50,28 @@ machine, and verified. - **The passphrase can be changed** (`kryptik state passphrase`, zone 0, root): `cryptsetup luksChangeKey` on a descriptor, the old one asked first. +## Where the code departs from the text above + +- **The prompt is `sysinit`'s own, not cryptsetup's.** cryptsetup prints its + prompt and then changes the terminal with a call that discards pending + input, so an answer sent the instant the prompt appears can be lost. + `sysinit` turns echo off first (`stty`, which discards nothing), prints + the prompt, reads one line and hands it to cryptsetup on a descriptor. +- **The console is `sysinit`'s while it runs.** The early getty is + supervised from the first moment and would read the same terminal, so + `kryptik-console` waits until `sysinit` has finished, however it ends, and + gives up waiting for it to start after 30 s. +- **A plain filesystem in the partition's place is refused, not mounted.** + Otherwise swapping the encrypted partition for an unencrypted one would + be believed without a question. +- **The suites did not gain a step each.** `vm-drive.py` answers the prompt + wherever it appears, from `KRYPTIK_STATE_PASSPHRASE`, and `run-ovmf.sh`'s + smoke mode attaches the driver too, so an undriven boot of an installed + disk is answered the same way. One unlock path, the one a person uses. +- Not yet checked by a suite: `kryptik state passphrase` and the two header + commands of `kryptik-recover` (the state suite damages and restores the + header from the host). + ## What it does and does not give Confidentiality against an offline reader: yes. Authentication: **no**. XTS diff --git a/docs/design/update-channel.md b/docs/design/update-channel.md index 94d33ef..cac7fd0 100644 --- a/docs/design/update-channel.md +++ b/docs/design/update-channel.md @@ -1,7 +1,11 @@ # An update channel -Status: design. Nothing here is built yet; it finishes the roadmap's "An -update channel". Builds on [boot and updates](boot-and-updates.md), whose +Status: implemented in `kryptikd` (`update.rs`, the broker's three verbs, +`kryptik update`), in `kryptik-update` (`check-manifest`, `check-pointer`) +and in the net zone (`update-fetch.py`), with the rules, the verbs' refusals +and the fetcher tested offline. Not yet exercised on the installed system: +the last row of the test table, and the release tooling that publishes a +pointer, are open. Builds on [boot and updates](boot-and-updates.md), whose verification it does not change, on [the broker](broker.md), which carries the bytes, and on [the clock](time.md), without which freshness means nothing. @@ -74,9 +78,11 @@ decides nothing about what they must be. It is signed in a namespace of its own, so a manifest's signature can never be replayed as a pointer nor a pointer's as a manifest. Which key signs it is an open decision (below). -The channel's address is on the verified root (`/etc/kryptik/update.conf`, -visible read-only in zones like the time sources), so the net zone is not -told where to look by anything it could have written. +The channel's address is zone 0's to give (`channel =
` in +`/etc/kryptik/update.conf`, visible read-only in the nic zone like the time +sources), so the net zone is not told where to look by anything it could +have written. With no such file there is no channel: the net zone asks +nobody and `update-poll` answers `idle`. Zone 0 accepts a pointer when its signature verifies against the same trust anchor releases are verified against, its role is the one this image @@ -150,10 +156,12 @@ authenticity rests on it. A `development` image may name an `http://` address, which is what the test network serves; a `production` one may not. A release is hundreds of megabytes through a socket the launcher also -supervises its zone with. The copy is handed to a child of the launcher, -which holds the connection and the staging file and nothing else, so -supervision, the zone's other verbs and its death are all noticed as -promptly as they are today. +supervises its zone with. It crosses in pieces of at most 1 MiB, each one +request that the launcher answers between two looks at its zone, under the +same five-second deadline as every other request. Supervision, the zone's +other verbs and its death are therefore never further away than one piece, +and there is no second process, no long-lived connection and no state +between pieces other than the staged file's length. ### The person, and what they see @@ -229,6 +237,21 @@ the choice is about how the keys are held, which is the owner's: The design above works with any of the three; only who holds which key, and whether `status` can say "stale", changes. +**What the build does meanwhile.** The trust anchor is an OpenSSH +allowed-signers file, and each line of it names the namespaces its key is +honoured in. The release key's line has always said +`namespaces="kryptik-release"`, so that key cannot sign a pointer whatever +the updater asks for: the first option is not the default, it is a line +someone would have to widen. The development build therefore makes a second +key beside the release key and enrols it as +`kryptik-latest namespaces="kryptik-latest"`: the second option, enforced by +the anchor rather than by convention. Stage 04 proves it both ways round on +every build (each key verifies in its own namespace and is refused in the +other's), and `make test-update-verify` runs the updater against an anchor +of that shape. An owner who chooses one key lists the release key on the +second line; one who chooses no schedule changes nothing here and simply +signs a pointer only when there is a release. + ## Open points - The release process that publishes `latest`, its signature and the @@ -242,9 +265,12 @@ whether `status` can say "stale", changes. ## Files -`compartments/kryptikd/src/update.rs` (pointer and staging rules), -`broker.rs` (the verbs), `serve.rs` and `tools/kryptik` (`kryptik update`), -`tools/update/kryptik-update` (`check-manifest`), a fetcher shipped for the -net zone beside `tools/net/netzone-init.sh`, `rootfs.rs` -(`/etc/kryptik/update.conf` and `/etc/ssl/cert.pem` into a zone's `/etc`), -and the rows above in the suites. +`compartments/kryptikd/src/update.rs` (the pointer and staging rules, and +what zone 0 keeps under `/var/lib/kryptik/update`), `broker.rs` (the verbs), +`serve.rs`, `tools/desktop/kryptik-launch.c` and `tools/kryptik` (`kryptik +update`), `tools/update/kryptik-update` (`check-manifest`, `check-pointer`), +`tools/net/update-fetch.py` and `tools/net/netzone-init.sh` (the net zone's +half), `rootfs.rs` (`/etc/kryptik/update.conf` into the nic zone's `/etc`; +the CA bundle under `/etc/ssl/certs` was already there), and the suites: +the `update.rs` and `broker.rs` unit tests, `make test-update-verify`, `make +test-update-fetch`, and the update rows of the boundary suite. diff --git a/docs/roadmap.md b/docs/roadmap.md index 4b5885a..1533c9e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -278,24 +278,36 @@ passes, not when its code is written. revoking them, a build that signs with a key it is handed and refuses to invent one for a release, and an installed system that accepts the next release and refuses a development build. -- [ ] **No known-vulnerable pins.** glibc now carries upstream's maintained - 2.40 branch (its security fixes through 2026-09-10) with the unwind - test as its gate. What remains: every other pin checked against its - upstream's security releases, `tools/check-support-status.sh` - extended so CI fails when a pin falls behind one, and a check that - says when the glibc branch has moved past the commit pinned here. +- [ ] **No known-vulnerable pins.** glibc carries upstream's maintained 2.40 + branch (its security fixes through 2026-09-10) with the unwind test as + its gate. Every pin that was behind has been read against its upstream + (2026-09-19): 22 moved, six are held with their reasons in + `tools/pin-reviews.tsv`, and `tools/check-pin-reviews.sh` fails CI when + a behind pin has no current review or upstream has released past the + one it has. Ticked when the rebuilt image has passed acceptance and the + six held pins are moved or patched: a release asks the gate with + `--no-held`, and it refuses them. Still to write: a check that says + when the glibc branch has moved past the commit pinned here. - [ ] **An update channel.** `kryptik-update` applies a payload from a mounted disk and nothing fetches one. The net zone downloads a release by URL into a transfer area; zone 0 verifies the manifest signature, every file and the embedded root hash exactly as it does today, and refuses a downgrade. A release process that publishes the payload, its - signature and the corresponding source. + signature and the corresponding source. Written + ([the design](design/update-channel.md)): the rules and the staging in + `kryptikd`, the broker's three verbs, `kryptik update`, the two checks + in `kryptik-update` and the net zone's fetcher, each with its offline + suite. Ticked when the update suite has fetched a release over the + test network, staged, applied and committed it on the installed + system, and the release tooling publishes a signed pointer. - [ ] **The state partition is encrypted.** `/home`, `/var` and the `/etc` overlay sit on plain ext4, so a stolen laptop gives up zone 0's home, the Wi-Fi passphrases and the zone volumes' headers. LUKS2 on `kryptik-state`, unlocked at boot by a passphrase (and later a TPM, see version 2), created by the installer, with the state test's degraded - paths still honest. + paths still honest. Written + ([the design](design/state-encryption.md)). Ticked when the install, + state and integrity suites pass with it on the installed system. - [ ] **kryptikd is built from pinned source by a pinned compiler.** Today the runner's rustc compiles it and the result is copied in (ADR-010's unresolved cost). A pinned rustc in the build (its published binary, diff --git a/docs/status.md b/docs/status.md index f6bc02e..55216c1 100644 --- a/docs/status.md +++ b/docs/status.md @@ -15,17 +15,18 @@ documentation starts a run; a newer push cancels one in flight. ## Last full pass -Revision `9749cd8` on `main`, 2026-09-16, release `0.1.20260916.9749cd84`. -Every suite passed: +Revision `55e1652` on `main`, 2026-09-20, release `0.1.20260920.55e16523.1` +(Distro run 35482105602). Every suite passed, and no suite's own summary +counts a failure: | Suite | Result | What ran | | --- | --- | --- | | inputs | PASS | revision, compositor sources, `sources.lock`, media hashes | -| build | PASS | host suites 16/0, libc unwinding 7/0, userspace smoke, artifact hardening audit, kernel config validation, upstream support status | -| boot | PASS | USB image 36/0, ISO 36/0, firmware-only boot attested from the recorded QEMU commands | -| install | PASS | install-test 43/0, state-test 47/0 | -| integrity | PASS | Secure Boot 36/0, foreign keys refused 6/0, integrity-test 23/0 | -| zones | PASS | zones-test 37/0 (network and encrypted storage on the Kryptik kernel) | +| build | PASS | host suites 18/0, libc unwinding 7/0, userspace smoke, artifact hardening audit, kernel config validation, upstream support status | +| boot | PASS | USB image 38/0, ISO 38/0, firmware-only boot attested from the recorded QEMU commands | +| install | PASS | install-test 43/0, state-test 54/0 (with the watchdog reset) | +| integrity | PASS | Secure Boot 38/0, foreign keys refused 6/0, integrity-test 26/0 | +| zones | PASS | zones-test 42/0 (network, encrypted storage and the clock on the Kryptik kernel) | | desktop | PASS | gui-test 30/0 | | update | PASS | update-test 21/0 | | release | PASS | export | @@ -33,12 +34,28 @@ Every suite passed: All of this ran under QEMU with OVMF firmware. Nothing has run on physical hardware yet. +That second sentence is there for a reason. Until 2026-09-20 the aggregator +recorded PASS for any driver that exited 0, whatever its summary counted as +failed. This run's `results.tsv` was read back against the corrected rule. + ## Since then -- `5c88a65` renamed the acceptance suites. Its Distro run failed one item, - `build / kernel-config`: `5a3d77d` had added `CONFIG_TG3`, which is not a - kernel symbol, so the Broadcom tg3 driver would silently have been left - out. The fragment now says `CONFIG_TIGON3`. Unverified until the next run. +`main` has not passed since. It carries the glibc release branch, gcc 14.4.0, +the kernel's built-in rule (drivers as modules), chrony's removal and +twenty-two version bumps, none of which a build had reached when they were +merged, and two defects stopped every build of it: + +- util-linux 2.42.3 does not compile against a glibc older than 2.43 (a + missing include, and a wrong fallback value for `RESOLVE_NO_SYMLINKS`). + Patched in `build/patches/util-linux-2.42.3/`. +- Stage 05 kept a private list of options that must survive config + resolution and accepted only `=y`; the built-in rule had made the virtio + GPU driver `=m`. The list is shared with CI's config check now. + +With the first fixed, stage 04 built every bumped package natively for the +first time. Not yet proven by any run: the kernel with drivers as modules, +its size against the budget, the update channel's fetch on the installed +system, and the encrypted state partition. ## Known gaps diff --git a/docs/threat-model.md b/docs/threat-model.md index cefdd0d..24add47 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -45,9 +45,19 @@ Confidentiality on the wire remains the application's responsibility. *Capability: offline access to the disk; boot from external media.* -**Defended at rest.** Per-zone LUKS2 volumes are meaningless without their -keys. dm-verity plus Secure Boot means a modified root filesystem or a swapped -kernel fails to boot rather than silently running. +**Defended at rest against a reader; not against a writer of the state +partition.** dm-verity plus Secure Boot means a modified root filesystem or a +swapped kernel fails to boot rather than silently running. The state partition +(`/home`, `/var`, the Wi-Fi passphrases, the shadow file, the `/etc` overlay, +the zone volumes' headers) is LUKS2 and asks for its passphrase at every boot, +and the zone volumes inside it are encrypted again, so a stolen disk gives up +none of it. It is encrypted and not authenticated: someone with the disk in +hand cannot choose what a block decrypts to, but can damage one, and cannot be +stopped from destroying the header. What the system honours from `/etc` without +asking is therefore held to a list on the verified root, not to the partition +([the state partition, encrypted](design/state-encryption.md)). The ESP, the +root slots and the LUKS header are in the clear by design: the disk says it is +Kryptik. **Not defended while running or suspended.** Keys are in RAM. See [coercion, and access to a running or suspended machine](#coercion-and-access-to-a-running-or-suspended-machine). @@ -90,8 +100,13 @@ cryptanalysis, and no software can. ### Microarchitectural side channels Shared caches and shared branch predictors permit cross-zone inference. -Mitigating this properly requires core scheduling or physical separation; -neither is implemented today. Treated as a known gap, not a solved problem. +Two things are done about the sibling-thread half of it: the signed command +line carries `nosmt`, so no two zones ever share a core's threads, and every +zone takes a core-scheduling cookie of its own, which is what would keep that +true if SMT were ever turned back on (ADR-011 in [decisions](decisions.md); +the cost of `nosmt` has not been measured on real hardware). Caches and +predictors shared between cores, and between a zone and the kernel, remain. +Treated as a known gap, not a solved problem. ### Targeted attack by a well-resourced state actor diff --git a/tools/acceptance.sh b/tools/acceptance.sh index b97d484..a1f2b9d 100755 --- a/tools/acceptance.sh +++ b/tools/acceptance.sh @@ -89,7 +89,6 @@ SYSROOT="${KRYPTIK_WORK}/sysroot" # medium instead would have tested and exported the previous release.) # Explicit --media-*/--payload-* win. A lone release is B with no A, and # the update test says so rather than running. -newest() { ls -t "$@" 2>/dev/null | head -1; } version_of_medium() { local b; b="$(basename "$1")"; b="${b#kryptik-}"; printf '%s' "${b%-usb.img}"; } version_of_payload() { local b; b="$(basename "$1")"; printf '%s' "${b#payload-}"; } if [[ -z "$MEDIA_USB" ]]; then @@ -102,7 +101,8 @@ if [[ -z "$MEDIA_USB" ]]; then fi VER=""; [[ -n "$MEDIA_USB" ]] && VER="$(version_of_medium "$MEDIA_USB")" [[ -z "$MEDIA_ISO" && -n "$VER" && -f "${IMGDIR}/kryptik-${VER}.iso" ]] && MEDIA_ISO="${IMGDIR}/kryptik-${VER}.iso" -[[ -z "$MEDIA_ISO" ]] && MEDIA_ISO="$(newest "${IMGDIR}"/kryptik-*.iso)" +# Never the newest ISO of some other release: without this release's own, the +# ISO items are INCOMPLETE, which is never a pass. [[ -z "$PAYLOAD_B" && -n "$VER" && -d "${IMGDIR}/payload-${VER}" ]] && PAYLOAD_B="${IMGDIR}/payload-${VER}" VER_B=""; [[ -n "$PAYLOAD_B" ]] && VER_B="$(version_of_payload "$PAYLOAD_B")" if [[ -z "$PAYLOAD_A" && -n "$VER_B" ]]; then @@ -177,11 +177,11 @@ item() { elif [[ "$rc" -ne 0 ]]; then res=FAIL; note="exit ${rc}" else res=PASS - if [[ "$minp" -gt 0 ]]; then - local p="${checks%%/*}" - if [[ "$checks" == "-" || "$p" -lt "$minp" ]]; then - res=FAIL; note="exit 0 but only ${p:-no} checks reported passed (minimum ${minp}): the driver did not exercise what it claims" - fi + local p="${checks%%/*}" f="${checks##*/}" + if [[ "$checks" != "-" && "$f" -gt 0 ]]; then + res=FAIL; note="exit 0 but its own summary counts ${f} failed" + elif [[ "$minp" -gt 0 && ( "$checks" == "-" || "$p" -lt "$minp" ) ]]; then + res=FAIL; note="exit 0 but only ${p:-no} checks reported passed (minimum ${minp}): the driver did not exercise what it claims" fi fi printf -- '-- %s: %s (exit %s, %ss, checks %s)%s\n' "$name" "$res" "$rc" "$((SECONDS - t0))" "$checks" "${note:+ - $note}" @@ -189,6 +189,7 @@ item() { } # ------------------------------------------------------------- prereqs -- +need_host() { if [[ "$NOHOST" -eq 1 ]]; then echo "not run (--no-host)"; else need_cargo; fi; } need_root() { [[ "$EUID" -eq 0 ]] || echo "needs root (the chroot and the VM disks)"; } need_sysroot() { [[ -x "${SYSROOT}/usr/bin/gcc" ]] || echo "no built sysroot at ${SYSROOT} (make system)"; } need_usb() { [[ -f "$MEDIA_USB" ]] || echo "no USB image (make media)"; } @@ -313,9 +314,7 @@ item inputs revision M host 0 it_revision item inputs compositor-sources M host 0 it_compositor_sources item inputs sources-lock M host 0 it_sources_lock need_sources item inputs media-hashes M host 0 it_media_hashes need_usb -if [[ "$NOHOST" -eq 0 ]]; then -item build host-suites M host 0 it_host_suites need_cargo -fi +item build host-suites M host 0 it_host_suites need_host item build libc-unwind M host 0 it_libc_unwind need_sysroot item build userspace-smoke M host 0 it_userspace need_sysroot item build artifact-hardening M host 0 it_artifacts need_sysroot @@ -426,15 +425,23 @@ it_export() { cp "${OUT}/REVISION.txt" "${d}/" 2>/dev/null mkdir -p "${d}/acceptance-logs" && cp "${OUT}"/*.log "${OUT}/results.tsv" "${d}/acceptance-logs/" 2>/dev/null echo "-- the copies hash the same as what was tested" + # Against the hashes taken when the run began: a medium that changed + # while it was being tested is a mismatch too. + : > "${d}/SHA256SUMS" for f in "$MEDIA_USB" "$MEDIA_ISO"; do [[ -f "$f" ]] || continue - want="$(sha_of "$f")"; got="$(sha_of "${d}/$(basename "$f")")" + want="$H_ISO"; [[ "$f" == "$MEDIA_USB" ]] && want="$H_USB" + got="$(sha_of "${d}/$(basename "$f")")" if [[ "$want" == "$got" ]]; then echo " ok $(basename "$f") ${got}"; else echo " MISMATCH $(basename "$f"): tested ${want}, exported ${got}"; ok=1; fi + printf '%s ./%s\n' "$got" "$(basename "$f")" >> "${d}/SHA256SUMS" done - ( cd "$d" && sha256sum ./*.img ./*.iso ./*.crt ./*.der ./root.json 2>/dev/null ) > "${d}/SHA256SUMS" - echo " wrote ${d}/SHA256SUMS" return "$ok" } +# Every other file of the export, listed last so that the report, the results +# and RELEASE.txt are the final ones. The media lines are it_export's. +seal_export() { # seal_export DIR + ( cd "$1" && find . -type f ! -name SHA256SUMS ! -name '*.img' ! -name '*.iso' -print0 | sort -z | xargs -0 sha256sum ) >> "$1/SHA256SUMS" +} V="$(verdict_of)" write_report "$V" if [[ -n "$EXPORT" ]] || wanted release; then @@ -454,6 +461,9 @@ if [[ -n "$EXPORT" ]] || wanted release; then echo "trust : kryptik-sb.crt / kryptik-sb.der (the developer Secure Boot key, a test anchor)" echo "read : INSTRUCTIONS.md" } > "${EXPORT}/RELEASE.txt" + # Again, now that the export's own row and log exist. + cp "${OUT}"/*.log "${OUT}/results.tsv" "${EXPORT}/acceptance-logs/" 2>/dev/null + seal_export "$EXPORT" fi fi diff --git a/tools/check-pin-reviews.sh b/tools/check-pin-reviews.sh new file mode 100755 index 0000000..b64d076 --- /dev/null +++ b/tools/check-pin-reviews.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Every pin that is behind its upstream needs a row in tools/pin-reviews.tsv: +# +# package pinned reviewed_up_to fine|held date what was read, and why +# +# A row stops covering its pin when the pin moves or upstream releases past +# reviewed_up_to, so the next release has to be read too. `held` means a known +# fix is not taken, for the reason in the note; --no-held, which a release +# asks, refuses it. Reads a survey, never the network: +# +# tools/check-source-currency.sh --tsv > survey.tsv +# tools/check-pin-reviews.sh --survey survey.tsv [--reviews FILE] [--no-held] +set -uo pipefail + +SURVEY=""; REVIEWS="$(dirname "${BASH_SOURCE[0]}")/pin-reviews.tsv"; NO_HELD=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --survey) SURVEY="${2:-}"; shift 2 ;; + --reviews) REVIEWS="${2:-}"; shift 2 ;; + --no-held) NO_HELD=1; shift ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[[ -s "$SURVEY" && -f "$REVIEWS" ]] || { echo "FAIL: need a non-empty --survey and a reviews file" >&2; exit 1; } + +newer() { [[ "$1" != "$2" && "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" == "$1" ]]; } +bad=0 +fail() { echo " $*"; bad=$((bad + 1)); } + +declare -A PIN UPTO VERDICT NOTE SEEN +while read -r pkg pinned upto verdict _date note; do + [[ -z "$pkg" || "$pkg" == \#* ]] && continue + if [[ "$verdict" =~ ^(fine|held)$ && -n "$note" && -z "${PIN[$pkg]:-}" ]]; then + PIN[$pkg]="$pinned"; UPTO[$pkg]="$upto"; VERDICT[$pkg]="$verdict"; NOTE[$pkg]="$note" + else + fail "MALFORMED: ${pkg}: one row per package, verdict fine or held, and a note" + fi +done < "$REVIEWS" + +# Tabs become a separator that is not whitespace: read merges a run of tabs, so +# an empty "newest" column would shift every field after it. +while IFS=$'\037' read -r name pinned newest status _; do + [[ -n "$name" ]] || continue + SEEN[$name]=1 + row="${PIN[$name]:-}" + if [[ "$status" != BEHIND ]]; then + [[ "$status" == UNKNOWN ]] && echo " not determined, which is not the same as fine: ${name} ${pinned}" + [[ -n "$row" ]] && fail "STALE: ${name} is ${status} now; remove its row" + elif [[ -z "$row" ]]; then fail "NOT REVIEWED: ${name} ${pinned} -> ${newest}" + elif [[ "$row" != "$pinned" ]]; then fail "STALE: ${name}: the row reviews ${row}, the pin is ${pinned}" + elif newer "$newest" "${UPTO[$name]}"; then fail "NEW RELEASE: ${name}: reviewed up to ${UPTO[$name]}, upstream is at ${newest}" + elif [[ "${VERDICT[$name]}" == held ]]; then + echo " HELD: ${name} ${pinned}: ${NOTE[$name]}" + [[ "$NO_HELD" -eq 1 ]] && bad=$((bad + 1)) + fi +done < <(tr '\t' '\037' < "$SURVEY") +for pkg in "${!PIN[@]}"; do [[ -n "${SEEN[$pkg]:-}" ]] || fail "STALE: ${pkg} is not a source"; done + +[[ "$bad" -eq 0 ]] || { echo "FAIL: ${bad} pin(s) without a current review"; exit 1; } +echo "ok: every pin that is behind upstream has a current review" diff --git a/tools/check-source-currency.sh b/tools/check-source-currency.sh index 26ae3ad..3793878 100755 --- a/tools/check-source-currency.sh +++ b/tools/check-source-currency.sh @@ -2,7 +2,7 @@ # Compare every pinned version against what upstream currently publishes. # # ./tools/check-source-currency.sh report everything -# ./tools/check-source-currency.sh --only NAME one package +# ./tools/check-source-currency.sh --only=NAME one package # ./tools/check-source-currency.sh --fail-on-behind # ./tools/check-source-currency.sh --strict also fail on UNKNOWN # ./tools/check-source-currency.sh --tsv machine-readable @@ -171,6 +171,25 @@ for line in sys.stdin: print(out[-1] if out else '')" } +# The values of one string key in a JSON API response, a leading "v" dropped. +# grep and sed, not a JSON parser, on purpose: the answer wanted is "every +# tag_name" and the key is anchored on its opening quote, so "author_name" +# does not match "name". A tags endpoint's objects start with their name, and +# the brace is part of the match there so a nested "name" is not taken. +api_values() { # api_values URL KEY + local open='"' + [[ "$2" == name ]] && open='\{"' + fetch "$1" | grep -oE "${open}$2\":\"[^\"]*\"" \ + | sed -E "s/.*\"$2\":\"v?([^\"]*)\"/\1/" || true +} + +# Versions made only of numbers and dots, the highest of them. Drops every +# spelling of a pre-release that has letters in it (0.8-dev, 4.0.7rc1). +numeric_newest() { grep -E '^[0-9]+(\.[0-9]+)+$' | sort -V -u | tail -1 || true; } + +# freedesktop projects number a release candidate X.Y.9N or X.Y.90N. +drop_ninety() { grep -vE '\.9[0-9]+$' || true; } + # --- per-source strategy ---------------------------------------------------- # # Returns "|", either field possibly empty. @@ -191,6 +210,89 @@ upstream_for() { ;; esac + # --- hosts with no directory listing to read --------------------------- + # + # Sixteen pins were UNKNOWN until these were written, which is a third of + # what the image exposes to untrusted input: the compositor's libraries, + # less, lynx, openssh. Each rule below was run against the real host + # before it was written down, and each encodes a trap that produces a + # confidently wrong number rather than none: + # + # * wayland and libinput number a release candidate X.Y.9N or X.Y.90N, + # with no "rc" in it (1.25.91, 1.31.901). Dropping rc/alpha/beta keeps + # them, and reports a candidate as the newest release. + # * a GitLab release list is ordered by date, not version: libinput + # 1.30.4 sits above 1.31.3. Taking the first entry is wrong; sort. + # * psmisc's release list is missing a release its tag list has, and + # kernel-hardening-checker publishes tags and no releases at all. + # * less marks a version "released for general use" on its front page; + # a newer tarball in the directory is a beta. + # * lynx's directory is full of 2.9.3dev.N snapshots. + # * https://curl.se/ca/ answers 200 with a meta refresh, which curl -L + # does not follow; the list is on caextract.html. + local fd="https://gitlab.freedesktop.org/api/v4/projects" + case "$name" in + glibc-fhs-patch) + # Not a release of anything: it is the LFS book's patch for the + # pinned glibc and moves only when glibc does. + printf '%s|%s' "" "tools/check-source-currency.sh, the glibc row (the patch follows glibc's pin)" + return ;; + less) + consulted="https://www.greenwoodsoftware.com/less/ (released for general use)" + newest="$(fetch "https://www.greenwoodsoftware.com/less/" \ + | grep -oE 'less-[0-9]+ has been released for general use' \ + | sed -E 's/less-([0-9]+) .*/\1/' | sort -V -u | tail -1 || true)" ;; + procps-ng) + consulted="gitlab.com procps-ng/procps releases" + newest="$(api_values "https://gitlab.com/api/v4/projects/procps-ng%2Fprocps/releases?per_page=50" tag_name | numeric_newest)" ;; + psmisc) + consulted="gitlab.com psmisc/psmisc tags" + newest="$(api_values "https://gitlab.com/api/v4/projects/psmisc%2Fpsmisc/repository/tags?per_page=100" name | numeric_newest)" ;; + lvm2) + consulted="https://sourceware.org/pub/lvm2/" + newest="$(newest_in_listing "$consulted" 'LVM2\.([0-9]+(\.[0-9]+)+)\.tgz')" ;; + openssh) + consulted="https://ftp.openbsd.org/pub/OpenBSD/OpenSSH/portable/" + newest="$(newest_in_listing "$consulted" 'openssh-([0-9]+\.[0-9]+p[0-9]+)\.tar\.gz')" ;; + ca-bundle) + consulted="https://curl.se/docs/caextract.html" + newest="$(newest_in_listing "$consulted" 'cacert-([0-9]{4}-[0-9]{2}-[0-9]{2})\.pem')" ;; + wayland|libinput) + consulted="gitlab.freedesktop.org ${name}/${name} releases" + newest="$(api_values "${fd}/${name}%2F${name}/releases?per_page=50" tag_name | drop_ninety | numeric_newest)" ;; + wayland-protocols) + consulted="gitlab.freedesktop.org wayland/wayland-protocols releases" + newest="$(api_values "${fd}/wayland%2Fwayland-protocols/releases?per_page=50" tag_name | numeric_newest)" ;; + libdisplay-info) + consulted="gitlab.freedesktop.org emersion/libdisplay-info releases" + newest="$(api_values "${fd}/emersion%2Flibdisplay-info/releases?per_page=50" tag_name | numeric_newest)" ;; + wlroots) + # The pinned series only: dwl is written against one wlroots + # series, and the next one is an API change, not a drop-in. + local wseries="${V_WLROOTS%.*}" + consulted="gitlab.freedesktop.org wlroots/wlroots tags (series ${wseries})" + newest="$(api_values "${fd}/wlroots%2Fwlroots/repository/tags?per_page=100" name \ + | grep -E "^${wseries//./\\.}\.[0-9]+$" | numeric_newest || true)" ;; + seatd) + consulted="https://git.sr.ht/~kennylevinsen/seatd/refs/rss.xml" + newest="$(fetch "$consulted" | grep -oE '[0-9]+(\.[0-9]+)+' \ + | sed -E 's/<[^>]*>//g' | sort -V -u | tail -1 || true)" ;; + dwl) + consulted="codeberg.org dwl/dwl tags" + newest="$(api_values "https://codeberg.org/api/v1/repos/dwl/dwl/tags?limit=50" name | numeric_newest)" ;; + lynx) + consulted="https://invisible-mirror.net/archives/lynx/tarballs/" + newest="$(newest_in_listing "$consulted" 'lynx([0-9]+(\.[0-9]+)+)\.tar\.gz')" ;; + kernel-hardening-checker) + consulted="https://github.com/a13xp0p0v/kernel-hardening-checker/tags.atom" + newest="$(fetch "$consulted" | grep -oE 'v[0-9]+(\.[0-9]+)+' \ + | sed -E 's/v//; s/<.*//' | sort -V -u | tail -1 || true)" ;; + esac + if [[ -n "$consulted" ]]; then + printf '%s|%s' "$newest" "$consulted" + return + fi + case "$url" in # kernel.org and others put releases in per-series subdirectories, so # the file's own directory only ever offers that series. Look in the diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index 0342752..16e3b3b 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -135,7 +135,6 @@ menu_zones() { [ -z "$info" ] && state="(daemon not answering)" printf ' %d) %-10s %-3s %-10s %s\n' "$n" "$z" "$(zone_field "$z" glyph)" "$(zone_field "$z" label)" "$state" done - NZONES=$n } run_menu() { diff --git a/tools/desktop/kryptik-launch.c b/tools/desktop/kryptik-launch.c index f6917b8..c92d979 100644 --- a/tools/desktop/kryptik-launch.c +++ b/tools/desktop/kryptik-launch.c @@ -15,6 +15,8 @@ * kryptik-launch --wifi-add SSID add one, or replace its passphrase; the passphrase * is one line on standard input, never an argument * kryptik-launch --wifi-forget SSID remove one + * kryptik-launch --update status|fetch|apply what release is current and staged; ask for + * it to be fetched; install what has arrived * * With a display, the zone's Wayland proxy (kryptik-wlproxy) is started * first if it is not already running, listening at @@ -275,7 +277,8 @@ static void usage(void) " kryptik-launch --runtime-dir\n" " kryptik-launch --clipboard-move FROM TO\n" " kryptik-launch --wifi-list | --wifi-add SSID | --wifi-forget SSID\n" - " (--wifi-add reads the passphrase from standard input)\n", stderr); + " (--wifi-add reads the passphrase from standard input)\n" + " kryptik-launch --update status|fetch|apply\n", stderr); exit(2); } @@ -362,6 +365,22 @@ static int wifi_main(int argc, char **argv) return ok ? 0 : 1; } +/* The update channel from the person's side: the daemon's update verbs + * (kryptikd's update.rs). The reply is `ok` on a line of its own and then + * text for the person, or one `error:` line. `apply` answers when + * kryptik-update has finished, which is as long as writing a slot takes. */ +static int update_main(int argc, char **argv) +{ + if (argc != 3 || (strcmp(argv[2], "status") != 0 && strcmp(argv[2], "fetch") != 0 && strcmp(argv[2], "apply") != 0)) + usage(); + char req[32]; + snprintf(req, sizeof req, "update-%s\n", argv[2]); + char *r = talk(req, -1); + int ok = strncmp(r, "ok\n", 3) == 0; + fputs(ok ? r + 3 : r, ok ? stdout : stderr); + return ok ? 0 : 1; +} + int main(int argc, char **argv) { int ask = 0, no_display = 0, pass_fd = -1, sep = -1; @@ -369,6 +388,8 @@ int main(int argc, char **argv) int i; if (argc >= 2 && strncmp(argv[1], "--wifi-", 7) == 0) return wifi_main(argc, argv); + if (argc >= 2 && strcmp(argv[1], "--update") == 0) + return update_main(argc, argv); if (argc == 4 && strcmp(argv[1], "--clipboard-move") == 0) { if (!ident_ok(argv[2]) || !ident_ok(argv[3])) usage(); @@ -456,13 +477,24 @@ int main(int argc, char **argv) char *req = malloc(cap); if (!req) die("out of memory"); - int len = snprintf(req, cap, "run %s%s%s%s\n", zone, wl ? " wayland=" : "", wl ? wl : "", pass_fd >= 0 ? " pass=fd" : ""); + /* snprintf returns what it WANTED to write. Adding that to an offset + * without looking is how a short buffer becomes a write past its end, + * so every piece is checked to have fitted before the next is placed. */ + size_t len = 0; + #define PUT(...) do { \ + int n_ = snprintf(req + len, cap - len, __VA_ARGS__); \ + if (n_ < 0 || (size_t)n_ >= cap - len) \ + die("request too long"); \ + len += (size_t)n_; \ + } while (0) + PUT("run %s%s%s%s\n", zone, wl ? " wayland=" : "", wl ? wl : "", pass_fd >= 0 ? " pass=fd" : ""); for (i = 0; i < ncmd; i++) { if (strchr(cmd[i], '\n')) die("argument %d contains a newline", i); - len += snprintf(req + len, cap - (size_t)len, "arg %s\n", cmd[i]); + PUT("arg %s\n", cmd[i]); } - len += snprintf(req + len, cap - (size_t)len, "end\n"); + PUT("end\n"); + #undef PUT char *r = talk(req, pass_fd); if (pass_fd >= 0) diff --git a/tools/dev/build-host/post-build.sh b/tools/dev/build-host/post-build.sh index 0633483..e29ffc9 100755 --- a/tools/dev/build-host/post-build.sh +++ b/tools/dev/build-host/post-build.sh @@ -34,5 +34,5 @@ run_stage kernel make SUDO= KRYPTIK_KRYPTIKD_BIN="$KD_BIN" KRYPTIK_WLPROXY_BIN=" VER_A="0.1.$(date +%Y%m%d).$(git rev-parse --short=8 HEAD)" run_stage media-a make SUDO= KRYPTIK_KRYPTIKD_BIN="$KD_BIN" KRYPTIK_WLPROXY_BIN="$WL_BIN" media KRYPTIK_VERSION="$VER_A" || { echo "media-a FAILED"; exit 1; } run_stage media-b make SUDO= KRYPTIK_KRYPTIKD_BIN="$KD_BIN" KRYPTIK_WLPROXY_BIN="$WL_BIN" media KRYPTIK_VERSION="${VER_A}.1" || { echo "media-b FAILED"; exit 1; } -ls -la "$KRYPTIK_WORK/images/" | grep -E "usb.img|\.iso|payload" +ls -lad "$KRYPTIK_WORK"/images/*usb.img "$KRYPTIK_WORK"/images/*.iso "$KRYPTIK_WORK"/images/payload* 2>/dev/null || true echo "[$(STAMP)] POST-BUILD DONE: A=${VER_A} B=${VER_A}.1" diff --git a/tools/efi/kryptik-efiboot.c b/tools/efi/kryptik-efiboot.c index 1659af2..e6ed0c3 100644 --- a/tools/efi/kryptik-efiboot.c +++ b/tools/efi/kryptik-efiboot.c @@ -33,6 +33,7 @@ #include <string.h> #include <sys/ioctl.h> #include <sys/stat.h> +#include <sys/wait.h> #include <unistd.h> #define EFIVARS "/sys/firmware/efi/efivars/" @@ -90,21 +91,43 @@ static int delete_var(const char *name) { /* --- the ESP partition, by label ------------------------------------------ */ struct part { char dev[128]; char uuid[40]; uint64_t start, size; uint32_t number; }; -static int run_read(const char *cmd, char *out, size_t cap) { - FILE *p = popen(cmd, "r"); if (!p) return -1; - if (!fgets(out, (int)cap, p)) { pclose(p); return -1; } - pclose(p); +/* The first line a program prints. No shell: the arguments are an array, so + * nothing in them is ever parsed as a command. The device name handed to + * blkid comes from devices.sh on the verified root, but a root tool that + * builds a command line out of any string is one edit away from trusting + * the wrong one. */ +static int run_read(char *const argv[], char *out, size_t cap) { + int fd[2]; + if (pipe(fd)) return -1; + pid_t pid = fork(); + if (pid < 0) { close(fd[0]); close(fd[1]); return -1; } + if (pid == 0) { + int nul = open("/dev/null", O_WRONLY); + dup2(fd[1], 1); + if (nul >= 0) dup2(nul, 2); + close(fd[0]); close(fd[1]); + execvp(argv[0], argv); + _exit(127); + } + close(fd[1]); + FILE *p = fdopen(fd[0], "r"); + int got = p && fgets(out, (int)cap, p) != NULL; + if (p) fclose(p); else close(fd[0]); + int st; + while (waitpid(pid, &st, 0) < 0 && errno == EINTR) {} + if (!got) return -1; out[strcspn(out, "\n")] = 0; return 0; } static int find_esp(struct part *p) { char dev[128]; - if (run_read("/usr/libexec/kryptik/devices.sh part kryptik-esp 2>/dev/null", dev, sizeof dev) || !dev[0]) return -1; + char *find[] = { "/usr/libexec/kryptik/devices.sh", "part", "kryptik-esp", NULL }; + if (run_read(find, dev, sizeof dev) || !dev[0]) return -1; snprintf(p->dev, sizeof p->dev, "%s", dev); - char cmd[256], out[128]; - snprintf(cmd, sizeof cmd, "blkid -s PARTUUID -o value %s 2>/dev/null", dev); - if (run_read(cmd, out, sizeof out) || strlen(out) != 36) return -1; + char out[128]; + char *uuid[] = { "blkid", "-s", "PARTUUID", "-o", "value", dev, NULL }; + if (run_read(uuid, out, sizeof out) || strlen(out) != 36) return -1; snprintf(p->uuid, sizeof p->uuid, "%s", out); const char *base = strrchr(dev, '/'); base = base ? base + 1 : dev; char path[256]; diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 79d13bf..5e9b1a7 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -37,15 +37,9 @@ VMDIR="${KRYPTIK_WORK}/vm"; mkdir -p "$VMDIR" DISK="${DISK:-${VMDIR}/gui.img}" [[ -e "$DISK" && ! -f "$DISK" ]] && die "refusing: ${DISK} is not a regular file" -PASS=0; FAIL=0 -green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } -red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } -step() { printf '\n==> %s\n' "$*"; } -TUSER=tester; TPASS=tester-pw; RPASS=root-pw -TUSER_HASH="$(openssl passwd -6 "$TPASS")"; ROOT_HASH="$(openssl passwd -6 "$RPASS")" -DRV="${SELF}/vm-drive.py" +# shellcheck source=tools/image/suite-lib.sh +source "${SELF}/suite-lib.sh" VARSF="${VMDIR}/gui-vars.fd"; cp /usr/share/OVMF/OVMF_VARS_4M.fd "$VARSF" -LATEST="${KRYPTIK_WORK}/logs/ovmf-serial.latest.log" SHOT="${VMDIR}/gui-untrusted.ppm" SHOT_FS="${VMDIR}/gui-untrusted-fullscreen.ppm" @@ -56,7 +50,7 @@ DISK_SIZE="$("${SELF}/test-disk-size.sh" --medium "$USB")" || die "could not siz rm -f "$DISK"; truncate -s "$DISK_SIZE" "$DISK" CTL="${VMDIR}/testctl-gui.img" "${SELF}/mk-testctl.sh" --out "$CTL" install_target=/dev/vda smoke_poweroff=1 install_wait=5 \ - "preseed_user=${TUSER}" "preseed_password_hash=${TUSER_HASH}" "preseed_root_hash=${ROOT_HASH}" > /dev/null + "${PRESEED[@]}" > /dev/null "${SELF}/run-ovmf.sh" --usb "$USB" --disk "$DISK" --testctl "$CTL" --vars clean --mode smoke --timeout "$TIMEOUT" --name gui-install > /dev/null tr -d '\r' < "$LATEST" | grep -q 'KRYPTIK_INSTALL: rc=0' && green "installed" || { red "install failed"; exit 1; } diff --git a/tools/image/install-test.sh b/tools/image/install-test.sh index 8d67cef..490b50d 100755 --- a/tools/image/install-test.sh +++ b/tools/image/install-test.sh @@ -47,19 +47,12 @@ case "$DISK" in /dev/*|/sys/*|/proc/*) die "refusing to use ${DISK} as a target [[ -e "$DISK" && ! -f "$DISK" ]] && die "refusing: ${DISK} exists and is not a regular file" for t in python3 sfdisk blkid truncate; do have "$t" || die "required tool not found: $t"; done -PASS=0; FAIL=0 -green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } -red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } +# shellcheck source=tools/image/suite-lib.sh +source "${SELF}/suite-lib.sh" want() { if grep -qE "$2" "$1"; then green "$3"; else red "$3"; fi; } deny() { if grep -qE "$2" "$1"; then red "$3"; else green "$3"; fi; } -step() { printf '\n==> %s\n' "$*"; } txt_of() { tr -d '\r' < "$1"; } -# The test account and root password the preseed creates. The hashes are -# what lands on disk; the plaintext exists only in this harness. -TUSER=tester; TPASS=tester-pw; RPASS=root-pw -hash_of() { openssl passwd -6 "$1"; } -TUSER_HASH="$(hash_of "$TPASS")"; ROOT_HASH="$(hash_of "$RPASS")" # ----------------------------------------------------------------- step 1 -- step "step 1: install from the medium onto a blank ${SIZE} disk" @@ -68,7 +61,7 @@ if [[ -z "$SIZE" ]]; then SIZE="$("${SELF}/test-disk-size.sh" --medium "$USB")" rm -f "$DISK"; truncate -s "$SIZE" "$DISK" CTL="${VMDIR}/testctl-install.img" "${SELF}/mk-testctl.sh" --out "$CTL" install_target=/dev/vda smoke_poweroff=1 install_wait=5 \ - "preseed_user=${TUSER}" "preseed_password_hash=${TUSER_HASH}" "preseed_root_hash=${ROOT_HASH}" > /dev/null || die "control disk" + "${PRESEED[@]}" > /dev/null || die "control disk" "${SELF}/run-ovmf.sh" --usb "$USB" --disk "$DISK" --testctl "$CTL" --vars "$VARS" --mode smoke --timeout "$TIMEOUT" --name install-p1 qrc=$? P1="${VMDIR}/install-p1.txt"; txt_of "${KRYPTIK_WORK}/logs/ovmf-serial.latest.log" > "$P1" @@ -78,7 +71,7 @@ want "$P1" 'KRYPTIK_INSTALL: rc=0' "the installer exited 0 want "$P1" 'KRYPTIK_INSTALL: verify: kryptik-esp=/dev/vda1 type=vfat' "partition 1 is the ESP" want "$P1" 'KRYPTIK_INSTALL: verify: kryptik-a=/dev/vda2' "partition 2 is kryptik-a" want "$P1" 'KRYPTIK_INSTALL: verify: kryptik-b=/dev/vda3' "partition 3 is kryptik-b" -want "$P1" 'KRYPTIK_INSTALL: verify: kryptik-state=/dev/vda4 type=ext4' "partition 4 is the state partition" +want "$P1" 'KRYPTIK_INSTALL: verify: kryptik-state=/dev/vda4 type=crypto_LUKS' "partition 4 is the state partition, and it is LUKS" want "$P1" 'KRYPTIK_INSTALL: verify: esp_files=.*EFI/BOOT/BOOTX64.EFI' "the ESP has the removable-media boot file" want "$P1" 'KRYPTIK_INSTALL: verify: install_json=yes' "install.json was written" want "$P1" 'KRYPTIK_INSTALL: verify: preseed=present' "the first-boot preseed was written" @@ -102,7 +95,6 @@ cp "/usr/share/OVMF/OVMF_VARS_4M.fd" "$VARSF" SERVE="$("${SELF}/run-ovmf.sh" --no-media --disk "$DISK" --vars-file "$VARSF" --mode serve --allow-reboot --name install-p2)" SER="$(sed -n 's/^serial=//p' <<<"$SERVE")"; PIDF="$(sed -n 's/^pid=//p' <<<"$SERVE")"; LOG2="$(sed -n 's/^log=//p' <<<"$SERVE")" [[ -S "$SER" ]] || die "no serial socket from run-ovmf: ${SERVE}" -DRV="${SELF}/vm-drive.py" REC="${VMDIR}/install-p2.json" python3 "$DRV" --serial "$SER" --timeout 300 --record "$REC" \ "expect:KRYPTIK_SMOKE: END" \ @@ -112,7 +104,8 @@ python3 "$DRV" --serial "$SER" --timeout 300 --record "$REC" \ "grab:mounts:awk '\$2==\"/\"||\$2==\"/var\"||\$2==\"/etc\"||\$2==\"/home\" {print \$2, \$1, \$3, \$4}' /proc/mounts" \ "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" \ - "run:touch /home/${TUSER}/persisted-p2 && sync" \ + "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" \ "expect:Linux version" \ "expect:KRYPTIK_SMOKE: END" \ @@ -129,7 +122,16 @@ echo " transcript: ${LOG2}" [[ "$drc" -eq 0 ]] && green "first boot, login, reboot, second login and clean poweroff all happened" || red "the serial drive failed (see above)" want "$P2" 'KRYPTIK_SMOKE: root_source=/dev/dm-0 ext4 ro' "installed root is the verity device" want "$P2" 'KRYPTIK_SMOKE: boot_identity=slot=a media=' "booted slot a" -want "$P2" 'KRYPTIK_SMOKE: var_source=/dev/vda4 ext4' "state partition mounted on /var" +want "$P2" 'KRYPTIK_SMOKE: var_source=/dev/mapper/kryptik-state ext4' "the unlocked state partition is mounted on /var" +want "$P2" 'passphrase for the state partition \(try 1 of 3\)' "sysinit asked for the state passphrase on the console" +deny "$P2" "$KRYPTIK_STATE_PASSPHRASE" "the passphrase is nowhere in the transcript" +# From the host: partition 4 is a LUKS header and ciphertext. +S4=$(( $(part_start "$DISK" 4) * 512 )) +magic() { dd if="$DISK" bs=1 skip="$1" count="$2" status=none | od -An -tx1 | tr -d ' \n'; } +[[ "$(magic "$S4" 6)" == 4c554b53babe ]] && green "partition 4 starts with a LUKS header" || red "partition 4 does not start with a LUKS header" +[[ "$(magic $(( S4 + 1080 )) 2)" != 53ef ]] && green "no ext4 superblock in the clear" || red "an ext4 superblock is readable on partition 4" +if tail -c +$(( S4 + 1 )) "$DISK" | LC_ALL=C grep -aq 'KRYPTIK-CLEAR-MARKER-7f3a91'; then red "a file written under /home is readable from the raw partition" +else green "a file written under /home is not readable from the raw partition"; fi 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" diff --git a/tools/image/integrity-test.sh b/tools/image/integrity-test.sh index 328ba5f..55573c4 100755 --- a/tools/image/integrity-test.sh +++ b/tools/image/integrity-test.sh @@ -39,27 +39,20 @@ while [[ "$#" -gt 0 ]]; do esac done [[ -f "$USB" ]] || die "--usb IMG is required" -for t in python3 sbsign sbverify openssl mcopy mdel mdir sfdisk; do have "$t" || die "required tool not found: $t"; done +for t in python3 sbsign sbverify openssl mcopy mdel mdir sfdisk cryptsetup losetup; do have "$t" || die "required tool not found: $t"; done VMDIR="${KRYPTIK_WORK}/vm"; mkdir -p "$VMDIR" DISK="${DISK:-${VMDIR}/integrity.img}" [[ -e "$DISK" && ! -f "$DISK" ]] && die "refusing: ${DISK} is not a regular file" ENROLLED="${KRYPTIK_WORK}/keys/sb/vars/enrolled.fd" [[ -f "$ENROLLED" ]] || die "no enrolled variable store; run tools/image/ovmf-vars.sh" -PASS=0; FAIL=0 -green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } -red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } -step() { printf '\n==> %s\n' "$*"; } -TUSER=tester; TPASS=tester-pw; RPASS=root-pw -TUSER_HASH="$(openssl passwd -6 "$TPASS")"; ROOT_HASH="$(openssl passwd -6 "$RPASS")" -DRV="${SELF}/vm-drive.py" +# shellcheck source=tools/image/suite-lib.sh +source "${SELF}/suite-lib.sh" VARSF="${VMDIR}/integrity-vars.fd"; cp "$ENROLLED" "$VARSF" -LATEST="${KRYPTIK_WORK}/logs/ovmf-serial.latest.log" txt_latest() { tr -d '\r' < "$LATEST"; } # Partition offsets on the disk file, from its GPT, so the host can edit the # ESP with mtools and flip bytes in slot a without mounting anything. -part_start() { sfdisk -d "$DISK" 2>/dev/null | awk -v n="$1" -F'[ ,]+' '$1 ~ n"$" {for(i=1;i<=NF;i++) if($i=="start=") print $(i+1)}'; } # ----------------------------------------------------------------- step 1 -- step "step 1: install, then boot alone with the developer key enrolled (Secure Boot on)" @@ -68,7 +61,7 @@ DISK_SIZE="$("${SELF}/test-disk-size.sh" --medium "$USB")" || die "could not siz rm -f "$DISK"; truncate -s "$DISK_SIZE" "$DISK" CTL="${VMDIR}/testctl-integrity.img" "${SELF}/mk-testctl.sh" --out "$CTL" install_target=/dev/vda smoke_poweroff=1 install_wait=5 \ - "preseed_user=${TUSER}" "preseed_password_hash=${TUSER_HASH}" "preseed_root_hash=${ROOT_HASH}" > /dev/null + "${PRESEED[@]}" > /dev/null "${SELF}/run-ovmf.sh" --usb "$USB" --disk "$DISK" --testctl "$CTL" --vars enrolled --mode smoke --timeout "$TIMEOUT" --name integ-install > /dev/null txt_latest | grep -q 'KRYPTIK_INSTALL: rc=0' && green "installed from the medium under Secure Boot" || { red "install failed"; exit 1; } txt_latest | grep -q 'KRYPTIK_SMOKE: secureboot=1' && green "the medium itself booted with Secure Boot enforced" || red "medium did not report secureboot=1" @@ -101,7 +94,7 @@ grep -q 'SIGNED=loaded' <<<"$T1" && green "the module signed by the build loads" # ----------------------------------------------------------------- step 2 -- step "step 2: an untrusted boot artifact is refused by the firmware" -ESP_OFF=$(( $(part_start 1) * 512 )) +ESP_OFF=$(( $(part_start "$DISK" 1) * 512 )) ESPIMG="${VMDIR}/integrity-esp.img" # lift the ESP out, keep a pristine copy, swap in a foreign-signed kernel dd if="$DISK" of="$ESPIMG" bs=1M iflag=skip_bytes,count_bytes skip="$ESP_OFF" count=$((512*1024*1024)) status=none @@ -131,7 +124,7 @@ rm -rf "$TMPK" # ----------------------------------------------------------------- step 3 -- step "step 3: a tampered root is refused by dm-verity before userspace" -A_OFF=$(( $(part_start 2) * 512 )) +A_OFF=$(( $(part_start "$DISK" 2) * 512 )) # Flip a byte in the ext4 superblock (byte 1024 of the image, the volume # name field at +0x78): the first thing a root mount reads, so dm-verity # sees a block whose hash does not match before any userspace exists. A @@ -177,10 +170,9 @@ step "step 5: offline tampering of the state partition does not reach privileged # a trust anchor of their own for updates, a zone definition the launch # daemon will honour, a kernel tunable applied at boot. Plant all three from # the host, boot, and measure each from inside the guest. -S_OFF=$(( $(part_start 4) * 512 )) TMPK="$(mktemp -d)"; MNT="$TMPK/state"; mkdir -p "$MNT" ssh-keygen -q -t ed25519 -N "" -f "$TMPK/attacker" >/dev/null -if mount -o loop,offset="$S_OFF" "$DISK" "$MNT" 2>/dev/null; then +if open_state "$DISK" "$MNT" 2>/dev/null; then up="$MNT/lib/kryptik/etc/upper" mkdir -p "$up/kryptik/trust" "$up/kryptik/zones" "$up/sysctl.d" printf 'kryptik-release namespaces="kryptik-release" %s\n' "$(cut -d' ' -f1,2 "$TMPK/attacker.pub")" > "$up/kryptik/trust/release-signers" @@ -209,7 +201,7 @@ EOF printf 'ACTION=="add", RUN+="/var/lib/kryptik/evil.sh"\n' > "$up/udev/rules.d/99-evil.rules" printf '#!/bin/sh\ntouch /var/lib/kryptik/evil-ran\n' > "$MNT/lib/kryptik/evil.sh"; chmod 0755 "$MNT/lib/kryptik/evil.sh" printf 'planted:1310720:65536\n' > "$up/subuid" - sync; umount "$MNT" + close_state "$MNT" green "planted a trust anchor, a zone definition, a sysctl fragment, a preload library and a udev rule under the state's /etc upper layer, and one allowed change" else red "could not mount the state partition from the host (loop/offset); step 5 not performed" @@ -264,13 +256,13 @@ if [[ -n "${EXTRA[*]:-}" ]]; then grep -q 'not enrolled' <<<"$T5" && green "an update signed by the planted anchor's key is refused (the anchor is read from the verified root)" || red "an attacker-signed update was not refused" fi grep -q 'KRYPTIK_SMOKE: sysctl kernel.kptr_restrict=2' <<<"$T5" && green "the planted sysctl fragment was not applied" || red "the planted sysctl was applied" -grep -q 'KRYPTIK_SMOKE: var_source=/dev/vda4' <<<"$T5" && green "state stayed persistent through the tamper (this is a repairable machine, not a bricked one)" || red "state not persistent in step 5" +grep -q 'KRYPTIK_SMOKE: var_source=/dev/mapper/kryptik-state' <<<"$T5" && green "state stayed persistent through the tamper (this is a repairable machine, not a bricked one)" || red "state not persistent in step 5" # undo the planting so later runs start clean -if mount -o loop,offset="$S_OFF" "$DISK" "$MNT" 2>/dev/null; then +if open_state "$DISK" "$MNT" 2>/dev/null; then rm -rf "$MNT/lib/kryptik/etc/upper/kryptik/trust" "$MNT/lib/kryptik/etc/upper/kryptik/zones" "$MNT/lib/kryptik/etc/upper/sysctl.d" \ "$MNT/lib/kryptik/etc/upper/ld.so.preload" "$MNT/lib/kryptik/etc/upper/udev" "$MNT/lib/kryptik/etc/upper/subuid" \ "$MNT/lib/kryptik/etc/quarantine" "$MNT/lib/kryptik/evil.sh" "$MNT/lib/kryptik/evil-ran" - sync; umount "$MNT" + close_state "$MNT" fi rm -rf "$TMPK" diff --git a/tools/image/release-host.py b/tools/image/release-host.py new file mode 100755 index 0000000..fffe409 --- /dev/null +++ b/tools/image/release-host.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""A release host for the suites: static files over HTTP, on loopback only. + + release-host.py ROOT PORTFILE LOG [NORANGE] + +Serves ROOT on 127.0.0.1 and a port the kernel picks, written to PORTFILE +once the socket is listening. Honours `Range: bytes=N-` with a 206, which is +what the update channel asks a release host for (docs/design/update-channel.md) +and what python's own http.server does not do; while the file NORANGE exists +it ignores Range and sends the whole file, which is the server the fetcher +must also survive. Files are streamed, never read whole: a root image is +gigabytes. Every request is one line in LOG: the path, then the Range header +or `-`. + +Loopback only, on purpose: under QEMU's user network the guest reaches this +as 10.0.2.2, and nothing else on the runner's network can ask it anything. +""" +import http.server +import os +import shutil +import sys + +root, portfile, log = (os.path.realpath(sys.argv[1]), sys.argv[2], sys.argv[3]) +norange = sys.argv[4] if len(sys.argv) > 4 else None + + +class Host(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + # Links inside ROOT may point anywhere (the suites link to a payload + # rather than copy it); the requested NAME may not leave ROOT. The + # name is normalised and held to ROOT before anything touches the + # filesystem with it. + path = os.path.normpath(os.path.join(root, self.path.split("?", 1)[0].lstrip("/"))) + if not path.startswith(root + os.sep): + self.send_error(404) + return + if not os.path.isfile(path): + self.send_error(404) + return + rng = self.headers.get("Range") + with open(log, "a") as f: + f.write("%s %s\n" % (self.path, rng or "-")) + size = os.path.getsize(path) + start = 0 + if rng and rng.startswith("bytes=") and not (norange and os.path.exists(norange)): + try: + start = int(rng[6:].split("-", 1)[0]) + except ValueError: + start = 0 + if start >= size: + self.send_error(416) + return + self.send_response(206) + self.send_header("Content-Range", "bytes %d-%d/%d" % (start, size - 1, size)) + else: + self.send_response(200) + self.send_header("Content-Length", str(size - start)) + self.end_headers() + with open(path, "rb") as f: + f.seek(start) + try: + shutil.copyfileobj(f, self.wfile, 1 << 20) + except (BrokenPipeError, ConnectionResetError): + pass # the client went away mid-file: that is a test, not a fault + + +server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Host) +with open(portfile + ".tmp", "w") as f: + f.write(str(server.server_address[1])) +os.replace(portfile + ".tmp", portfile) +server.serve_forever() diff --git a/tools/image/run-ovmf.sh b/tools/image/run-ovmf.sh index b0524d8..79fc191 100755 --- a/tools/image/run-ovmf.sh +++ b/tools/image/run-ovmf.sh @@ -179,9 +179,17 @@ smoke) { printf '%q ' "$QEMU" "${ARGS[@]}"; echo; } > "${LOG}.cmd" ln -sfn "$LOG" "${KRYPTIK_WORK}/logs/ovmf-serial.latest.log" echo "serial log: ${LOG}" + # The console is a socket the driver watches, not a file: an installed + # disk asks for its state passphrase there and the driver answers it. + # wait=on holds the guest until the driver is connected, so it sees all. + SER="${VMDIR}/${RUN_ID}.serial" set +e; trap - ERR - timeout --foreground "$TIMEOUT" "$QEMU" "${ARGS[@]}" -serial "file:${LOG}" -monitor none < /dev/null > "${LOG}.qemu" 2>&1 - rc=$? + "$QEMU" "${ARGS[@]}" -chardev "socket,id=ser0,path=${SER},server=on,wait=on,logfile=${LOG}" -serial chardev:ser0 \ + -monitor none < /dev/null > "${LOG}.qemu" 2>&1 & + qpid=$! + for _ in $(seq 1 50); do [[ -S "$SER" ]] && break; sleep 0.2; done + python3 "${SELF}/vm-drive.py" --serial "$SER" --timeout "$TIMEOUT" wait-exit > /dev/null + if kill -0 "$qpid" 2>/dev/null; then kill "$qpid"; wait "$qpid"; rc=124; else wait "$qpid"; rc=$?; fi set -e [[ "$rc" -eq 124 ]] && warn "QEMU hit the ${TIMEOUT}s timeout" [[ "$rc" -ne 0 && "$rc" -ne 124 ]] && { warn "QEMU exited ${rc}:"; sed 's/^/ /' "${LOG}.qemu" | tail -5; } diff --git a/tools/image/state-test.sh b/tools/image/state-test.sh index 4170f62..0dffa44 100755 --- a/tools/image/state-test.sh +++ b/tools/image/state-test.sh @@ -51,27 +51,10 @@ DISK="${DISK:-${VMDIR}/state.img}" [[ -e "$DISK" && ! -f "$DISK" ]] && die "refusing: ${DISK} is not a regular file" CLONE="${VMDIR}/state-clone.img" -PASS=0; FAIL=0 -green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } -red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } -step() { printf '\n==> %s\n' "$*"; } -TUSER=tester; TPASS=tester-pw; RPASS=root-pw -TUSER_HASH="$(openssl passwd -6 "$TPASS")"; ROOT_HASH="$(openssl passwd -6 "$RPASS")" -DRV="${SELF}/vm-drive.py" +# shellcheck source=tools/image/suite-lib.sh +source "${SELF}/suite-lib.sh" VARSF="${VMDIR}/state-vars.fd"; cp /usr/share/OVMF/OVMF_VARS_4M.fd "$VARSF" -LATEST="${KRYPTIK_WORK}/logs/ovmf-serial.latest.log" -start_vm() { # start_vm NAME [extra args] -> SER PIDF LOG - local name="$1"; shift - local out; out="$("${SELF}/run-ovmf.sh" --no-media --disk "$DISK" --vars-file "$VARSF" --mode serve --allow-reboot --name "$name" "$@")" - SER="$(sed -n 's/^serial=//p' <<<"$out")"; PIDF="$(sed -n 's/^pid=//p' <<<"$out")"; LOG="$(sed -n 's/^log=//p' <<<"$out")" - [[ -S "$SER" ]] || die "no serial socket: ${out}" -} -stop_vm() { sleep 1; [[ -f "$PIDF" ]] && kill "$(cat "$PIDF")" 2>/dev/null; sleep 1; } -drive() { python3 "$DRV" --serial "$SER" --timeout 300 "$@"; } -txt() { tr -d '\r' < "$LOG"; } -ROOTSH() { printf 'su:%s:%s' "$RPASS" "$1"; } -part_start() { sfdisk -d "$1" 2>/dev/null | awk -v n="$2" -F'[ ,]+' '$1 ~ n"$" {for(i=1;i<=NF;i++) if($i=="start=") print $(i+1)}'; } # A boot that must come up degraded: smoke mode, transcript only, and the # guest cannot power itself off (no user to log in as), so it is killed at @@ -109,7 +92,7 @@ DISK_SIZE="$("${SELF}/test-disk-size.sh" --medium "$USB")" || die "could not siz rm -f "$DISK"; truncate -s "$DISK_SIZE" "$DISK" CTL="${VMDIR}/testctl-state.img" "${SELF}/mk-testctl.sh" --out "$CTL" install_target=/dev/vda smoke_poweroff=1 install_wait=5 \ - "preseed_user=${TUSER}" "preseed_password_hash=${TUSER_HASH}" "preseed_root_hash=${ROOT_HASH}" > /dev/null + "${PRESEED[@]}" > /dev/null "${SELF}/run-ovmf.sh" --usb "$USB" --disk "$DISK" --testctl "$CTL" --vars clean --mode smoke --timeout "$TIMEOUT" --name state-install > /dev/null tr -d '\r' < "$LATEST" | grep -q 'KRYPTIK_INSTALL: rc=0' && green "installed" || { red "install failed"; exit 1; } start_vm state-p1 @@ -135,7 +118,7 @@ drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "$(ROOTSH 'poweroff')" "expect:Power down" "wait-exit" rc=$?; stop_vm [[ "$rc" -eq 0 ]] && green "boots with a clone attached; login and the file work" || red "step 2 drive failed" -txt | grep -q 'KRYPTIK_SMOKE: var_source=/dev/vda4 ext4' && green "/var is this disk's partition (vda4), not the clone's" || red "/var is not vda4" +txt | grep -q 'KRYPTIK_SMOKE: var_source=/dev/mapper/kryptik-state ext4' && green "/var is the unlocked state partition" || red "/var is not the unlocked state partition" txt | grep -q 'state_dev=/dev/vda4' && green "boot identity names /dev/vda4" || red "boot identity does not name vda4" txt | grep -q 'sysinit: kryptik-state on other disks ignored: /dev/vdb4' && green "the clone's state partition was seen and ignored" || red "the clone's partition was not reported as ignored" txt | grep -q 'STATE DEGRADED' && red "degraded with a clone attached (ambiguity wrongly detected)" || green "not degraded: the clone is not this installation" @@ -160,10 +143,10 @@ normal_boot state-p3b step "step 4: a corrupt state partition" S4_OFF=$(( $(part_start "$DISK" 4) * 512 )) SAVE="${VMDIR}/state-super.bin" -# the ext4 superblock and group descriptors: the first 64 KiB of the partition +# both copies of the LUKS2 header: the first 64 KiB of the partition dd if="$DISK" of="$SAVE" bs=1 skip="$S4_OFF" count=65536 status=none dd if=/dev/zero of="$DISK" bs=1 seek="$S4_OFF" count=65536 conv=notrunc status=none -degraded_boot state-p4 'mount of /dev/vda4 failed' +degraded_boot state-p4 '/dev/vda4 carries no LUKS2 header' dd if="$SAVE" of="$DISK" bs=1 seek="$S4_OFF" conv=notrunc status=none normal_boot state-p4b @@ -199,5 +182,11 @@ else echo " note: no softdog line; the reset came from an emulated hardware txt | grep -q 'Kernel panic' && red "state-p6: kernel panic" || green "state-p6: no panic" txt | grep -q 'STATE DEGRADED' && red "state-p6: degraded after the reset" || green "state-p6: state is intact after the reset" +# ----------------------------------------------------------------- step 7 -- +step "step 7: three wrong passphrases, then the right one" +KRYPTIK_STATE_PASSPHRASE=not-the-passphrase degraded_boot state-p7 '/dev/vda4 was not unlocked in three tries' +[[ "$(tr -d '\r' < "$LATEST" | grep -c 'passphrase for the state partition')" -eq 3 ]] && green "state-p7: asked three times and no more" || red "state-p7: not asked exactly three times" +normal_boot state-p7b + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" [[ "$FAIL" -eq 0 ]] || exit 1 diff --git a/tools/image/suite-lib.sh b/tools/image/suite-lib.sh new file mode 100755 index 0000000..8c3c9d4 --- /dev/null +++ b/tools/image/suite-lib.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# What the installed-system suites share: the verdict, the accounts the +# preseed creates, and the plumbing around run-ovmf.sh and vm-drive.py. +# Sourced after common.sh with SELF set. start_vm reads DISK and VARSF; drive +# reads DRIVE_TIMEOUT. +# shellcheck disable=SC2034 # read by the suite that sources this + +PASS=0; FAIL=0 +green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } +red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } +step() { printf '\n==> %s\n' "$*"; } + +# The plaintext exists only in the harness; the hashes are what lands on disk. +TUSER=tester; TPASS=tester-pw; RPASS=root-pw +TUSER_HASH="$(openssl passwd -6 "$TPASS")"; ROOT_HASH="$(openssl passwd -6 "$RPASS")" +# The state passphrase: the installer takes it from the control disk, and +# vm-drive.py answers sysinit with it at every boot of an installed disk, +# driven or not, which is why it is exported. +export KRYPTIK_STATE_PASSPHRASE=state-pw +PRESEED=( "preseed_user=${TUSER}" "preseed_password_hash=${TUSER_HASH}" "preseed_root_hash=${ROOT_HASH}" + "state_passphrase=${KRYPTIK_STATE_PASSPHRASE}" ) + +DRV="${SELF}/vm-drive.py" +LATEST="${KRYPTIK_WORK}/logs/ovmf-serial.latest.log" + +start_vm() { # start_vm NAME [run-ovmf args] -> SER QMP PIDF LOG + local name="$1"; shift + local out; out="$("${SELF}/run-ovmf.sh" --no-media --disk "$DISK" --vars-file "$VARSF" --mode serve --allow-reboot --name "$name" "$@")" + SER="$(sed -n 's/^serial=//p' <<<"$out")"; QMP="$(sed -n 's/^qmp=//p' <<<"$out")" + PIDF="$(sed -n 's/^pid=//p' <<<"$out")"; LOG="$(sed -n 's/^log=//p' <<<"$out")" + [[ -S "$SER" ]] || die "no serial socket: ${out}" +} +stop_vm() { sleep 1; [[ -f "$PIDF" ]] && kill "$(cat "$PIDF")" 2>/dev/null; sleep 1; } +drive() { python3 "$DRV" --serial "$SER" --timeout "${DRIVE_TIMEOUT:-300}" "$@"; } +txt() { tr -d '\r' < "$LOG"; } +ROOTSH() { printf 'su:%s:%s' "$RPASS" "$1"; } # a command as root, through su +# Where partition N of a disk file starts, in sectors, from its GPT. +part_start() { sfdisk -d "$1" 2>/dev/null | awk -v n="$2" -F'[ ,]+' '$1 ~ n"$" {for(i=1;i<=NF;i++) if($i=="start=") print $(i+1)}'; } +# The state partition of a disk file, from the host: opened with the suites' +# passphrase and mounted at MNT, then put away again. +open_state() { # open_state DISK MNT + STATE_LOOP="$(losetup --find --show --offset $(( $(part_start "$1" 4) * 512 )) "$1")" || return 1 + printf '%s' "$KRYPTIK_STATE_PASSPHRASE" | cryptsetup open --type luks2 --key-file=- "$STATE_LOOP" kryptik-suite-state \ + && mount /dev/mapper/kryptik-suite-state "$2" && return 0 + close_state "$2"; return 1 +} +close_state() { # close_state MNT + sync; umount "$1" 2>/dev/null + cryptsetup close kryptik-suite-state 2>/dev/null; losetup -d "$STATE_LOOP" 2>/dev/null +} diff --git a/tools/image/update-test.sh b/tools/image/update-test.sh index 176a7d5..15c3966 100755 --- a/tools/image/update-test.sh +++ b/tools/image/update-test.sh @@ -8,9 +8,10 @@ # # A and B are two stage 06 releases of this tree (make media KRYPTIK_VERSION=... # twice); B's VERSION_ID is the observable change, reported by the guest's -# own boot report and os-release. The payload reaches the guest as files on -# a plain ext4 disk image (fetching is out of scope here and out of zone 0 -# by design). Guest-side mounts go under /run: the installed root is a +# own boot report and os-release. In steps 2 to 7 the payload reaches the +# guest as files on a plain ext4 disk image, the offline path; step 8 has the +# net zone fetch it (docs/design/update-channel.md). Guest-side mounts go +# under /run: the installed root is a # read-only verity image, so /mnt cannot take a directory, which is how the # first run of this driver failed at its first mkdir. # @@ -24,6 +25,9 @@ # a full state partition, a concurrent run; none arms a trial # 4 authenticated recovery: apply A --recovery, reboot -> slot a, version A # 5 rollback: arms b again, reboot -> slot b +# 8 (last) the same update over the network: back to slot a, then the net +# zone fetches B from a release host on this side of the user network, +# zone 0 stages it, `kryptik update apply` installs it, slot b commits # 6 interruption: apply A --recovery, kill the VM mid-write, boot: still # slot b, no trial; apply again succeeds; kill after arming: the # firmware consumes BootNext and boot-success commits a @@ -51,17 +55,20 @@ done for t in python3 mkfs.ext4 truncate ssh-keygen sfdisk; do have "$t" || die "required tool not found: $t"; done VA="$(awk -F': ' '$1=="version"{print $2}' "$PAY_A/manifest")"; VB="$(awk -F': ' '$1=="version"{print $2}' "$PAY_B/manifest")" [[ "$VA" != "$VB" ]] || die "A and B are the same version (${VA})" +# The signed statement that B is current, which stage 06 writes beside B's +# payload. It is made there and not here because this job is never given a +# private key. `not-a-pointer` is its control: the same text signed by the +# release key in the manifest's namespace. +CHAN_B="$(dirname "$PAY_B")/channel-${VB}" +for f in latest latest.sig not-a-pointer not-a-pointer.sig; do + [[ -s "$CHAN_B/$f" ]] || die "no ${CHAN_B}/${f}: stage 06's payload step writes it beside the payload" +done VMDIR="${KRYPTIK_WORK}/vm"; mkdir -p "$VMDIR" DISK="${DISK:-${VMDIR}/updated.img}" [[ -e "$DISK" && ! -f "$DISK" ]] && die "refusing: ${DISK} is not a regular file" -PASS=0; FAIL=0 -green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } -red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } -step() { printf '\n==> %s\n' "$*"; } -TUSER=tester; TPASS=tester-pw; RPASS=root-pw -TUSER_HASH="$(openssl passwd -6 "$TPASS")"; ROOT_HASH="$(openssl passwd -6 "$RPASS")" -DRV="${SELF}/vm-drive.py" +# shellcheck source=tools/image/suite-lib.sh +source "${SELF}/suite-lib.sh" VARSF="${VMDIR}/updated-vars.fd" [[ "$VARS" == "enrolled" ]] && cp "${KRYPTIK_WORK}/keys/sb/vars/enrolled.fd" "$VARSF" || cp /usr/share/OVMF/OVMF_VARS_4M.fd "$VARSF" @@ -92,20 +99,12 @@ mk_variant extra; echo "ride along" > "$BAD/extra/extra.bin" # An empty lost+found is the medium's own and is passed over (step 2 applies # a payload that is the root of an ext4 disk); one with something in it is not. mk_variant hidden; mkdir -p "$BAD/hidden/lost+found"; echo "ride along" > "$BAD/hidden/lost+found/ride" +# The statement and its control ride on the same disk: step 3 has the real +# updater judge both against the real anchor, with no network involved. +mkdir -p "$BAD/statement"; cp "$CHAN_B/latest" "$CHAN_B/latest.sig" "$CHAN_B/not-a-pointer" "$CHAN_B/not-a-pointer.sig" "$BAD/statement/" BADIMG="${VMDIR}/payload-bad.img"; payload_disk "$BADIMG" "$BAD" -# The guest side, as root through su. -ROOTSH() { printf 'su:%s:%s' "$RPASS" "$1"; } -start_vm() { # start_vm NAME [extra run-ovmf args] -> sets SER PIDF LOG - local name="$1"; shift - local out; out="$("${SELF}/run-ovmf.sh" --no-media --disk "$DISK" --vars-file "$VARSF" --mode serve --allow-reboot --name "$name" "$@")" - SER="$(sed -n 's/^serial=//p' <<<"$out")"; PIDF="$(sed -n 's/^pid=//p' <<<"$out")"; LOG="$(sed -n 's/^log=//p' <<<"$out")"; QMP="$(sed -n 's/^qmp=//p' <<<"$out")" - [[ -S "$SER" ]] || die "no serial socket: ${out}" -} -stop_vm() { sleep 1; [[ -f "$PIDF" ]] && kill "$(cat "$PIDF")" 2>/dev/null; sleep 1; } -drive() { python3 "$DRV" --serial "$SER" --timeout 420 "$@"; } -txt() { tr -d '\r' < "$LOG"; } -part_start_disk() { sfdisk -d "$1" 2>/dev/null | awk -v n="$2" -F'[ ,]+' '$1 ~ n"$" {for(i=1;i<=NF;i++) if($i=="start=") print $(i+1)}'; } +DRIVE_TIMEOUT=420 # Each step starts from the state the one before it leaves. When the copy in # step 4 failed for want of disk space, steps 5 to 7 went on to roll back a @@ -130,7 +129,7 @@ DISK_SIZE="$("${SELF}/test-disk-size.sh" --medium "$USB_A" --payloads 2)" || die rm -f "$DISK"; truncate -s "$DISK_SIZE" "$DISK" CTL="${VMDIR}/testctl-update.img" "${SELF}/mk-testctl.sh" --out "$CTL" install_target=/dev/vda smoke_poweroff=1 install_wait=5 \ - "preseed_user=${TUSER}" "preseed_password_hash=${TUSER_HASH}" "preseed_root_hash=${ROOT_HASH}" > /dev/null + "${PRESEED[@]}" > /dev/null "${SELF}/run-ovmf.sh" --usb "$USB_A" --disk "$DISK" --testctl "$CTL" --vars "$VARS" --mode smoke --timeout "$TIMEOUT" --name update-install > /dev/null tr -d '\r' < "${KRYPTIK_WORK}/logs/ovmf-serial.latest.log" | grep -q 'KRYPTIK_INSTALL: rc=0' && green "A installed" || { red "A did not install"; exit 1; } @@ -178,12 +177,14 @@ drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "$(ROOTSH 'kryptik-update apply /run/upd/p/extra --recovery; echo RC=$?')" "expect:unlisted file" \ "$(ROOTSH 'kryptik-update apply /run/upd/p/hidden --recovery; echo RC=$?')" "expect:lost\\+found is not empty" \ "$(ROOTSH 'kryptik-update apply /run/upd/a; echo RC=$?')" "expect:older than the running" \ + "$(ROOTSH 'kryptik-update check-pointer /run/upd/p/statement/latest /run/upd/p/statement/latest.sig && echo STATEMENT-OK')" "expect:signed by kryptik-latest" "expect:STATEMENT-OK" \ + "$(ROOTSH 'kryptik-update check-pointer /run/upd/p/statement/not-a-pointer /run/upd/p/statement/not-a-pointer.sig; echo RC=$?')" "expect:does NOT verify" "expect:RC=1" \ "$(ROOTSH 'flock /run/kryptik/update.lock sleep 20 & sleep 1; kryptik-update apply /run/upd/a --recovery; echo RC=$?')" "expect:another update is in progress" \ "$(ROOTSH 'fallocate -l 100G /var/filler 2>/dev/null || dd if=/dev/zero of=/var/filler bs=1M 2>/dev/null; cp -a /run/upd/a /var/lib/kryptik/updates/a-full 2>&1 | tail -1; kryptik-update apply /var/lib/kryptik/updates/a-full --recovery; echo RC=$?; rm -rf /var/filler /var/lib/kryptik/updates/a-full')" "expect:RC=1" \ "$(ROOTSH 'kryptik-update status')" "expect:trial pending: none" \ "$(ROOTSH 'poweroff')" "expect:Power down" "wait-exit" rc=$?; stop_vm -[[ "$rc" -eq 0 ]] && green "wrong key, modified image, truncated kernel, extra file, downgrade, concurrent run and full disk were all refused; no trial armed" || red "step 3 drive failed" +[[ "$rc" -eq 0 ]] && green "wrong key, modified image, truncated kernel, extra file, downgrade, concurrent run and full disk were all refused; no trial armed; the statement of what is current verifies against the image's anchor and one signed by the release key does not" || red "step 3 drive failed" # ----------------------------------------------------------------- step 4 -- step "step 4: authenticated recovery to ${VA} with --recovery" @@ -285,7 +286,7 @@ drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ rc=$?; stop_vm [[ "$rc" -eq 0 ]] && green "B armed from slot a" || red "step 7 arming failed" stop_unless_ok "$rc" "step 7 arming" -B_OFF=$(( $(part_start_disk "$DISK" 3) * 512 )) +B_OFF=$(( $(part_start "$DISK" 3) * 512 )) # The ext4 superblock's volume name: the first block a root mount reads, so # the trial boot meets the corruption at once (a byte deep in the data area # can sit in a block nothing reads at boot, and the trial would succeed). @@ -312,6 +313,101 @@ txt | grep -q 'boot-success: trial slot b did NOT boot' && green "boot-success n starts="$(txt | grep -c 'BdsDxe: starting Boot')"; ups="$(txt | grep -c 'KRYPTIK_SMOKE: END')"; panics="$(txt | grep -c 'Kernel panic')" if [[ "$starts" -ge 3 && "$ups" -ge 2 && "$panics" -ge 1 ]]; then green "three boots in one session: the corrupt trial (panicked), the fallback and the retried trial (both reached userspace)"; else red "expected three boots: firmware starts=${starts}, userspace ends=${ups}, panics=${panics}"; fi +# ----------------------------------------------------------------- step 8 -- +step "step 8: ${VB} once more, fetched by the net zone and staged by zone 0" +# Step 7 leaves slot b running ${VB}, committed, and there is nothing newer to +# fetch. So first back to slot a by rollback (what step 5 proves, the other +# way round); from ${VA} the channel has a release to offer. The copy step 7 +# applied from goes first: the staged release is then the second payload's +# worth on kryptik-state, not the third, which is what the disk was sized +# for (--payloads 2 above). +start_vm update-p8 +drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ + "$(ROOTSH 'rm -rf /var/lib/kryptik/updates/b2; kryptik-update rollback && echo RB8-OK')" "expect:armed: the next boot tries slot a" "expect:RB8-OK" \ + "$(ROOTSH 'reboot')" "expect:Linux version" "expect:KRYPTIK_SMOKE: END" \ + "login:${TUSER}:${TPASS}" \ + "$(ROOTSH 'cat /run/kryptik/boot-identity | head -1; cat /var/lib/kryptik/boot/last-result; echo P8A-OK')" "expect:slot=a" "expect:commit a" "expect:P8A-OK" \ + "$(ROOTSH 'poweroff')" "expect:Power down" "wait-exit" +rc=$?; stop_vm +[[ "$rc" -eq 0 ]] && green "back on slot a (${VA}) by rollback, with room for one staged release" || red "step 8: the rollback to slot a failed" +stop_unless_ok "$rc" "step 8 rollback" + +# The release host: B's statement and B's payload, served from this side of +# QEMU's user network. Bound to loopback, which is what the guest reaches as +# 10.0.2.2; never to an address another machine on the runner's network could +# ask. http is what a development image may be pointed at and a production +# one may not. The files are links: nothing here copies three gigabytes. +SERVE="${VMDIR}/channel"; rm -rf "$SERVE"; mkdir -p "$SERVE/${VB}" +cp "$CHAN_B/latest" "$CHAN_B/latest.sig" "$SERVE/" +for f in "$PAY_B"/*; do [[ -f "$f" ]] && ln -s "$(readlink -f "$f")" "$SERVE/${VB}/$(basename "$f")"; done +# release-host.py honours Range with a 206, as the design asks of a release +# host, so a fetch that is cut resumes from its byte instead of re-reading +# gigabytes through the user network; it streams, binds loopback and a port +# the kernel picks, and logs one line per request. It must not outlive this +# suite whichever way the suite ends, hence the trap. +CHAN_LOG="${VMDIR}/channel-requests.log"; : > "$CHAN_LOG"; rm -f "${VMDIR}/channel.port" +python3 "${SELF}/release-host.py" "$SERVE" "${VMDIR}/channel.port" "$CHAN_LOG" > "${VMDIR}/channel-host.err" 2>&1 & +CHAN_PID=$! +trap '[[ -n "${CHAN_PID:-}" ]] && kill "$CHAN_PID" 2>/dev/null' EXIT +for _ in $(seq 50); do [[ -s "${VMDIR}/channel.port" ]] && break; sleep 0.1; done +CHAN_PORT="$(cat "${VMDIR}/channel.port" 2>/dev/null)" +[[ -n "$CHAN_PORT" ]] || die "the release host did not start: $(cat "${VMDIR}/channel-host.err")" + +# The guest, with a network for this step and no other. Zone 0 names the +# channel; the net zone sees that file only from its next launch, so its +# service is restarted. Then, in order: the net zone brings the statement +# and zone 0 accepts it (status names ${VB}); nothing is fetched until the +# person asks; the person asks; the release arrives and is complete; apply +# is the offline path's apply, and the trial boot and the commit are the +# ones steps 2 and 7 judge. Each wait is a loop in the guest that gives up +# before the driver would. +# The net zone is restarted the way build/guest-tests/zones-check.sh does it, +# the one restart the suites have proven: down, a pause, up, then a NEW +# "netzone: READY" line in the catch-all log. Before that line, `status` +# would be read while the old zone is still there. +# (No single quote may appear in a command given to ROOTSH: the driver +# wraps it in them for `su -c`. And it must not `exit`, or the driver's own +# marker after it is never printed; hence the subshell.) +RESTART_NET='before=$(grep -hc "netzone: READY" /run/uncaught-logs/current 2>/dev/null); before=${before:-0}; s6-svc -d /run/service/net-zone; sleep 3; s6-svc -u /run/service/net-zone; (i=0; until [ "$(grep -hc "netzone: READY" /run/uncaught-logs/current 2>/dev/null || true)" -gt "$before" ]; do i=$((i+1)); [ $i -lt 90 ] || exit 1; sleep 1; done) && echo NET-RESTARTED || echo NET-NOT-READY' +# The release is gigabytes through the user network on a nested-KVM runner, +# so the wait for it is not a clock: it fails when the staged line has not +# changed for 100 seconds (a stall; the net zone asks once a minute, so less +# would call the quiet before the first piece a stall), it succeeds at +# "complete", and at the end of its six minutes it succeeds too if bytes +# were still arriving, for the next wait to take over. A slow link costs +# waits, not the run. +wait_arrival() { printf '%s' '(prev=; same=0; i=0; while [ $i -lt 72 ]; do s="$(kryptik update status | sed -n "s/^staged *//p")"; case "$s" in *"bytes, complete"*) echo ARRIVED-WHOLE; exit 0 ;; esac; if [ "$s" = "$prev" ]; then same=$((same+1)); else same=0; prev="$s"; fi; [ $same -lt 20 ] || { echo "STALLED at: $s"; exit 1; }; i=$((i+1)); sleep 5; done; echo "still arriving: $s")'; } +# In a subshell, so that giving up is not the login shell's exit; and giving +# up is a failed command, because the driver's `run:` judges the exit status +# and the word it then expects is also in the command line the console echoes. +wait_status() { printf '(i=0; until kryptik update status | grep -q "%s"; do i=$((i+1)); [ $i -lt 72 ] || exit 1; sleep 5; done) && echo %s || { kryptik update status; false; }' "$1" "$2"; } +start_vm update-p8b --net user +drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ + "$(ROOTSH "mkdir -p /etc/kryptik && printf \"channel = http://10.0.2.2:${CHAN_PORT}/\\n\" > /etc/kryptik/update.conf && echo CONF-OK")" "expect:CONF-OK" \ + "$(ROOTSH "$RESTART_NET")" "expect:NET-RESTARTED" \ + "run:kryptik update status | grep -q 'nothing asked for'" \ + "run:$(wait_status "newest ${VB} " STATED-OK)" "expect:STATED-OK" \ + "$(ROOTSH 'sleep 70; echo STAGED-UNASKED=$(ls /var/lib/kryptik/update/incoming 2>/dev/null | wc -l)')" "expect:STAGED-UNASKED=0" \ + "run:kryptik update fetch" "expect:${VB} will be fetched" \ + "run:$(wait_status "${VB}: .* bytes, " ARRIVING-OK)" "expect:ARRIVING-OK" \ + "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 manifest manifest.sig root.json LISTED" \ + "run:kryptik update apply" "expect:armed: the next boot tries slot b" \ + "$(ROOTSH 'reboot')" "expect:Linux version" "expect:KRYPTIK_SMOKE: END" \ + "login:${TUSER}:${TPASS}" \ + "$(ROOTSH 'cat /run/kryptik/boot-identity | head -1; cat /var/lib/kryptik/boot/last-result; echo P8C-OK')" "expect:slot=b" "expect:commit b" "expect:P8C-OK" \ + "run:test \"\$(cat /home/${TUSER}/marker)\" = before-update" \ + "$(ROOTSH 'poweroff')" "expect:Power down" "wait-exit" +rc=$?; stop_vm +kill "$CHAN_PID" 2>/dev/null; CHAN_PID="" +[[ "$rc" -eq 0 ]] && green "the net zone brought the statement, nothing was fetched until it was asked for, ${VB} arrived whole, and it was applied, trial-booted and committed; data intact" || red "step 8 drive failed" +txt | grep -q "version_id=${VB}" && green "guest reports version ${VB} after the fetched update" || red "guest did not report ${VB}" +# What the release host was asked for: the statement, then the manifest and +# its signature before anything large. +first="$(awk '{sub("^/", "", $1); if (!seen[$1]++) print $1}' "$CHAN_LOG" | head -5 | tr '\n' ' ')" +if [[ "$first" == "latest latest.sig ${VB}/manifest ${VB}/manifest.sig "* ]]; then green "the release host was asked for the statement, then the manifest and its signature, before any image"; else red "the release host was asked in another order: ${first}"; fi + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" echo "Limits: the interruptions are QEMU process kills with cache=writeback and explicit fsyncs;" echo "they exercise the recovery logic, not a storage controller's power-loss behaviour." diff --git a/tools/image/vm-drive.py b/tools/image/vm-drive.py index 73a4695..6a775c7 100755 --- a/tools/image/vm-drive.py +++ b/tools/image/vm-drive.py @@ -22,11 +22,17 @@ (qcodes, e.g. key:y key:ret key:alt+e) wait-exit wait for the serial socket to close (guest gone) +An installed disk asks for its state passphrase on this console at every boot. +With KRYPTIK_STATE_PASSPHRASE set, the driver answers wherever that is asked, +as a person would; no step names it. + Exit status 0 when every step succeeded; the failing step is named otherwise. The whole transcript goes to --log. stdlib only; no pexpect. """ import json, os, re, socket, sys, time +UNLOCK = re.compile(rb"passphrase for the state partition \(try \d of 3\): ") + class Drive: def __init__(self, path, log, timeout): self.s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) @@ -37,8 +43,9 @@ def __init__(self, path, log, timeout): self.log = open(log, "ab") if log else None self.timeout = timeout self.closed = False - self.records = {} self.marker = 0 + self.passphrase = os.environ.get("KRYPTIK_STATE_PASSPHRASE") + self.answered = 0 # how far into the transcript the prompts are answered def _read(self): try: @@ -52,6 +59,12 @@ def _read(self): self.all += d if self.log: self.log.write(d); self.log.flush() + if self.passphrase: + # From the last answer, or just before this read: a prompt may + # straddle two reads, and none is answered twice. + for m in UNLOCK.finditer(self.all, max(self.answered, len(self.all) - len(d) - 80)): + self.answered = m.end() + self.send_secret(self.passphrase) return True def seen(self, regex, timeout=None): @@ -93,6 +106,14 @@ def send(self, text, enter=True): if self.log: self.log.write(b"\n<<< " + text.encode() + b"\n"); self.log.flush() + def send_secret(self, text): + # To the guest and never to the transcript, which is uploaded with every + # acceptance report. A method of its own, so that no path leads from a + # password to the log. + self.s.sendall(text.encode() + b"\r") + if self.log: + self.log.write(b"\n<<< (a password)\n"); self.log.flush() + def drain(self, seconds): end = time.time() + seconds while time.time() < end: @@ -142,7 +163,7 @@ def login(self, user, password): self.knock(r"login: ?$", self.timeout) self.send(user) self.expect(r"Password: ?", 60) - self.send(password) + self.send_secret(password) # a fresh shell prompt: bash prints "user@host:dir$ " or "$ " self.expect(r"[$#] ?$", 60) # make the prompt unambiguous for run() @@ -150,32 +171,17 @@ def login(self, user, password): self.expect(r"READY-\d+", 30) self.expect(r"KDRV\$ ?$", 30) - def run(self, cmd, require_zero=True, record=None): + def run(self, cmd, require_zero=True): self.marker += 1 tag = f"KRC{self.marker}" self.send(f"{cmd}; echo {tag}=$?") m = self.expect(rf"{tag}=(\d+)", self.timeout) rc = int(m.group(1)) - # output between the echoed command and the tag is what we captured; - # keep whatever preceded the match for records - if record is not None: - self.records[record] = self.last_output.decode("utf-8", "replace") if hasattr(self, "last_output") else "" self.expect(r"KDRV\$ ?$", 30) if require_zero and rc != 0: raise RuntimeError(f"command failed ({rc}): {cmd}") return rc - def grab(self, name, cmd): - self.marker += 1 - tag = f"KRC{self.marker}" - self.send(f"echo BEGIN-{tag}; {cmd}; echo END-{tag}=$?") - self.expect(rf"BEGIN-{tag}\r?\n", self.timeout) - m = self.expect(rf"END-{tag}=(\d+)", self.timeout) - # everything consumed up to the END marker is in the discarded prefix; - # re-search the log-less buffer: simpler to capture during expect - self.expect(r"KDRV\$ ?$", 30) - return int(m.group(1)) - def su(self, password, cmd): # A login shell for root: the image strips the sbin directories from # an ordinary user's PATH, and a plain `su -c` inherits that PATH, so @@ -184,7 +190,7 @@ def su(self, password, cmd): tag = f"KRC{self.marker}" self.send(f"su - root -c '{cmd}; echo {tag}=$?'") self.expect(r"Password: ?", 60) - self.send(password) + self.send_secret(password) # Wait for the exit marker, but keep what the command printed: the # steps that follow expect lines of that output ("running slot: a", # "ZT END"), and a plain expect() would have consumed them with the diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 3a6efc0..f706af0 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -46,22 +46,10 @@ VMDIR="${KRYPTIK_WORK}/vm"; mkdir -p "$VMDIR" DISK="${DISK:-${VMDIR}/zones.img}" [[ -e "$DISK" && ! -f "$DISK" ]] && die "refusing: ${DISK} is not a regular file" -PASS=0; FAIL=0 -green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } -red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } -step() { printf '\n==> %s\n' "$*"; } -TUSER=tester; TPASS=tester-pw; RPASS=root-pw -TUSER_HASH="$(openssl passwd -6 "$TPASS")"; ROOT_HASH="$(openssl passwd -6 "$RPASS")" -DRV="${SELF}/vm-drive.py" +# shellcheck source=tools/image/suite-lib.sh +source "${SELF}/suite-lib.sh" VARSF="${VMDIR}/zones-vars.fd"; cp /usr/share/OVMF/OVMF_VARS_4M.fd "$VARSF" -LATEST="${KRYPTIK_WORK}/logs/ovmf-serial.latest.log" -ROOTSH() { printf 'su:%s:%s' "$RPASS" "$1"; } -start_vm() { local name="$1"; shift; local out; out="$("${SELF}/run-ovmf.sh" --no-media --disk "$DISK" --vars-file "$VARSF" --mode serve --allow-reboot --net user --name "$name" "$@")" - SER="$(sed -n 's/^serial=//p' <<<"$out")"; PIDF="$(sed -n 's/^pid=//p' <<<"$out")"; LOG="$(sed -n 's/^log=//p' <<<"$out")" - [[ -S "$SER" ]] || die "no serial socket: ${out}"; } -stop_vm() { sleep 1; [[ -f "$PIDF" ]] && kill "$(cat "$PIDF")" 2>/dev/null; sleep 1; } drive() { python3 "$DRV" --serial "$SER" --timeout "$1" "${@:2}"; } -txt() { tr -d '\r' < "$LOG"; } # ----------------------------------------------------------------- step 1 -- step "step 1: install and boot alone with a NIC" @@ -70,7 +58,7 @@ DISK_SIZE="$("${SELF}/test-disk-size.sh" --medium "$USB" --extra-mib 2048)" || d rm -f "$DISK"; truncate -s "$DISK_SIZE" "$DISK" CTL="${VMDIR}/testctl-zones.img" "${SELF}/mk-testctl.sh" --out "$CTL" install_target=/dev/vda smoke_poweroff=1 install_wait=5 \ - "preseed_user=${TUSER}" "preseed_password_hash=${TUSER_HASH}" "preseed_root_hash=${ROOT_HASH}" > /dev/null + "${PRESEED[@]}" > /dev/null "${SELF}/run-ovmf.sh" --usb "$USB" --disk "$DISK" --testctl "$CTL" --vars clean --mode smoke --timeout "$TIMEOUT" --name zones-install > /dev/null tr -d '\r' < "$LATEST" | grep -q 'KRYPTIK_INSTALL: rc=0' && green "installed" || { red "install failed"; exit 1; } @@ -79,7 +67,7 @@ step "step 2: the guest-side zone, network and storage checks (as root)" # 3 GB, not the 2 GB default: the ephemeral-size-bound check fills untrusted's # 2G tmpfs to its limit, and those pages are RAM. In a 2 GB guest the fill # ran the machine out of memory before ENOSPC could be reached. -start_vm zones-p2 --mem 3072 +start_vm zones-p2 --net user --mem 3072 drive 900 "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" diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index 36fb606..dd50aa9 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -13,7 +13,8 @@ # 2 kryptik-a the medium's verity root image, byte for byte, # read back and hashed against the medium's record # 3 kryptik-b empty (the first update fills it) -# 4 kryptik-state ext4, with install.json and the first-boot preseed +# 4 kryptik-state LUKS2 (docs/design/state-encryption.md), ext4 inside +# it, with install.json and the first-boot preseed # # It refuses to touch: # - the device the running root is on, through any dm/loop stack @@ -55,7 +56,7 @@ done # --- every external tool, checked before the first write ------------------- missing="" -for tool in sfdisk partx blockdev blkid mkfs.ext4 dd sha256sum mount umount sync awk sed \ +for tool in sfdisk partx blockdev blkid cryptsetup stty mkfs.ext4 dd sha256sum mount umount sync awk sed \ readlink lsblk head cmp cp mkdir stat tr; do command -v "$tool" >/dev/null 2>&1 || missing="${missing} ${tool}" done @@ -73,31 +74,10 @@ part_dev() { esac } -# The whole disk a block device belongs to (a partition -> its disk). -disk_of() { - n="$(basename "$1")" - if [ -e "/sys/class/block/$n/partition" ]; then - printf '/dev/%s' "$(basename "$(readlink -f "/sys/class/block/$n/..")")" - else - printf '/dev/%s' "$n" - fi -} - -# Every physical disk under a device, through dm and loop stacks. -# Prints one /dev/X per line. -disks_under() { - n="$(basename "$1")" - if [ -d "/sys/class/block/$n/slaves" ] && [ -n "$(ls "/sys/class/block/$n/slaves" 2>/dev/null)" ]; then - for s in /sys/class/block/"$n"/slaves/*; do disks_under "/dev/$(basename "$s")"; done - elif [ -r "/sys/class/block/$n/loop/backing_file" ]; then - # a loop device: the disk holding its backing file - bf="$(cat "/sys/class/block/$n/loop/backing_file")" - src="$(awk -v f="$bf" 'BEGIN{best=""} {if (index(f, $2)==1 && length($2)>length(best)) {best=$2; dev=$1}} END{print dev}' /proc/mounts)" - [ -n "$src" ] && disks_under "$src" - else - disk_of "/dev/$n" - fi -} +# Which disk a device is on, and which partitions are this system's own +# (the medium it booted from): the same answers the boot services use. This +# file had copies of two of these functions, and they had drifted. +. /usr/libexec/kryptik/devices.sh # --- refuse anything that is not a disposable whole disk ------------------- [ -b "$TARGET" ] || die "${TARGET} is not a block device. @@ -114,7 +94,9 @@ tname="$(basename "$TARGET_REAL")" # IS that disk, refuse - installing over the system you are running from is # not a supported outcome, it is a crash with extra steps. root_src="$(awk '$2 == "/" { print $1; exit }' /proc/mounts)" -root_disks="$(disks_under "$root_src" 2>/dev/null | sort -u)" +# kryptik_root_disk, not this name: with no initramfs the kernel calls the root +# /dev/root, which names no device, and the guard below compared against that. +root_disks="$(kryptik_root_disk 2>/dev/null || true)" for d in $root_disks; do [ "$(readlink -f "$d")" = "$TARGET_REAL" ] && die "${TARGET} is the disk this system is running from (root ${root_src} sits on ${d}). Refusing." @@ -122,11 +104,11 @@ done # Likewise the state partition, the medium's ESP and the test-control disk. for lbl in kryptik-state kryptik-testctl; do for dev in $(blkid -t PARTLABEL="$lbl" -o device 2>/dev/null); do - [ "$(readlink -f "$(disk_of "$dev")")" = "$TARGET_REAL" ] && die "${TARGET} holds the ${lbl} partition in use by this system. Refusing." + [ "$(readlink -f "$(_kd_disk_of "$dev")")" = "$TARGET_REAL" ] && die "${TARGET} holds the ${lbl} partition in use by this system. Refusing." done done for dev in $(blkid -t PARTLABEL=kryptik-media -o device 2>/dev/null); do - [ "$(readlink -f "$(disk_of "$dev")")" = "$TARGET_REAL" ] && die "${TARGET} is the install medium. Refusing." + [ "$(readlink -f "$(_kd_disk_of "$dev")")" = "$TARGET_REAL" ] && die "${TARGET} is the install medium. Refusing." done # Anything mounted from the target, or any of its partitions, is a hard stop. @@ -143,11 +125,14 @@ fi 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" +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 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 rmdir "$MNT_BASE" 2>/dev/null || true } trap cleanup EXIT INT TERM @@ -157,11 +142,13 @@ ROOT_SRC="" # block device holding the root image at ROOT_OFF ROOT_OFF=0 case "$media" in usb) - ROOT_SRC="$(blkid -t PARTLABEL=kryptik-media -o device 2>/dev/null | head -1)" - [ -b "$ROOT_SRC" ] || die "no partition labelled kryptik-media on this medium" - mdisk="$(disk_of "$ROOT_SRC")" - ESP_SRC="$(blkid -t PARTLABEL=kryptik-esp -o device 2>/dev/null | grep "^${mdisk}" | head -1)" - [ -b "$ESP_SRC" ] || die "no kryptik-esp partition on the medium ${mdisk}" + # The medium this system booted from, not the first disk that carries + # the label: a second stick, or a disk labelled to look like one, is + # not what gets installed. + ROOT_SRC="$(kryptik_part kryptik-media)" || true + [ -b "$ROOT_SRC" ] || die "no single kryptik-media partition on the medium this system booted from" + ESP_SRC="$(kryptik_part kryptik-esp)" || true + [ -b "$ESP_SRC" ] || die "no single kryptik-esp partition on the medium this system booted from" mount -o ro "$ESP_SRC" "$MNT_BASE/esp" || die "could not mount the medium's ESP" ROOT_JSON="$MNT_BASE/esp/kryptik/root.json" ;; @@ -232,6 +219,24 @@ if [ "$ASSUME_YES" -ne 1 ]; then [ "$answer" = "ERASE" ] || die "not confirmed; nothing was written" fi +# The state passphrase, before the first write: from the terminal twice, or +# one line of standard input when that is not a terminal (the unattended +# path). It reaches cryptsetup on a descriptor (printf is a builtin), never +# on a command line and never in a file. +if [ -t 0 ]; then + stty -echo + printf '%s: a passphrase for the state partition, asked at every boot: ' "$PROG" + IFS= read -r STATE_PASS || STATE_PASS="" + printf '\n%s: again: ' "$PROG" + IFS= read -r again || again="" + stty echo; echo + [ "$STATE_PASS" = "$again" ] || die "the two passphrases differ; nothing was written" + again="" +else + IFS= read -r STATE_PASS || STATE_PASS="" +fi +[ -n "$STATE_PASS" ] || die "no state passphrase given; nothing was written" + # --- partition ------------------------------------------------------------- say "partitioning (sfdisk, GPT: kryptik-esp, kryptik-a, kryptik-b, kryptik-state)" P1="$(part_dev "$TARGET_REAL" 1)"; P2="$(part_dev "$TARGET_REAL" 2)" @@ -262,14 +267,20 @@ if [ "$ROOT_OFF" -gt 0 ]; then else dd if="$ROOT_SRC" of="$P2" bs=4M iflag=count_bytes count="$ROOT_BYTES" conv=fsync status=none || die "writing the root image failed" fi +blockdev --flushbufs "$P2" # so the read-back is of the disk, not of the page cache say "reading kryptik-a back" got="$(dd if="$P2" bs=4M iflag=count_bytes count="$ROOT_BYTES" status=none | sha256sum | cut -c1-64)" [ "$got" = "$ROOT_SHA" ] || die "kryptik-a does not verify: wrote ${got}, the medium says ${ROOT_SHA}" say "kryptik-a verifies (${got})" say "clearing kryptik-b" dd if=/dev/zero of="$P3" bs=1M count=4 conv=fsync status=none || die "clearing kryptik-b failed" -say "creating kryptik-state (ext4)" -mkfs.ext4 -q -F -L kryptik-state "$P4" || die "mkfs.ext4 on ${P4} failed" +say "creating kryptik-state (LUKS2, ext4 inside it)" +printf '%s' "$STATE_PASS" | cryptsetup -q luksFormat --type luks2 --cipher aes-xts-plain64 \ + --key-size 512 --pbkdf argon2id --key-file=- "$P4" || die "luksFormat on ${P4} failed" +printf '%s' "$STATE_PASS" | cryptsetup open --type luks2 --key-file=- "$P4" "$MAPPING" \ + || die "could not open the new state partition" +STATE_PASS="" +mkfs.ext4 -q -F -L kryptik-state "/dev/mapper/$MAPPING" || die "mkfs.ext4 inside ${P4} failed" # --- the target ESP: slot A is the committed boot file ---------------------- mount -o rw "$P1" "$MNT_BASE/tesp" || die "could not mount the new ESP" @@ -284,7 +295,7 @@ sync umount "$MNT_BASE/tesp" || die "could not unmount the new ESP" # --- the state partition: what installed this, and the first-boot preseed -- -mount -o rw "$P4" "$MNT_BASE/state" || die "could not mount kryptik-state" +mount -o rw "/dev/mapper/$MAPPING" "$MNT_BASE/state" || die "could not mount kryptik-state" mkdir -p "$MNT_BASE/state/lib/kryptik" cat > "$MNT_BASE/state/lib/kryptik/install.json" <<EOF { @@ -307,8 +318,11 @@ if [ -n "$PRESEED" ] && [ -r "$PRESEED" ]; then fi sync umount "$MNT_BASE/state" || die "could not unmount kryptik-state" +cryptsetup close "$MAPPING" || die "could not close the new state partition" blockdev --flushbufs "$TARGET_REAL" 2>/dev/null || true sync say "installed ${VERSION} to ${TARGET_REAL}: boot it from firmware with the medium removed." say " slot a: ${P2} slot b: ${P3} (empty) state: ${P4} esp: ${P1}" +say "The state partition opens with that passphrase and nothing else: there is no" +say "escrow. Keep a copy of its header (kryptik-recover --backup-state-header)." diff --git a/tools/kryptik b/tools/kryptik index 79ea461..03720b3 100755 --- a/tools/kryptik +++ b/tools/kryptik @@ -84,6 +84,13 @@ USAGE: $PROG wifi add SSID add one, or change its passphrase (asked for on the terminal; never an argument) $PROG wifi forget SSID remove one + $PROG update status the release running, the newest one known + and how old that news is, what is staged + $PROG update fetch ask for the newest release to be fetched + $PROG update apply install what has arrived; the next boot + tries it once and falls back if it fails + $PROG state passphrase change the passphrase the state partition + asks for at boot (root; the old one first) OPTIONS: --zones DIR override the zone directory (default: $ZONES_DIR) @@ -406,6 +413,27 @@ cmd_wifi() { esac } +# The update channel from the person's side. It exists on an installed system +# only: the state is zone 0's and the launch service is what reaches it. +cmd_update() { + case "${1:-}" in + status|fetch|apply) [[ $# -eq 1 ]] || die "$PROG update $1 takes no argument" 2 ;; + *) usage >&2; die "update needs a subcommand: status, fetch or apply" 2 ;; + esac + via_launch || die "update goes through the launch service, which is not answering here (an installed Kryptik, as a session user)" + "$LAUNCH" --update "$1" +} + +# The state partition's passphrase. cryptsetup asks on the terminal, the old +# one first and the new one twice; nothing of it passes through here. +cmd_state() { + [[ "${1:-}" == passphrase && $# -eq 1 ]] || { usage >&2; die "state needs a subcommand: passphrase" 2; } + [[ $EUID -eq 0 ]] || die "the state passphrase is root's to change" + local dev; dev="$(sed -n 's/^state_dev=//p' /run/kryptik/boot-identity 2>/dev/null)" + [[ -b "$dev" ]] || die "this system has no state partition in use" + exec cryptsetup luksChangeKey "$dev" +} + # --- argument parsing ------------------------------------------------------- load_conf @@ -433,6 +461,8 @@ case "$cmd" in gc) cmd_gc ;; doctor) cmd_doctor ;; wifi) cmd_wifi "$@" ;; + update) cmd_update "$@" ;; + state) cmd_state "$@" ;; ""|help) usage ;; # Named explicitly rather than falling into "unknown command", because # these are the things people will reasonably expect to exist. diff --git a/tools/net/netzone-init.sh b/tools/net/netzone-init.sh index 1862cf0..7c87bc6 100755 --- a/tools/net/netzone-init.sh +++ b/tools/net/netzone-init.sh @@ -316,6 +316,33 @@ print(s.recv(4096).decode("utf-8", "replace").strip())' "$off" "$nsrc" "$BROKER" ask_time "$@" time_ticks=0 +# --- updates --------------------------------------------------------------------- +# Zone 0 has no network and never calls this zone, so this zone asks +# (docs/design/update-channel.md): it brings the signed statement of what is +# current, asks whether a release is wanted, and streams what zone 0 says is +# missing. update-fetch.py decides nothing and holds nothing; zone 0 names +# the channel (/etc/kryptik/update.conf on the verified root), verifies +# every signature and refuses any byte it did not ask for. Without that +# file there is no channel and nothing is asked. +UPDATE_CONF=/etc/kryptik/update.conf +UPDATE_FETCH="${KRYPTIK_UPDATE_FETCH:-/usr/libexec/kryptik/update-fetch.py}" +UPDATE_BROUGHT=/run/kryptik-update-statement-brought +UPDATE_PID="" +update_run() { # update_run latest|poll: in the background, one at a time + { command -v python3 >/dev/null 2>&1 && [ -r "$UPDATE_FETCH" ] && [ -r "$UPDATE_CONF" ]; } || return 0 + [ -n "$UPDATE_PID" ] && kill -0 "$UPDATE_PID" 2>/dev/null && return 0 + ( + out="$(python3 "$UPDATE_FETCH" "$1" --broker "$BROKER" 2>&1 | tail -1)" + case "$1:$out" in + poll:idle|*:) ;; + latest:ok*) : > "$UPDATE_BROUGHT"; say "update: zone 0 on the statement of what is current: ${out}" ;; + *) say "update $1: ${out}" ;; + esac + ) & + UPDATE_PID=$! +} +update_ticks=0; statement_ticks=999999 + status_line() { a="$(uplink_addr "$@")" w="$(wifi_state)" @@ -335,6 +362,7 @@ cleanup() { say "stopping" forwarding off [ -n "$DNSPID" ] && kill "$DNSPID" 2>/dev/null + [ -n "$UPDATE_PID" ] && kill "$UPDATE_PID" 2>/dev/null for n in $WIRELESS; do p="$(wpa_pid "$n")"; [ -n "$p" ] && kill "$p" 2>/dev/null; done command -v dhcpcd >/dev/null 2>&1 && dhcpcd -x 2>/dev/null exit 0 @@ -379,6 +407,20 @@ while :; do ask_time "$@" [ "$TIME_STATE" != "$time_was" ] && changed=1 fi + # Updates: the statement once a day once zone 0 has taken one, every + # half hour until then (zone 0 looks at one an hour whatever this zone + # does); the question "is a release wanted?" every minute, which costs + # one line on a local socket and is how a person's `kryptik update + # fetch` is noticed. + update_ticks=$((update_ticks + 1)); statement_ticks=$((statement_ticks + 1)) + if [ -n "$(uplink_addr "$@")" ]; then + if [ -e "$UPDATE_BROUGHT" ]; then statement_every=8640; else statement_every=180; fi + if [ "$statement_ticks" -ge "$statement_every" ]; then + statement_ticks=0; update_run latest + elif [ "$update_ticks" -ge 6 ]; then + update_ticks=0; update_run poll + fi + fi [ "$changed" = 1 ] && status_line "$@" sleep 10 & wait $! diff --git a/tools/net/update-fetch.py b/tools/net/update-fetch.py new file mode 100755 index 0000000..b38c770 --- /dev/null +++ b/tools/net/update-fetch.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""The net zone's half of the update channel (docs/design/update-channel.md). + + update-fetch.py latest fetch the statement of what is current and its + signature, and hand both to zone 0 + update-fetch.py poll ask zone 0 whether a release is wanted and, if + one is, stream what it says is still missing + +This zone is treated as hostile, so nothing here is trusted and nothing here +decides anything: zone 0 verifies every signature, names the address the +files come from (out of the statement it verified), says which file it wants +from which byte, and refuses any piece that is not exactly that. This script +is a pipe with a Range header. It holds nothing: a release is larger than +this zone's storage, so each piece goes from the connection to the broker +and is forgotten. + +Where to look is zone 0's to say: `channel = <address>` in +/etc/kryptik/update.conf, on the verified root and read-only here. TLS +authenticates the host and keeps the request private; nothing about the +release's authenticity rests on it. + +Exit 0: done, or nothing to do. Exit 1: said why on standard error. +""" +import argparse +import socket +import ssl +import sys +import urllib.request + +PIECE = 1 << 20 # the most one update-put carries +SMALL = 8 * 1024 # the most a statement or its signature may be +ROUNDS = 8 # polls per run: the manifest, then the files, then idle + + +def ask(broker, header, payload=b""): + """One request to zone 0's broker, one reply line.""" + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(60) + s.connect(broker) + s.sendall(header.encode() + b"\n" + payload) + s.shutdown(socket.SHUT_WR) + reply = b"" + while len(reply) < 4096: + chunk = s.recv(4096) + if not chunk: + break + reply += chunk + s.close() + return reply.decode("utf-8", "replace").strip() + + +def fetch(url, ca, offset=0): + """An open response positioned at `offset`, whether or not the server + honours a Range: one that ignores it sends the file from the start, and + the bytes before the offset are read and dropped.""" + headers = {"Range": "bytes=%d-" % offset} if offset else {} + context = ssl.create_default_context(cafile=ca) if url.startswith("https://") else None + r = urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=30, context=context) + if offset and r.status != 206: + left = offset + while left: + skipped = r.read(min(left, PIECE)) + if not skipped: + raise OSError("%s ends before byte %d" % (url, offset)) + left -= len(skipped) + return r + + +def channel(conf): + with open(conf, encoding="utf-8") as f: + for line in f: + key, _, value = line.partition("=") + if key.strip() == "channel" and value.strip(): + return value.strip().rstrip("/") + "/" + raise OSError("%s names no channel" % conf) + + +def latest(args): + base = channel(args.conf) + parts = [] + for name in ("latest", "latest.sig"): + body = fetch(base + name, args.ca).read(SMALL + 1) + if not body or len(body) > SMALL: + raise OSError("%s%s is empty or larger than %d bytes" % (base, name, SMALL)) + parts.append(body) + reply = ask(args.broker, "update-latest %d %d" % (len(parts[0]), len(parts[1])), parts[0] + parts[1]) + print(reply) + return 0 if reply.startswith("ok") else 1 + + +def poll(args): + for _ in range(ROUNDS): + words = ask(args.broker, "update-poll").split() + if words[:1] != ["fetch"]: + print(" ".join(words) or "no reply") + return 0 if words == ["idle"] else 1 + # fetch <version> <base> need <name> <offset> [<name> <offset> ...] + if len(words) < 6 or words[3] != "need" or len(words) % 2: + raise OSError("zone 0 said something this does not understand: %s" % " ".join(words)) + version, base, need = words[1], words[2], words[4:] + for name, offset in zip(need[0::2], need[1::2]): + offset = int(offset) + r = fetch(base + name, args.ca, offset) + while True: + piece = r.read(PIECE) + if not piece: + break + reply = ask(args.broker, "update-put %s %d %d" % (name, offset, len(piece)), piece) + if not reply.startswith("ok"): + # Zone 0 has the last word: what it refuses is not sent + # again, and the next poll says what it wants instead. + print("%s %s at byte %d: %s" % (version, name, offset, reply), file=sys.stderr) + return 1 + offset += len(piece) + r.close() + print("still fetching after %d rounds; the next run carries on" % ROUNDS) + return 0 + + +def main(): + ap = argparse.ArgumentParser(description="fetch for zone 0's update channel; decides nothing") + ap.add_argument("what", choices=["latest", "poll"]) + ap.add_argument("--conf", default="/etc/kryptik/update.conf") + ap.add_argument("--broker", default="/run/kryptik/broker") + ap.add_argument("--ca", default="/etc/ssl/certs/ca-certificates.crt") + args = ap.parse_args() + try: + return latest(args) if args.what == "latest" else poll(args) + except (OSError, ValueError) as e: # urllib's errors are OSErrors + print("update-fetch: %s" % e, file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/pin-reviews.tsv b/tools/pin-reviews.tsv new file mode 100644 index 0000000..a56a6c8 --- /dev/null +++ b/tools/pin-reviews.tsv @@ -0,0 +1,49 @@ +# Reviews of pins that are behind their upstream. Read by tools/check-pin-reviews.sh. +# +# package pinned reviewed_up_to fine|held date what was read, and why +# +# fine: nothing released after the pin, up to reviewed_up_to, is a security fix +# that reaches Kryptik. held: something is, and the note says why the pin stays. +# A pin that has caught up has no row; a row for it fails, so this file shrinks. + +# Reviewed 2026-09-19 from upstream NEWS and changelogs, project security +# pages, the Debian security tracker and NVD records. "Survey" below is +# check-source-currency.sh's newest-upstream column on that day. + +# --- toolchain ----------------------------------------------------------------- +binutils 2.43.1 2.47 fine 2026-09-19 NEWS 2.44 to 2.47 and the Debian tracker: every CVE since 2.43 is a crafted-input bug in tools that are only fed the project's own objects, and upstream's SECURITY.txt excludes them; no wrong-code or hardening fix found. 2.44 drops gold, which the recipe enables. +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. +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. + +# --- held: a fix exists upstream and the pin stays, for the reason given ----------- +zlib 1.3.1 1.3.2 held 2026-09-19 1.3.2 fixes CVE-2026-27171 (a CPU loop in crc32_combine when a caller passes a negative length; local, low). 1.3.2 also INTRODUCES CVE-2026-85091, a heap overflow in gz_vacate that 1.3.1 does not contain (checked: the function is absent). The fix, upstream df84af25dc, is in no release. Move when one carries it. +gawk 5.3.0 5.4.1 held 2026-09-19 CVE-2026-40467, -40468, -40469 and -40553 (integer overflows in builtin.c) are fixed in 5.4.1 only; 5.3.2 does not have them. 5.4 makes MinRX the default regex engine under every build script that calls awk, glibc's and the kernel's included. Whether the overflows are reachable from data alone is not established. Moves in a build of its own, not in a batch. +gzip 1.13 1.14 held 2026-09-19 CVE-2026-41992 (out-of-bounds access in the LZH decoder after a crafted .Z file in the same gzip -d run) affects 1.14 and earlier and is fixed only in upstream git, so moving to 1.14 fixes nothing. Carry upstream's patch, or move when 1.15 exists. +acl 2.3.2 2.4.0 held 2026-09-19 2.4.0 fixes CVE-2026-54370 (a race in setfacl, getfacl and chacl) and CVE-2026-54369 (libacl follows symlinks), and does not build with the pinned tar 1.35: both define acl_*_at. Needs the tar fix the LFS book carries. Bumping libacl does not fix its callers; each has to adopt the new functions. +readline 8.2 8.3 held 2026-09-19 No CVE, but 8.2 is built without its 13 official patches, which include a use-after-free and an unterminated paste buffer. 8.3 has to move together with bash 5.3, which needs four symbols new in it. +shadow 4.16.0 4.20.2 held 2026-09-19 No CVE is fixed in the gap (CVE-2024-56433 is unfixed upstream), but 4.17.0 fixes a use-after-free in sgetgrent. 4.19 needs --disable-logind, 4.20 changes the login.defs line the recipe edits, removes PASS_MIN_DAYS and makes su - fail unless TIOCSTI is off. Target 4.19.4, with the recipe. + +# --- fine: nothing in the gap is a security fix that reaches Kryptik --------------- +bash 5.2.32 5.3 fine 2026-09-19 Patches 5.2.33 to 5.2.37 and the 5.3 NEWS: no security fix found. 5.3 needs readline 8.3. +grep 3.11 3.12 fine 2026-09-19 NEWS 3.12: a failure of grep -r on directories with over 100,000 entries; no security fix. +diffutils 3.10 3.12 fine 2026-09-19 NEWS 3.11 and 3.12: no security fix in a release. CVE-2026-53910 (diff3, disputed) is fixed only in upstream git, so a bump would not fix it. Never 3.11. +m4 1.4.19 1.4.21 fine 2026-09-19 NEWS 1.4.20 and 1.4.21: build fixes for newer glibc and compilers. +file 5.45 5.48 fine 2026-09-19 ChangeLog 5.46 to 5.48: the stack overrun it mentions is a regression that exists only in 5.46 (checked in readelf.c: 5.45 uses a BUFSIZ buffer there). Never pin 5.46. +zstd 1.5.6 1.5.7 fine 2026-09-19 Release notes 1.5.7: performance and CLI defaults; no security fix. +libffi 3.5.2 3.8.0 fine 2026-09-19 The x86-64 memory access fixes of 3.4.7 and 3.4.8 are in 3.5.2. 3.6 to 3.8 history read; no upstream security page exists, so this rests on that and an NVD search. +openssl 3.5.8 3.6.4 fine 2026-09-19 3.5.8 is the newest release of the 3.5 LTS series (supported to 2030-04-08) and came out the same day as 3.6.4. The one extra fix in 3.6.4, CVE-2026-54876, affects 3.6 and 4.0 only. 3.6 reaches end of life on 2026-11-01. +gettext 0.22.5 1.0 fine 2026-09-19 NEWS 0.23 to 1.0: no security fix. 1.0 changes how po directories are handled. +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.7 fine 2026-09-19 NEWS to 3.0.7: no security fix. 3.0 breaks the libpkgconf ABI, moves to meson and changed quoting and sysroot handling twice. +iana-etc 20240806 20260911 fine 2026-09-19 Data only: /etc/services and /etc/protocols. +groff 1.23.0 1.24.1 fine 2026-09-19 NEWS 1.24.0 and 1.24.1: no security fix. 1.24.0 has incompatible request-syntax changes; never that one. +e2fsprogs 1.47.1 1.47.4 fine 2026-09-19 Release notes 1.47.2 to 1.47.4: no security fix. +libpipeline 1.5.7 1.5.8 fine 2026-09-19 NEWS 1.5.8: no security fix. +man-db 2.12.1 2.13.1 fine 2026-09-19 NEWS 2.13.0 and 2.13.1: no security fix. +perl 5.40.5 5.44.0 fine 2026-09-19 5.40.5 is the newest 5.40.x and has every fix through CVE-2026-13221. 5.40 is in perlpolicy's security-fix window (tools/support-policy.tsv). 5.42 and 5.44 are new series, not security releases. +libxkbcommon 1.13.2 1.14.0 fine 2026-09-19 NEWS for 1.14.0: no security fix. Upstream's own tags show 1.14.0 only as betas on the day this was read, so the survey may be early. +libdrm 2.4.129 2.4.134 fine 2026-09-19 The 27 commits between them: none is a security fix. +libinput 1.30.4 1.32.0 fine 2026-09-19 CVE-2026-35093, -35094 and -50292 are all fixed in the pinned 1.30.4, the newest 1.30.x. 1.31.3 and 1.32.0 add hardening against malicious uinput devices that was not backported; wlroots 0.19.3 has not been built against 1.32. diff --git a/tools/release-manifest.sh b/tools/release-manifest.sh index 727afcd..9403458 100755 --- a/tools/release-manifest.sh +++ b/tools/release-manifest.sh @@ -9,6 +9,19 @@ # [--principal NAME] [--exact] # [--require-role production] # [--no-downgrade VERSION] MANIFEST +# ./tools/release-manifest.sh pointer --key PRIVKEY --manifest MANIFEST +# --signers SIGNERS --base BASE --out FILE +# [--issued DATE] +# +# `pointer` writes the update channel's statement of what is current +# (docs/design/update-channel.md) for a manifest that is already signed, and +# signs it in a namespace of its own with a key of its own: FILE and FILE.sig +# are what a release host serves as `latest` and `latest.sig`. It says which +# release is current (the manifest's version and role), which manifest that +# is (its SHA-256, so where the files come from decides nothing), where the +# files are (BASE, absolute or relative to the channel address) and when it +# was issued. Re-running it for an unchanged release with a later date is how +# a channel shows that nothing is being withheld. # # This is the verification primitive the signed-image and recoverable-update # work needs: a record of exactly which bytes a release consists @@ -52,7 +65,7 @@ source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" NAMESPACE="kryptik-release" MAGIC="KRYPTIK-MANIFEST-1" -usage() { sed -n '2,10p' "${BASH_SOURCE[0]}"; } +usage() { sed -n '2,13p' "${BASH_SOURCE[0]}"; } [[ "$#" -gt 0 ]] || { usage; exit 1; } MODE="$1"; shift @@ -396,10 +409,66 @@ signed manifest; do not install or boot it." ok "manifest verified: signature, role, and every listed file." } +POINTER_MAGIC="KRYPTIK-LATEST-1" +POINTER_NAMESPACE="kryptik-latest" + +# Does MANIFEST's signature verify against SIGNERS, by a principal enrolled there? +manifest_signed_by() { # MANIFEST SIGNERS + local who + who="$(ssh-keygen -Y find-principals -s "$1.sig" -f "$2" 2>/dev/null | head -1 || true)" + [[ -n "$who" ]] && ssh-keygen -Y verify -f "$2" -I "$who" -n "$NAMESPACE" -s "$1.sig" < "$1" >/dev/null 2>&1 +} + +do_pointer() { + local key="" manifest="" signers="" base="" out="" issued="" + while [[ "$#" -gt 0 ]]; do + case "$1" in + --key) key="${2:?--key needs a file}"; shift 2 ;; + --manifest) manifest="${2:?--manifest needs a file}"; shift 2 ;; + --signers) signers="${2:?--signers needs a file}"; shift 2 ;; + --base) base="${2:?--base needs an address}"; shift 2 ;; + --out) out="${2:?--out needs a file}"; shift 2 ;; + --issued) issued="${2:?--issued needs a date}"; shift 2 ;; + *) die "pointer: unknown argument: $1" ;; + esac + done + [[ -f "$key" ]] || die "pointer: --key is required and must exist" + [[ -f "$manifest" ]] || die "pointer: --manifest is required and must exist" + [[ -n "$base" && -n "$out" ]] || die "pointer: --base and --out are required" + head -1 "$manifest" | grep -qxF "$MAGIC" || die "pointer: ${manifest} is not a ${MAGIC}" + # A statement about a release nobody has signed would announce a manifest + # no machine will accept. + [[ -s "${manifest}.sig" ]] || die "pointer: ${manifest} is not signed yet (no ${manifest}.sig)" + # That a signature file exists says nothing: it has to be the signature + # the machines will check, by a key they have. + [[ -f "$signers" ]] || die "pointer: --signers is required and must exist (the anchor the image carries)" + manifest_signed_by "$manifest" "$signers" \ + || die "pointer: ${manifest}.sig does not verify against ${signers}; no statement is written about it" + case "$base" in *[[:space:]]*) die "pointer: --base must not contain spaces" ;; esac + local version role + version="$(awk -F': ' '$1=="version"{print $2; exit}' "$manifest")" + role="$(awk -F': ' '$1=="role"{print $2; exit}' "$manifest")" + [[ -n "$version" && -n "$role" ]] || die "pointer: the manifest has no version or no role" + issued="${issued:-$(date -u +%Y-%m-%dT%H:%M:%S+00:00)}" + { + printf '%s\n' "$POINTER_MAGIC" + printf 'role: %s\n' "$role" + printf 'version: %s\n' "$version" + printf 'issued: %s\n' "$issued" + printf 'manifest-sha256: %s\n' "$(sha256sum "$manifest" | cut -c1-64)" + printf 'base: %s\n' "$base" + } > "$out" + rm -f "${out}.sig" + ssh-keygen -Y sign -f "$key" -n "$POINTER_NAMESPACE" "$out" < /dev/null >/dev/null 2>&1 \ + || die "pointer: ssh-keygen could not sign with ${key}" + ok "pointer: ${out} names ${version} (${role}), issued ${issued}; signed as ${out}.sig" +} + case "$MODE" in create) do_create "$@" ;; sign) do_sign "$@" ;; verify) do_verify "$@" ;; + pointer) do_pointer "$@" ;; -h|--help|help) usage ;; - *) die "unknown mode '${MODE}' (expected create, sign or verify)" ;; + *) die "unknown mode '${MODE}' (expected create, sign, verify or pointer)" ;; esac diff --git a/tools/resolve-kernel-config.sh b/tools/resolve-kernel-config.sh index 7da3e48..3890ffb 100755 --- a/tools/resolve-kernel-config.sh +++ b/tools/resolve-kernel-config.sh @@ -78,6 +78,10 @@ mkdir -p "$(dirname "$OUT")" cp .config "$OUT" ok "resolved config: ${OUT}" +echo +log "the options Kryptik's guarantees rest on" +kconfig_critical_check "$OUT" || die "a critical option did not survive resolution (MISSING, above)" + echo log "every fragment line, against the resolved config" if kconfig_fragment_check "$OUT" "${FRAGMENTS[@]}"; then diff --git a/tools/run-tests.sh b/tools/run-tests.sh index 3643bb4..2f19fef 100755 --- a/tools/run-tests.sh +++ b/tools/run-tests.sh @@ -25,22 +25,17 @@ cd "$ROOT" || exit 1 STRICT=0 [[ "${1:-}" == "--strict" ]] && STRICT=1 -# name|script. The name is the make target of the same suite, so a failure -# here is reproduced with `make <name>`. -SUITES=( - "test-harness|tools/test-step-errexit.sh" - "test-toolchain-identity|tools/test-toolchain-identity.sh" - "test-hardening|tools/test-hardening-flags.sh" - "test-kernel-hardening|tools/test-check-kernel-hardening.sh" - "test-services|tools/test-services.sh" - "test-netzone-time|tools/test-netzone-time.sh" - "test-boot-success|tools/test-boot-success.sh" - "test-manifest|tools/test-artifact-manifest.sh" - "test-s6-init|tools/test-s6-init-config.sh" - "test-image-signing|tools/test-image-signing.sh" - "test-installer|tools/test-installer.sh" - "test-mkdisk-guards|tools/test-mkdisk-guards.sh" -) +# Every tools/test-* file is a suite: a new one runs here, and in CI, without +# being listed anywhere (this was a list, and eighteen suites were not on it). +# Named are only the ones that run somewhere else: the first two chroot into +# the built system and are acceptance items of their own, the last two need +# the compositor workspace and run with the compartment suites below. +ELSEWHERE=" test-libc-unwind.sh test-userspace-smoke.sh test-desktop-identity.sh test-compositor.sh " +SUITES=() +for t in tools/test-*.sh tools/test-*.py; do + [[ "$ELSEWHERE" == *" ${t##*/} "* ]] && continue + n="${t##*/}"; SUITES+=("${n%.*}|$t") +done # The compartment suites need a kryptikd, which needs cargo. They are always # COUNTED - the total is the total - and where there is no cargo they are diff --git a/tools/test-acceptance-inputs.sh b/tools/test-acceptance-inputs.sh index ac119fd..55f462b 100755 --- a/tools/test-acceptance-inputs.sh +++ b/tools/test-acceptance-inputs.sh @@ -91,5 +91,57 @@ stage; release 0.1.20260915.abcdef01 choose "" "$IMGDIR/payload-0.1.20260915.abcdef01" "$IMGDIR/payload-0.1.20260915.abcdef01" [[ "$NEED" == *"same version"* ]] && ok "need_update refuses A and B being one release" || bad "need_update: '$NEED'" +# 8. The release under test has no ISO and an older release has one: the +# older one is not taken in its place. +stage; release 0.1.20260914.00000000 +: > "$IMGDIR/kryptik-0.1.20260915.abcdef01-usb.img" +choose +[[ "$VER" = 0.1.20260915.abcdef01 && -z "$MEDIA_ISO" ]] && ok "another release's ISO is never the ISO under test" || bad "ISO=$(b "$MEDIA_ISO") for VER=$VER" + +# --- the verdict: item(), its summary parser and the aggregation, as written -- +sed -n '/^R_SUITE=()/,/^# -* prereqs --$/p' "$ACC" > "$T/item.sh" +sed -n '/^need_host() /p; /^verdict_of() {/,/^}/p; /^seal_export() {/,/^}/p' "$ACC" >> "$T/item.sh" +for fn in item checks_in need_host verdict_of seal_export; do + grep -q "^${fn}() " "$T/item.sh" || { echo "could not extract ${fn} from $ACC"; exit 1; } +done +# shellcheck disable=SC2034 # read by the sourced functions +{ OUT="$T/out"; ONLY=""; NOHOST=0; } +mkdir -p "$OUT" +need_cargo() { :; } +# shellcheck source=/dev/null +. "$T/item.sh" +result_of() { local i; for i in "${!R_NAME[@]}"; do [[ "${R_NAME[$i]}" == "$1" ]] && echo "${R_RES[$i]}"; done; } +says() { printf '%s\n' "$SAY"; } +try() { # try NAME MINPASS SUMMARY WANT WHAT: a driver that exits 0 and prints SUMMARY + SAY="$3"; item t "$1" M host "$2" says > /dev/null + [[ "$(result_of "$1")" == "$4" ]] && ok "$5" || bad "$5: got $(result_of "$1")" +} +try clean 0 '25 passed, 0 failed' PASS "exit 0 and no failure counted is a pass" +try counted 0 '25 passed, 3 failed' FAIL "exit 0 with 3 failed in its own summary is a failure" +try other 0 'passed 9, failed 1' FAIL "the same in the installer suite's wording" +try reversed 0 '2 check(s) failed, 40 passed' FAIL "the same in the services suite's wording" +try suites 0 '17 suites: 15 passed, 1 failed, 1 did not run' FAIL "the same in the host suites' wording" +try thin 10 '5 passed, 0 failed' FAIL "fewer checks than the item's minimum is still a failure" +try silent 10 'nothing countable' FAIL "no summary at all, where a minimum is set, is a failure" +[[ "$(verdict_of)" == FAIL ]] && ok "one failed mandatory item fails the verdict" || bad "verdict $(verdict_of)" + +# shellcheck disable=SC2034 # the results so far, put away +{ R_SUITE=(); R_NAME=(); R_MAND=(); R_KIND=(); R_RES=(); R_CHECKS=(); R_RC=(); R_SECS=(); R_LOG=(); R_NOTE=(); } +try clean2 0 'All 12 checks passed' PASS "a clean item, alone" +# shellcheck disable=SC2034 # read by need_host +NOHOST=1; item build host-suites M host 0 says need_host > /dev/null +[[ "$(result_of host-suites)" == INCOMPLETE ]] && ok "--no-host leaves a row, and it reads INCOMPLETE" || bad "--no-host: '$(result_of host-suites)'" +[[ "$(verdict_of)" == INCOMPLETE ]] && ok "a skipped mandatory item keeps the verdict from PASS" || bad "verdict $(verdict_of)" + +# --- the export's list covers every file in it but itself --------------------- +E="$T/export"; mkdir -p "$E/acceptance-logs" +for f in kryptik-1-usb.img manifest-1 manifest-1.sig INSTRUCTIONS.md RELEASE.txt ACCEPTANCE-REPORT.md acceptance-logs/results.tsv; do echo "$f" > "$E/$f"; done +( cd "$E" && sha256sum ./kryptik-1-usb.img ) > "$E/SHA256SUMS" # it_export's line +seal_export "$E" +( cd "$E" && sha256sum --quiet -c SHA256SUMS ) && ok "every line of the export's list verifies" || bad "the export's list does not verify" +missing="$(cd "$E" && find . -type f ! -name SHA256SUMS | while read -r f; do grep -qF " $f" SHA256SUMS || echo "$f"; done)" +[[ -z "$missing" ]] && ok "manifests, signatures, the report and the results are all on the list" || bad "not on the list: $missing" +[[ "$(sort "$E/SHA256SUMS" | uniq -d | wc -l)" -eq 0 ]] && ok "no file is listed twice" || bad "a file is listed twice" + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" [[ "$FAIL" -eq 0 ]] diff --git a/tools/test-check-pin-reviews.sh b/tools/test-check-pin-reviews.sh new file mode 100755 index 0000000..36da73b --- /dev/null +++ b/tools/test-check-pin-reviews.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Tests for tools/check-pin-reviews.sh. Offline: it reads two files. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TOOL="${ROOT}/tools/check-pin-reviews.sh" +W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT +PASS=0; FAIL=0 + +survey() { : > "$W/s"; while [[ $# -ge 4 ]]; do printf '%s\t%s\t%s\t%s\turl\n' "$1" "$2" "$3" "$4" >> "$W/s"; shift 4; done; } +reviews() { printf '%s\n' "$@" > "$W/r"; } +# expect NAME WANT_RC REGEX [extra tool args...] +expect() { + local name="$1" want="$2" rx="$3"; shift 3 + local out rc; out="$("$TOOL" --survey "$W/s" --reviews "$W/r" "$@" 2>&1)"; rc=$? + if [[ "$rc" -eq "$want" ]] && grep -qE -- "$rx" <<<"$out"; then PASS=$((PASS + 1)); echo " PASS $name" + else FAIL=$((FAIL + 1)); echo " FAIL $name (exit $rc, wanted $want)"; sed 's/^/ /' <<<"$out"; fi +} +ROW="zlib 1.3.1 1.3.2 fine 2026-09-19 read the ChangeLog: build fixes only" + +survey zlib 1.3.1 1.3.2 BEHIND bash 5.3 5.3 current +reviews "# none"; expect "a behind pin with no row fails" 1 'NOT REVIEWED: zlib 1.3.1 -> 1.3.2' +reviews "$ROW"; expect "a row that covers it passes" 0 '^ok:' +survey zlib 1.3.1 1.3.3 BEHIND; expect "upstream released past the review" 1 'NEW RELEASE: zlib: reviewed up to 1.3.2, upstream is at 1.3.3' +survey zlib 1.3.2 1.3.3 BEHIND; expect "the pin moved off the version reviewed" 1 'STALE: zlib: the row reviews 1.3.1, the pin is 1.3.2' +survey zlib 1.3.2 1.3.2 current; expect "the pin caught up and the row was left" 1 'STALE: zlib is current now' + +survey coreutils 9.5 9.12 BEHIND +reviews "coreutils 9.5 9.9 fine 2026-09-19 read NEWS"; expect "9.12 sorts after 9.9, not before it" 1 'NEW RELEASE' +reviews "coreutils 9.5 9.12 fine 2026-09-19 read NEWS"; expect "and a review up to 9.12 covers it" 0 '^ok:' + +survey zlib 1.3.1 1.3.2 BEHIND +reviews "zlib 1.3.1 1.3.2 held 2026-09-19 1.3.2 introduces a worse bug" +expect "a held pin passes, with its reason printed" 0 'HELD: zlib 1.3.1: 1.3.2 introduces' +expect "and a release refuses it" 1 'FAIL: 1 pin' --no-held + +reviews "zlib 1.3.1 1.3.2 fine 2026-09-19"; expect "a row with no note is not a review" 1 'MALFORMED: zlib' +reviews "zlib 1.3.1 1.3.2 maybe 2026-09-19 looked"; expect "nor is an unknown verdict" 1 'MALFORMED: zlib' +reviews "$ROW" "$ROW"; expect "nor a second row for one package" 1 'MALFORMED: zlib' +reviews "$ROW" "zlibb 1 2 fine 2026-09-19 a typo"; expect "a row for no source is stale" 1 'STALE: zlibb is not a source' + +# The bug this suite found: an empty "newest" column shifted the fields, and +# every undetermined pin was counted as none. +survey less 661 "" UNKNOWN; reviews "# none" +expect "an undetermined pin is reported, not swallowed" 0 'not determined.*less 661' + +: > "$W/s"; expect "an empty survey has not passed" 1 'need a non-empty --survey' + +printf 'x\t1\t1\tcurrent\turl\n' > "$W/s" +if "$TOOL" --survey "$W/s" 2>&1 | grep -q MALFORMED; then FAIL=$((FAIL + 1)); echo " FAIL tools/pin-reviews.tsv has a malformed row" +else PASS=$((PASS + 1)); echo " PASS tools/pin-reviews.tsv is well formed"; fi + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] diff --git a/tools/test-check-source-currency.sh b/tools/test-check-source-currency.sh index 5f30b55..3457ba3 100755 --- a/tools/test-check-source-currency.sh +++ b/tools/test-check-source-currency.sh @@ -102,6 +102,51 @@ mkdir -p "${SERVE}/api.github.com/repos/acme/preview/releases/latest" printf '{"tag_name": "v9.9.9", "prerelease": true}\n' \ > "${SERVE}/api.github.com/repos/acme/preview/releases/latest/index.html" +# --- hosts with an API or a page instead of a listing ----------------------- +# +# json <path> <body>: what an API endpoint answers. The query string is not +# part of the path, and the fixture server decodes %2F, so a project path is +# two directories here. +json() { mkdir -p "${SERVE}/$1"; printf '%s\n' "$2" > "${SERVE}/$1/index.html"; } + +# freedesktop: ordered by DATE, so the newest version is not first, and a +# release candidate is numbered 1.31.901 with no "rc" anywhere in it. +json "gitlab.freedesktop.org/api/v4/projects/libinput/libinput/releases" \ + '[{"name":"libinput 1.30.4","tag_name":"1.30.4"},{"name":"libinput 1.32.901","tag_name":"1.32.901"},{"name":"libinput 1.32.0","tag_name":"1.32.0"},{"name":"libinput 1.31.3","tag_name":"1.31.3"}]' +json "gitlab.freedesktop.org/api/v4/projects/wayland/wayland/releases" \ + '[{"name":"1.26.91","tag_name":"1.26.91"},{"name":"1.26.0","tag_name":"1.26.0"},{"name":"1.25.0","tag_name":"1.25.0"}]' + +# wlroots: tags, and only the pinned series counts. The commit author's +# "author_name" must not be read as a tag name. +json "gitlab.freedesktop.org/api/v4/projects/wlroots/wlroots/repository/tags" \ + '[{"name":"0.20.2","commit":{"author_name":"9.9.9"}},{"name":"0.19.3","commit":{"author_name":"x"}},{"name":"0.19.2","commit":{"author_name":"x"}},{"name":"0.19.0-rc1","commit":{"author_name":"x"}}]' + +# Forgejo tags with a leading v and pre-release spellings that have letters. +json "codeberg.org/api/v1/repos/dwl/dwl/tags" \ + '[{"name":"v0.9-dev","id":"a"},{"name":"v0.8","id":"b"},{"name":"v0.8-rc1","id":"c"},{"name":"v0.7","id":"d"}]' + +# less: the directory has a newer tarball, and it is a beta. The front page +# says which version is for general use. +mkdir -p "${SERVE}/www.greenwoodsoftware.com/less" +printf '%s\n' '<p>less-718 has been released for beta testing.</p>' \ + '<p>less-710 has been released for general use.</p>' \ + '<a href="less-718.tar.gz">less-718.tar.gz</a> <a href="less-710.tar.gz">less-710.tar.gz</a>' \ + > "${SERVE}/www.greenwoodsoftware.com/less/index.html" + +# lynx: development snapshots beside the release. +page "invisible-mirror.net/archives/lynx/tarballs" \ + "lynx2.9.3.tar.gz" "lynx2.9.3dev.4.tar.gz" "lynx2.9.4dev.2.tar.gz" + +# openssh: the portable suffix is part of the version. +page "ftp.openbsd.org/pub/OpenBSD/OpenSSH/portable" \ + "openssh-10.4p1.tar.gz" "openssh-10.5p1.tar.gz" "openssh-10.5p1.tar.gz.asc" + +# A tags feed for a project that publishes no releases. +mkdir -p "${SERVE}/github.com/a13xp0p0v/kernel-hardening-checker" +printf '%s\n' '<feed><title>Tags from kernel-hardening-checker' \ + 'v0.6.17.1v0.6.10' \ + > "${SERVE}/github.com/a13xp0p0v/kernel-hardening-checker/tags.atom" + # --- fixture server --------------------------------------------------------- python3 - "$SERVE" "${W}/port" >/dev/null 2>&1 <<'PY' & @@ -135,6 +180,7 @@ build_root() { cat > "${FAKE}/build/config/versions.env" <<'EOF' V_PYTHON=3.12.5 V_OPENSSL=3.3.1 +V_WLROOTS=0.19.3 EOF cat > "${FAKE}/tools/fetch-sources.sh" <<'STUB' #!/usr/bin/env bash @@ -150,6 +196,15 @@ perl 5.40.0 https://www.cpan.org/src/5.0/perl-5.40.0.tar.xz zlib 1.3.1 https://github.com/madler/zlib/releases/download/v1.3.1/zlib-1.3.1.tar.gz preview 1.0 https://github.com/acme/preview/releases/download/v1.0/preview-1.0.tar.gz linux 6.18.50 https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.18.50.tar.xz +libinput 1.30.4 https://gitlab.freedesktop.org/libinput/libinput/-/archive/1.30.4/libinput-1.30.4.tar.gz +wayland 1.26.0 https://gitlab.freedesktop.org/wayland/wayland/-/releases/1.26.0/downloads/wayland-1.26.0.tar.xz +wlroots 0.19.3 https://gitlab.freedesktop.org/wlroots/wlroots/-/releases/0.19.3/downloads/wlroots-0.19.3.tar.gz +dwl 0.8 https://codeberg.org/dwl/dwl/releases/download/v0.8/dwl-v0.8.tar.gz +less 661 https://www.greenwoodsoftware.com/less/less-661.tar.gz +lynx 2.9.3 https://invisible-mirror.net/archives/lynx/tarballs/lynx2.9.3.tar.gz +openssh 10.5p1 https://ftp.openbsd.org/pub/OpenBSD/OpenSSH/portable/openssh-10.5p1.tar.gz +kernel-hardening-checker 0.6.17.1 https://github.com/a13xp0p0v/kernel-hardening-checker/archive/refs/tags/v0.6.17.1.tar.gz +glibc-fhs-patch 2.40 https://www.linuxfromscratch.org/patches/lfs/12.2/glibc-2.40-fhs-1.patch ROWS STUB chmod 755 "${FAKE}/tools/fetch-sources.sh" @@ -202,6 +257,18 @@ expect_row preview "" UNKNOWN # a prerelease is not a release expect_row mystery "" UNKNOWN # nothing parsed is not "current" expect_row linux "" deferred # support status, not version +# Hosts read through an API or a page. Each of these was UNKNOWN once, and +# each has a way to be confidently wrong. +expect_row libinput 1.32.0 BEHIND # 1.32.901 is a release candidate; the list is by date +expect_row wayland 1.26.0 current # 1.26.91 is a release candidate +expect_row wlroots 0.19.3 current # the pinned series; 0.20.2 is not a drop-in; author_name is not a tag +expect_row dwl 0.8 current # v0.9-dev and v0.8-rc1 are not releases +expect_row less 710 BEHIND # 718 is a beta, whatever the directory offers +expect_row lynx 2.9.3 current # 2.9.4dev.2 is a snapshot +expect_row openssh 10.5p1 current # the portable suffix is part of the version +expect_row kernel-hardening-checker 0.6.17.1 current # tags only, no releases +expect_row glibc-fhs-patch "" deferred # follows the glibc pin + if [[ "$(field linux 5)" == *check-kernel-eol* ]]; then green "the kernel row names the tool that does answer the question" else diff --git a/tools/test-firstboot.sh b/tools/test-firstboot.sh new file mode 100755 index 0000000..49a0b19 --- /dev/null +++ b/tools/test-firstboot.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# First-boot setup knows when it is done from the account database, so a setup +# cut short anywhere is finished by the next boot. The three predicates are +# taken from build/service-scripts/firstboot.sh itself and pointed at staged +# passwd and shadow files. No root. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SRC="$ROOT/build/service-scripts/firstboot.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 +sed -n '/^regular_user() /p; /^has_password() /p; /^complete() /p' "$SRC" \ + | sed "s|/etc/passwd|$T/passwd|; s|/etc/shadow|$T/shadow|" > "$T/fns.sh" +[[ "$(grep -c '' "$T/fns.sh")" -eq 3 ]] || { echo "could not extract the three predicates from $SRC"; exit 1; } +# shellcheck source=/dev/null +. "$T/fns.sh" + +state() { # state "PASSWD LINES" "SHADOW LINES" + printf '%s\n' "root:x:0:0::/root:/bin/bash" "nobody:x:65534:65534::/:/bin/false" $1 > "$T/passwd" + printf '%s\n' $2 > "$T/shadow" +} +HASH='$6$salt$abcdefghijklmnopqrstuvwxyz' +is() { if complete; then ok "$1"; else bad "$1"; fi; } +not() { if complete; then bad "$1"; else ok "$1"; fi; } + +state "" "root:!:1::::::" +not "a fresh install is not done" +state "ana:x:1000:1000::/home/ana:/bin/bash" "root:!:1:::::: ana:!:1::::::" +not "a user made a moment before the power went (no password yet) is not done" +[[ "$(regular_user)" == ana ]] && ok "and the next boot finds that user instead of asking for a name" || bad "regular_user: '$(regular_user)'" +state "ana:x:1000:1000::/home/ana:/bin/bash" "root:!:1:::::: ana:${HASH}:1::::::" +not "a user who can log in, with root still locked, is not done: nobody could administer it" +state "ana:x:1000:1000::/home/ana:/bin/bash" "root:${HASH}:1:::::: ana:!${HASH}:1::::::" +not "a locked hash is not a password" +state "ana:x:1000:1000::/home/ana:/bin/bash" "root:${HASH}:1:::::: ana:${HASH}:1::::::" +is "a user and root who can both authenticate: done" +state "svc:x:999:999::/:/bin/false" "root:${HASH}:1:::::: svc:${HASH}:1::::::" +not "a system account is not the desktop user" + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] diff --git a/tools/test-git-hooks.sh b/tools/test-git-hooks.sh index 60efe76..09ccc6d 100755 --- a/tools/test-git-hooks.sh +++ b/tools/test-git-hooks.sh @@ -183,7 +183,9 @@ fi echo echo "=== and the real repository, which is where it was actually wrong ===" -real_mode="$(git -C "$ROOT" ls-files -s -- tools/git-hooks/pre-commit | awk '{print $1}')" +# safe.directory: acceptance runs this as root over a checkout that is not +# root's, where git otherwise refuses to read the repository at all. +real_mode="$(git -c safe.directory='*' -C "$ROOT" ls-files -s -- tools/git-hooks/pre-commit | awk '{print $1}')" if [[ "$real_mode" == "100755" ]]; then green "tools/git-hooks/pre-commit is 100755 in this repository's index" else diff --git a/tools/test-launch-secrets.py b/tools/test-launch-secrets.py old mode 100644 new mode 100755 diff --git a/tools/test-release-manifest.sh b/tools/test-release-manifest.sh index 6393423..8cbf98c 100755 --- a/tools/test-release-manifest.sh +++ b/tools/test-release-manifest.sh @@ -472,6 +472,91 @@ else fi rm -f "${REL}/usr/share/.kryptik-update" "${REL}/.kryptik-update" +# --- the update channel's statement of what is current ----------------------- +# `pointer` writes and signs it; the machine's side is kryptik-update's +# check-pointer. The rows that matter are the ones where the two meet: what +# this tool emits is accepted by the updater's own function, under an anchor +# shaped like the image's (each key honoured in one namespace only). +build_release +make_signed 1.0.3 development +ssh-keygen -q -t ed25519 -N '' -C latest -f "${W}/keys/latest" "$ANCHOR" +PTR="${W}/latest" +NO_COLOR=1 bash "$TOOL" pointer --key "${W}/keys/latest" --signers "$ANCHOR" --manifest "$MAN" --base 1.0.3/ --out "$PTR" \ + --issued 2027-03-02T14:05:00+00:00 > "$OUT" 2>&1; RC=$? +want="$(printf 'KRYPTIK-LATEST-1\nrole: development\nversion: 1.0.3\nissued: 2027-03-02T14:05:00+00:00\nmanifest-sha256: %s\nbase: 1.0.3/\n' "$(sha256sum "$MAN" | cut -c1-64)")" +if [[ "$RC" -eq 0 && "$(cat "$PTR")" == "$want" && -s "${PTR}.sig" ]]; then + green "pointer: names the manifest's version and role, its hash, the base and the date, and nothing else" +else + red "pointer: wrote something else (exit ${RC})"; show; cat "$PTR" 2>/dev/null +fi +if ssh-keygen -Y verify -f "$ANCHOR" -I kryptik-latest -n kryptik-latest -s "${PTR}.sig" < "$PTR" >/dev/null 2>&1 \ + && ! ssh-keygen -Y verify -f "$ANCHOR" -I kryptik-latest -n kryptik-release -s "${PTR}.sig" < "$PTR" >/dev/null 2>&1; then + green "pointer: signed in its own namespace, and not a signature a manifest could borrow" +else + red "pointer: the signature is not in kryptik-latest alone" +fi + +# The updater's own check, lifted out of the tool as its suite does. +UPD="${ROOT}/tools/update/kryptik-update" +{ + echo 'LATEST_NAMESPACE=kryptik-latest'; echo 'LATEST_MAGIC=KRYPTIK-LATEST-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_pointer() {/,/^}/p' "$UPD" + printf 'SNAP=%q\n' "${W}/snap"; echo 'mkdir -p "$SNAP"' + echo 'cmd_check_pointer "$1" "$2" && echo ACCEPTED' +} > "${W}/check-pointer.sh" +if bash "${W}/check-pointer.sh" "$PTR" "${PTR}.sig" 2>&1 | grep -qx ACCEPTED; then + green "pointer: what this tool writes is what kryptik-update's check-pointer accepts" +else + red "pointer: kryptik-update refuses what this tool wrote: $(bash "${W}/check-pointer.sh" "$PTR" "${PTR}.sig" 2>&1 | tail -1)" +fi +# Signed by the release key instead: a statement the anchor does not honour. +NO_COLOR=1 bash "$TOOL" pointer --key "${W}/keys/rel" --signers "$ANCHOR" --manifest "$MAN" --base 1.0.3/ --out "${W}/latest-by-rel" > /dev/null 2>&1 +# Into a variable first: the refusal exits 1, and under pipefail that would +# fail the pipeline whatever grep found. +said="$(bash "${W}/check-pointer.sh" "${W}/latest-by-rel" "${W}/latest-by-rel.sig" 2>&1)" +if [[ "$said" == *"REFUSED:"*"does NOT verify"* && "$said" != *ACCEPTED* ]]; then + green "pointer: one signed with the release key is refused by the updater, because the anchor honours that key for releases only" +else + red "pointer: the updater accepted a statement signed by the release key" +fi + +# Re-issued later for the same release: only the date moves. +NO_COLOR=1 bash "$TOOL" pointer --key "${W}/keys/latest" --signers "$ANCHOR" --manifest "$MAN" --base 1.0.3/ --out "${W}/latest-2" \ + --issued 2027-04-01T00:00:00+00:00 > /dev/null 2>&1 +if [[ "$(diff <(cat "$PTR") <(cat "${W}/latest-2") | grep -c '^[<>]')" -eq 2 ]] && grep -qx 'issued: 2027-04-01T00:00:00+00:00' "${W}/latest-2"; then + green "pointer: re-issued for an unchanged release, only the date differs" +else + red "pointer: a re-issue changed more than the date" +fi + +# A signature file that is there and is not the manifest's signature. +cp "${MAN}.sig" "${W}/man.sig.good"; ssh-keygen -q -t ed25519 -N "" -f "${W}/keys/stranger" > /dev/null +rm -f "${MAN}.sig"; ssh-keygen -Y sign -f "${W}/keys/stranger" -n kryptik-release "$MAN" < /dev/null > /dev/null 2>&1 +NO_COLOR=1 bash "$TOOL" pointer --key "${W}/keys/latest" --signers "$ANCHOR" --manifest "$MAN" --base 1.0.3/ --out "${W}/latest-stranger" > "$OUT" 2>&1; RC=$? +if [[ "$RC" -ne 0 && ! -e "${W}/latest-stranger" ]] && grep -q 'does not verify' "$OUT"; then + green "pointer: no statement is written about a manifest signed by a key the image does not carry" +else + red "pointer: wrote a statement for a manifest a stranger signed (exit ${RC})"; show +fi +cp "${W}/man.sig.good" "${MAN}.sig" + +rm -f "${MAN}.sig" +NO_COLOR=1 bash "$TOOL" pointer --key "${W}/keys/latest" --signers "$ANCHOR" --manifest "$MAN" --base 1.0.3/ --out "${W}/latest-unsigned" > "$OUT" 2>&1; RC=$? +if [[ "$RC" -ne 0 && ! -e "${W}/latest-unsigned" ]] && grep -q 'is not signed yet' "$OUT"; then + green "pointer: no statement is written about a manifest nobody has signed" +else + red "pointer: wrote a statement for an unsigned manifest (exit ${RC})"; show +fi + echo if [[ "$FAIL" -gt 0 ]]; then echo "${FAIL} of $((PASS + FAIL)) checks failed." diff --git a/tools/test-update-fetch.sh b/tools/test-update-fetch.sh new file mode 100755 index 0000000..fc6361f --- /dev/null +++ b/tools/test-update-fetch.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# The net zone's half of the update channel, tools/net/update-fetch.py, against +# a real HTTP server on loopback and a stand-in for zone 0's broker on a unix +# socket (docs/design/update-channel.md). +# +# The fetcher decides nothing, so what is checked is that it is a faithful +# pipe: the bytes that arrive are the bytes that were served, in the order +# and from the offsets zone 0 asked for, in pieces zone 0 will take, and that +# it stops when zone 0 says no. The stand-in keeps zone 0's side of the +# conversation honest enough for that: it answers a poll from what it holds, +# and refuses a piece that is not at the offset it holds. +# +# Needs bash and python3; no root, no network beyond loopback. Exit 0 when +# every row passes, 77 when python3 is missing. +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FETCH="$ROOT/tools/net/update-fetch.py" +command -v python3 >/dev/null 2>&1 || { echo "python3 not found; cannot run"; exit 77; } + +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)" +PIDS=() +cleanup() { for p in "${PIDS[@]}"; do kill "$p" 2>/dev/null; done; rm -rf "$T"; } +trap cleanup EXIT + +# --- a release to serve -------------------------------------------------------- +REL="$T/www/chan/1.0.3" +mkdir -p "$REL" "$T/stage" +head -c 2621445 /dev/urandom > "$REL/kryptik-root.img" # 2.5 MiB and five bytes: three pieces +head -c 70000 /dev/urandom > "$REL/kryptik-a.efi" +printf '{ "fixture": true }\n' > "$REL/root.json" +printf 'KRYPTIK-MANIFEST-1\nfixture\n' > "$REL/manifest" +printf 'a signature, as far as this suite cares\n' > "$REL/manifest.sig" +printf 'KRYPTIK-LATEST-1\nversion: 1.0.3\n' > "$T/www/chan/latest" +printf 'and its signature\n' > "$T/www/chan/latest.sig" +# What the stand-in "verified manifest" lists: name and size. +for f in kryptik-root.img kryptik-a.efi root.json; do printf '%s %s\n' "$f" "$(stat -c %s "$REL/$f")"; done > "$T/listed" + +# --- the release host: tools/image/release-host.py, the one the update suite +# serves a real release from. Range honoured unless $T/norange exists. +HOST="$ROOT/tools/image/release-host.py" + +# --- zone 0's broker, as far as the fetcher can tell ------------------------------ +cat > "$T/broker.py" <<'EOF' +import os, socket, sys +sock, work, base = sys.argv[1:4] +stage = os.path.join(work, "stage") +def held(n): + p = os.path.join(stage, n) + return os.path.getsize(p) if os.path.exists(p) else 0 +def poll(): + if not os.path.exists(os.path.join(work, "wanted")): + return "idle" + need = [(n, 0) for n in ("manifest", "manifest.sig") if held(n) == 0] + if not need: + for line in open(os.path.join(work, "listed")): + n, size = line.split() + if held(n) < int(size): + need.append((n, held(n))) + return "fetch 1.0.3 %s need %s" % (base, " ".join("%s %d" % x for x in need)) if need else "idle" +srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +srv.bind(sock); srv.listen(8) +while True: + c, _ = srv.accept() + data = b"" + while True: + chunk = c.recv(1 << 16) + if not chunk: + break + data += chunk + header, _, payload = data.partition(b"\n") + words = header.decode().split() + open(os.path.join(work, "requests.log"), "a").write("%s payload=%d\n" % (header.decode(), len(payload))) + if words[0] == "update-latest": + plen, slen = int(words[1]), int(words[2]) + open(os.path.join(work, "got-latest"), "wb").write(payload[:plen]) + open(os.path.join(work, "got-latest.sig"), "wb").write(payload[plen:plen + slen]) + reply = "ok available 1.0.3" if len(payload) == plen + slen else "error: payload short" + elif words[0] == "update-poll": + reply = poll() + elif words[0] == "update-put": + name, offset, length = words[1], int(words[2]), int(words[3]) + refused = os.path.join(work, "refuse") + if os.path.exists(refused) and open(refused).read().strip() == name: + reply = "error: %s: zone 0 says no" % name + elif offset != held(name) or length != len(payload): + reply = "error: %s: %d bytes are held; the next byte wanted is %d, not %d" % (name, held(name), held(name), offset) + else: + open(os.path.join(stage, name), "ab").write(payload) + reply = "ok %s %d" % (name, held(name)) + else: + reply = "error: unknown verb" + c.sendall((reply + "\n").encode()); c.close() +EOF + +python3 "$HOST" "$T/www" "$T/port" "$T/http.log" "$T/norange" & PIDS+=($!) +for _ in $(seq 50); do [[ -s "$T/port" ]] && break; sleep 0.1; done +PORT="$(cat "$T/port" 2>/dev/null)" +[[ -n "$PORT" ]] || { echo "the HTTP server did not start"; exit 1; } +BASE="http://127.0.0.1:$PORT/chan" +python3 "$T/broker.py" "$T/broker.sock" "$T" "$BASE/1.0.3/" & PIDS+=($!) +for _ in $(seq 50); do [[ -S "$T/broker.sock" ]] && break; sleep 0.1; done +[[ -S "$T/broker.sock" ]] || { echo "the broker stand-in did not start"; exit 1; } +printf '# where releases are\nchannel = %s\n' "$BASE" > "$T/update.conf" + +run() { python3 "$FETCH" "$@" --conf "$T/update.conf" --broker "$T/broker.sock" --ca /nonexistent; } +identical() { cmp -s "$REL/$1" "$T/stage/$1"; } + +# --- the statement of what is current --------------------------------------------- +out="$(run latest 2>&1)"; rc=$? +if [[ "$rc" = 0 && "$out" == "ok available 1.0.3" ]] && cmp -s "$T/www/chan/latest" "$T/got-latest" && cmp -s "$T/www/chan/latest.sig" "$T/got-latest.sig"; then + ok "latest: the statement and its signature reach zone 0 byte for byte, and its answer is passed on" +else + bad "latest: rc=$rc out=$out" +fi + +cp "$T/www/chan/latest" "$T/latest.keep" +head -c 9000 /dev/zero > "$T/www/chan/latest" +: > "$T/requests.log" +out="$(run latest 2>&1)"; rc=$? +if [[ "$rc" = 1 && "$out" == *"larger than 8192"* && ! -s "$T/requests.log" ]]; then + ok "latest: a statement larger than zone 0 will take is not sent at all" +else + bad "latest, oversized: rc=$rc out=$out" +fi +cp "$T/latest.keep" "$T/www/chan/latest" + +# --- nothing asked for -------------------------------------------------------------- +out="$(run poll 2>&1)"; rc=$? +[[ "$rc" = 0 && "$out" == "idle" && -z "$(ls -A "$T/stage")" ]] \ + && ok "poll: told idle, it fetches nothing" || bad "poll, idle: rc=$rc out=$out" + +# --- a release, whole ----------------------------------------------------------------- +: > "$T/wanted"; : > "$T/requests.log" +out="$(run poll 2>&1)"; rc=$? +if [[ "$rc" = 0 && "$out" == "idle" ]] && identical manifest && identical manifest.sig && identical kryptik-root.img && identical kryptik-a.efi && identical root.json; then + ok "poll: every file of the release arrives byte for byte, and the run ends when zone 0 says idle" +else + bad "poll, whole release: rc=$rc out=$out" +fi +first_two="$(grep '^update-put' "$T/requests.log" | head -2 | awk '{print $2}' | tr '\n' ' ')" +[[ "$first_two" == "manifest manifest.sig " ]] \ + && ok "poll: the manifest and its signature cross before anything else, because that is what zone 0 asked for" \ + || bad "poll: the first two pieces were: $first_two" +biggest="$(grep '^update-put' "$T/requests.log" | awk '{print $4}' | sort -n | tail -1)" +pieces="$(grep -c '^update-put kryptik-root.img' "$T/requests.log")" +[[ "$biggest" = 1048576 && "$pieces" = 3 ]] \ + && ok "poll: no piece is larger than 1 MiB (the root image crossed in $pieces)" \ + || bad "poll: biggest piece $biggest, root image pieces $pieces" + +# --- a download cut short resumes from the byte zone 0 names ------------------------- +truncate -s 1048581 "$T/stage/kryptik-root.img"; : > "$T/http.log" +out="$(run poll 2>&1)"; rc=$? +if [[ "$rc" = 0 ]] && identical kryptik-root.img && grep -q '^/chan/1.0.3/kryptik-root.img bytes=1048581-$' "$T/http.log"; then + ok "poll: a file cut at byte 1048581 is asked for from that byte, and ends up identical" +else + bad "poll, resume: rc=$rc out=$out http: $(cat "$T/http.log" | tr '\n' ' ')" +fi +truncate -s 1048581 "$T/stage/kryptik-root.img"; : > "$T/norange" +out="$(run poll 2>&1)"; rc=$? +rm -f "$T/norange" +[[ "$rc" = 0 ]] && identical kryptik-root.img \ + && ok "poll: a server that ignores Range sends the whole file; the bytes before the offset are dropped, not sent to zone 0" \ + || bad "poll, resume without Range: rc=$rc out=$out" + +# --- zone 0 has the last word ---------------------------------------------------------- +rm -f "$T/stage/kryptik-root.img"; echo kryptik-root.img > "$T/refuse"; : > "$T/requests.log" +out="$(run poll 2>&1)"; rc=$? +tries="$(grep -c '^update-put kryptik-root.img' "$T/requests.log")" +if [[ "$rc" = 1 && "$out" == *"zone 0 says no"* && "$tries" = 1 && ! -e "$T/stage/kryptik-root.img" ]]; then + ok "poll: a piece zone 0 refuses ends the run; it is not sent again" +else + bad "poll, refusal: rc=$rc tries=$tries out=$out" +fi +rm -f "$T/refuse" + +# --- what it cannot do without ----------------------------------------------------------- +printf '# nothing here\n' > "$T/empty.conf" +out="$(python3 "$FETCH" latest --conf "$T/empty.conf" --broker "$T/broker.sock" 2>&1)"; rc=$? +[[ "$rc" = 1 && "$out" == *"names no channel"* ]] \ + && ok "without a channel address from zone 0 it asks nobody" || bad "no channel: rc=$rc out=$out" +printf 'channel = http://127.0.0.1:1/chan\n' > "$T/dead.conf" +out="$(python3 "$FETCH" latest --conf "$T/dead.conf" --broker "$T/broker.sock" 2>&1)"; rc=$? +[[ "$rc" = 1 && "$out" == update-fetch:* ]] \ + && ok "a host that does not answer is one line and exit 1, not a traceback" || bad "dead host: rc=$rc out=$out" + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] diff --git a/tools/test-update-manifest-snapshot.sh b/tools/test-update-manifest-snapshot.sh index 5777396..482db25 100755 --- a/tools/test-update-manifest-snapshot.sh +++ b/tools/test-update-manifest-snapshot.sh @@ -63,7 +63,15 @@ mkpayload signed 2 mkpayload replacement 3 ssh-keygen -q -t ed25519 -N '' -f key >/dev/null 2>&1 || { echo "cannot make a key"; exit 77; } -printf 'review %s\n' "$(cat key.pub)" > signers +ssh-keygen -q -t ed25519 -N '' -f latestkey >/dev/null 2>&1 || { echo "cannot make a key"; exit 77; } +# The trust anchor as stage 04 installs it: the release key honoured for +# manifests and nothing else, a second key honoured for statements of what +# is current and nothing else. An anchor without the namespaces would let +# this suite pass things the installed system refuses. +{ + printf 'kryptik-release namespaces="kryptik-release" %s\n' "$(cut -d' ' -f1,2 key.pub)" + printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 latestkey.pub)" +} > signers ssh-keygen -Y sign -f key -n kryptik-release signed/manifest >/dev/null 2>&1 || { echo "cannot sign"; exit 77; } printf 'development\n' > role @@ -72,16 +80,20 @@ printf 'development\n' > role { echo 'NAMESPACE=kryptik-release' echo 'MAGIC=KRYPTIK-MANIFEST-1' + echo 'LATEST_NAMESPACE=kryptik-latest' + echo 'LATEST_MAGIC=KRYPTIK-LATEST-1' echo "SIGNERS=$T/signers" echo "ROLE_FILE=$T/role" echo 'say() { printf "%s\n" "$*"; }' echo 'die() { printf "REFUSED: %s\n" "$*"; exit 1; }' echo 'hdr() { awk -F": " -v k="$2" '"'"'$1==k {print $2; exit}'"'"' "$1"; }' echo 'running_version() { echo 1; }' - sed -n '/^verify_payload() {/,/^cmd_apply() {/p' "$TOOL" | sed '$d' + sed -n '/^pin() {/,/^cmd_apply() {/p' "$TOOL" | sed '$d' } > verify.sh grep -q '^verify_payload() {' verify.sh || { echo "could not extract verify_payload from $TOOL"; exit 1; } +grep -q '^pin() {' verify.sh || { echo "could not extract pin from $TOOL"; exit 1; } + # run_case NAME WHAT-THE-WRITER-REPLACES: a fresh copy of the signed payload, # the tool's verify_payload over it, and a writer that lands the moment the # real ssh-keygen has accepted the signature. Prints the tool's output plus @@ -100,6 +112,14 @@ ssh-keygen() { all) cp $T/replacement/* $T/payload/ ;; esac fi + # Before anything is judged: what a payload directory may simply contain. + if [ "\${2:-}" = verify ]; then + case "$what" in + link) mv $T/payload/kryptik-root.img $T/payload-root.img; ln -s $T/payload-root.img $T/payload/kryptik-root.img ;; + dotfile) echo "ride along" > $T/payload/.hidden ;; + lookalike) echo "ride along" > $T/payload/kryptik-rootXimg ;; + esac + fi return "\$rc" } SNAP=$T/snap-$name @@ -141,11 +161,158 @@ else bad "unexpected outcome for a replaced payload: $(tail -2 <<<"$out" | tr '\n' ' ')" fi -if command ssh-keygen -Y verify -f signers -I review -n kryptik-release -s "$T/payload/manifest.sig" < "$T/replacement/manifest" >/dev/null 2>&1; then +# Cases 4 to 6: what the listing and the opening must refuse. +out="$(run_case link link)" +if [[ "$out" == *"REFUSED:"*"not a regular file"* ]]; then ok "a root image that is a link is refused, not followed"; else bad "a linked root image: $(tail -2 <<<"$out" | tr '\n' ' ')"; fi +out="$(run_case dotfile dotfile)" +if [[ "$out" == *"REFUSED:"*"unlisted file in the payload: .hidden"* ]]; then ok "an unlisted file whose name begins with a dot is seen and refused"; else bad "a dotfile stowaway: $(tail -2 <<<"$out" | tr '\n' ' ')"; fi +out="$(run_case lookalike lookalike)" +if [[ "$out" == *"REFUSED:"*"unlisted file in the payload: kryptik-rootXimg"* ]]; then ok "a name that only matches a listed one as a pattern is refused"; else bad "a look-alike name: $(tail -2 <<<"$out" | tr '\n' ' ')"; fi + +if command ssh-keygen -Y verify -f signers -I kryptik-release -n kryptik-release -s "$T/payload/manifest.sig" < "$T/replacement/manifest" >/dev/null 2>&1; then bad "control: the replacement manifest has a valid signature, which it must not" else ok "control: the real verifier rejects the replacement manifest" fi +# --- the update channel's two checks ----------------------------------------- +# check-manifest and check-pointer (docs/design/update-channel.md) are what +# zone 0 runs on a manifest and on a statement of what is current before it +# believes either. Same functions, same real ssh-keygen, the same trust anchor +# as above; each case in its own bash because die exits. +check() { # check FUNCTION ARGS... -> the tool's output, REFUSED: on a refusal + { echo "source $T/verify.sh"; printf 'SNAP=%q\n' "$(mktemp -d "$T/snap.XXXXXX")"; printf '%q ' "$@"; echo; } > "$T/check.sh" + bash "$T/check.sh" 2>&1 +} +staged() { # staged NAME -> a directory holding only the signed manifest and its signature + rm -rf "${T:?}/$1"; mkdir -p "$T/$1"; cp "$T/signed/manifest" "$T/signed/manifest.sig" "$T/$1/" +} + +staged stage +out="$(check cmd_check_manifest "$T/stage")" +want_sha="$(sha256sum "$T/signed/manifest" | cut -c1-64)" +if [[ "$out" == *"version: 2"* && "$out" == *"sha256: $want_sha"* && "$(grep -c '^file [0-9]* ' <<<"$out")" = 4 ]] \ + && grep -qx "file $(stat -c %s "$T/signed/kryptik-root.img") kryptik-root.img" <<<"$out"; then + ok "check-manifest: a signed manifest with no payload beside it verifies, and prints its version, its hash and the four files with their sizes" +else + bad "check-manifest on a signed manifest: $(tail -3 <<<"$out" | tr '\n' ' ')" +fi + +# Signed by the right key in the pointer's namespace: a pointer's signature +# must never pass for a manifest's. +staged crossed; rm -f "$T/crossed/manifest.sig" +ssh-keygen -Y sign -f key -n kryptik-latest "$T/crossed/manifest" >/dev/null 2>&1 +out="$(check cmd_check_manifest "$T/crossed")" +[[ "$out" == *"REFUSED:"*"does NOT verify"* && "$out" != *"version:"* ]] \ + && ok "check-manifest: a manifest signed in the pointer's namespace is refused" \ + || bad "check-manifest accepted a signature from the pointer's namespace: $(tail -2 <<<"$out" | tr '\n' ' ')" + +ssh-keygen -q -t ed25519 -N '' -f otherkey >/dev/null 2>&1 +staged stranger; rm -f "$T/stranger/manifest.sig" +ssh-keygen -Y sign -f otherkey -n kryptik-release "$T/stranger/manifest" >/dev/null 2>&1 +out="$(check cmd_check_manifest "$T/stranger")" +[[ "$out" == *"REFUSED:"*"not enrolled"* ]] \ + && ok "check-manifest: a manifest signed by a key that is not enrolled is refused" \ + || bad "check-manifest accepted a stranger's key: $(tail -2 <<<"$out" | tr '\n' ' ')" + +# The rules `apply` has, because they are the same function: the role, and no +# downgrade (nothing that arrives over the network is a recovery). +resigned() { # resigned NAME SED-EXPRESSION -> the signed manifest, edited, signed again + rm -rf "${T:?}/$1"; mkdir -p "$T/$1" + sed "$2" "$T/signed/manifest" > "$T/$1/manifest" + ssh-keygen -Y sign -f key -n kryptik-release "$T/$1/manifest" >/dev/null 2>&1 +} +resigned prod 's/^role: development/role: production/' +out="$(check cmd_check_manifest "$T/prod")" +[[ "$out" == *"REFUSED:"*"this image requires 'development'"* ]] \ + && ok "check-manifest: a validly signed manifest for another role is refused" \ + || bad "check-manifest accepted another role: $(tail -2 <<<"$out" | tr '\n' ' ')" +resigned older 's/^version: 2/version: 0.9/' +out="$(check cmd_check_manifest "$T/older")" +[[ "$out" == *"REFUSED:"*"older than the running"* ]] \ + && ok "check-manifest: a validly signed older release is refused; the channel has no --recovery" \ + || bad "check-manifest accepted a downgrade: $(tail -2 <<<"$out" | tr '\n' ' ')" +resigned climbs 's| root.json$| ../root.json|' +out="$(check cmd_check_manifest "$T/climbs")" +[[ "$out" == *"REFUSED:"*"directory component"* && "$out" != *"file "* ]] \ + && ok "check-manifest: a listed name with a directory component is refused before any name is printed" \ + || bad "check-manifest printed a path that climbs: $(tail -2 <<<"$out" | tr '\n' ' ')" + +# The pointer. +mkdir -p "$T/ptr" +printf 'KRYPTIK-LATEST-1\nrole: development\nversion: 2\nissued: 2027-03-02T14:05:00+00:00\nmanifest-sha256: %s\nbase: 2/\n' "$want_sha" > "$T/ptr/latest" +ssh-keygen -Y sign -f latestkey -n kryptik-latest "$T/ptr/latest" >/dev/null 2>&1 +out="$(check cmd_check_pointer "$T/ptr/latest" "$T/ptr/latest.sig")"; rc=$? +[[ "$rc" = 0 && "$out" == *"signature verifies"* ]] \ + && ok "check-pointer: a pointer signed by an enrolled key in its own namespace verifies" \ + || bad "check-pointer refused a good pointer: $(tail -2 <<<"$out" | tr '\n' ' ')" + +cp "$T/ptr/latest" "$T/ptr/replayed-ns" +ssh-keygen -Y sign -f latestkey -n kryptik-release "$T/ptr/replayed-ns" >/dev/null 2>&1 +out="$(check cmd_check_pointer "$T/ptr/replayed-ns" "$T/ptr/replayed-ns.sig")" +[[ "$out" == *"REFUSED:"*"does NOT verify"* ]] \ + && ok "check-pointer: a pointer signed in the manifest's namespace is refused" \ + || bad "check-pointer accepted a signature from the manifest's namespace: $(tail -2 <<<"$out" | tr '\n' ' ')" + +cp "$T/ptr/latest" "$T/ptr/stranger" +ssh-keygen -Y sign -f otherkey -n kryptik-latest "$T/ptr/stranger" >/dev/null 2>&1 +out="$(check cmd_check_pointer "$T/ptr/stranger" "$T/ptr/stranger.sig")" +[[ "$out" == *"REFUSED:"*"not enrolled"* ]] \ + && ok "check-pointer: a pointer signed by a key that is not enrolled is refused" \ + || bad "check-pointer accepted a stranger's key: $(tail -2 <<<"$out" | tr '\n' ' ')" + +# The anchor's own rule, both ways round. The release key signing a pointer +# in the pointer's namespace is a well-formed signature by an enrolled key, +# and is refused because that key is not enrolled for that namespace; so is +# the statement key signing a manifest. This is what lets the statement key +# live where a timer can reach it. +cp "$T/ptr/latest" "$T/ptr/by-release-key" +ssh-keygen -Y sign -f key -n kryptik-latest "$T/ptr/by-release-key" >/dev/null 2>&1 +out="$(check cmd_check_pointer "$T/ptr/by-release-key" "$T/ptr/by-release-key.sig")" +[[ "$out" == *"REFUSED:"*"does NOT verify"*"not for kryptik-latest"* ]] \ + && ok "check-pointer: the release key is not honoured for a pointer, whatever namespace it signs in" \ + || bad "check-pointer accepted a pointer signed by the release key: $(tail -2 <<<"$out" | tr '\n' ' ')" +staged by-latest-key; rm -f "$T/by-latest-key/manifest.sig" +ssh-keygen -Y sign -f latestkey -n kryptik-release "$T/by-latest-key/manifest" >/dev/null 2>&1 +out="$(check cmd_check_manifest "$T/by-latest-key")" +[[ "$out" == *"REFUSED:"*"does NOT verify"* && "$out" != *"version:"* ]] \ + && ok "check-manifest: the statement key cannot sign a release, whatever namespace it signs in" \ + || bad "check-manifest accepted a manifest signed by the statement key: $(tail -2 <<<"$out" | tr '\n' ' ')" + +sed 's/^version: 2/version: 1/' "$T/ptr/latest" > "$T/ptr/edited" +out="$(check cmd_check_pointer "$T/ptr/edited" "$T/ptr/latest.sig")" +[[ "$out" == *"REFUSED:"*"does NOT verify"* ]] \ + && ok "check-pointer: a pointer edited after it was signed is refused" \ + || bad "check-pointer accepted an edited pointer: $(tail -2 <<<"$out" | tr '\n' ' ')" + +# A manifest is not a pointer even when someone signs it as one. +cp "$T/signed/manifest" "$T/ptr/manifest-as-pointer" +ssh-keygen -Y sign -f latestkey -n kryptik-latest "$T/ptr/manifest-as-pointer" >/dev/null 2>&1 +out="$(check cmd_check_pointer "$T/ptr/manifest-as-pointer" "$T/ptr/manifest-as-pointer.sig")" +[[ "$out" == *"REFUSED:"*"not a KRYPTIK-LATEST-1"* ]] \ + && ok "check-pointer: a manifest presented as a pointer is refused by its first line" \ + || bad "check-pointer accepted a manifest: $(tail -2 <<<"$out" | tr '\n' ' ')" + +# The tool itself, not the functions lifted out of it: the two checks need +# neither root nor this installation's disks, and say what they do need. +if [[ "$(id -u)" != 0 ]]; then + out="$(sh "$TOOL" check-pointer "$T/ptr/latest" "$T/ptr/latest.sig" 2>&1)" + [[ "$out" != *"must run as root"* && "$out" == *"no trust anchor at /usr/share/kryptik/trust/release-signers"* ]] \ + && ok "check-pointer runs without root and stops at the image's trust anchor, which this host does not have" \ + || bad "the tool's own check-pointer, unprivileged: $(tail -2 <<<"$out" | tr '\n' ' ')" + # The directory the tool copies into is removed by its exit trap, so it + # must never be one the caller's environment named. + mkdir -p "$T/precious"; echo keep > "$T/precious/marker" + SNAP="$T/precious" sh "$TOOL" check-pointer "$T/ptr/latest" "$T/ptr/latest.sig" >/dev/null 2>&1 + [[ -f "$T/precious/marker" && -z "$(find "$T/precious" -name 'latest*')" ]] \ + && ok "a SNAP in the environment is not where the tool copies, and is not what its exit trap removes" \ + || bad "the tool used, or removed, the directory the environment named as SNAP" + out="$(sh "$TOOL" apply "$T/signed" 2>&1)" + [[ "$out" == *"must run as root"* ]] \ + && ok "control: apply still refuses to run without root" \ + || bad "apply without root: $(tail -2 <<<"$out" | tr '\n' ' ')" +fi + + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" [[ "$FAIL" -eq 0 ]] diff --git a/tools/update/kryptik-recover b/tools/update/kryptik-recover index 9f72d37..2230253 100755 --- a/tools/update/kryptik-recover +++ b/tools/update/kryptik-recover @@ -7,6 +7,8 @@ # kryptik-recover --disk DEV --commit-slot a|b make that slot the boot file # kryptik-recover --disk DEV --restore-slot a|b rewrite that slot from this medium's root image # kryptik-recover --disk DEV --status +# kryptik-recover --disk DEV --backup-state-header FILE +# kryptik-recover --disk DEV --restore-state-header FILE # # --commit-slot: the other slot is intact (an update went wrong after the # commit, or the committed kernel file was damaged): copy that slot's kernel @@ -19,20 +21,26 @@ # an older version than what was there is what "recover from the medium" # means, and it is said. # +# The state partition is LUKS2 (docs/design/state-encryption.md) and its +# header is the state: without it, or without the passphrase, the partition +# is lost, and there is no escrow. A backup belongs off this disk. +# # Nothing here depends on the damaged system: every byte written comes from # the medium, which the firmware verified. set -eu PROG=kryptik-recover say() { printf '%s: %s\n' "$PROG" "$*"; } die() { printf '%s: FAILED: %s\n' "$PROG" "$*" >&2; exit 1; } -DISK=""; COMMIT=""; RESTORE=""; STATUS=0 +DISK=""; COMMIT=""; RESTORE=""; STATUS=0; HDR_OUT=""; HDR_IN="" while [ $# -gt 0 ]; do case "$1" in --disk) DISK="${2:-}"; shift 2 ;; --commit-slot) COMMIT="${2:-}"; shift 2 ;; --restore-slot) RESTORE="${2:-}"; shift 2 ;; --status) STATUS=1; shift ;; - -h|--help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --backup-state-header) HDR_OUT="${2:-}"; shift 2 ;; + --restore-state-header) HDR_IN="${2:-}"; shift 2 ;; + -h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) die "unknown argument: $1" ;; esac done @@ -72,6 +80,17 @@ if [ "$STATUS" = 1 ]; then exit 0 fi +if [ -n "$HDR_OUT" ]; then # cryptsetup refuses to overwrite an existing file + cryptsetup luksHeaderBackup "$ST" --header-backup-file "$HDR_OUT" || die "could not back up the header of ${ST}" + say "the header of ${ST} is in ${HDR_OUT}" + exit 0 +fi +if [ -n "$HDR_IN" ]; then + cryptsetup -q luksHeaderRestore "$ST" --header-backup-file "$HDR_IN" || die "could not restore the header of ${ST} from ${HDR_IN}" + say "the header of ${ST} is restored from ${HDR_IN}" + exit 0 +fi + commit_slot() { # commit_slot SLOT (ESP mounted rw at /run/kryptik-recover) s="$1"; k="/run/kryptik-recover/EFI/kryptik/kryptik-$s.efi" [ -f "$k" ] || die "no kernel for slot $s on the ESP" @@ -90,9 +109,11 @@ if [ -n "$RESTORE" ]; then mkdir -p /run/kryptik-recover-media case "$media" in usb) - src="$(blkid -t PARTLABEL=kryptik-media -o device 2>/dev/null | head -1)"; off=0 - mdisk="$(printf '%s' "$src" | sed 's/p\{0,1\}[0-9]*$//')" - mesp="$(blkid -t PARTLABEL=kryptik-esp -o device 2>/dev/null | grep "^${mdisk}" | head -1)" + # This medium's own partitions (devices.sh), never the first + # disk that carries the label. + . /usr/libexec/kryptik/devices.sh + src="$(kryptik_part kryptik-media)" || die "no single kryptik-media partition on this medium"; off=0 + mesp="$(kryptik_part kryptik-esp)" || die "no single kryptik-esp partition on this medium" mount -o ro "$mesp" /run/kryptik-recover-media || die "cannot mount the medium's ESP" rj=/run/kryptik-recover-media/kryptik/root.json; kdir=/run/kryptik-recover-media/EFI/kryptik ;; iso) @@ -107,6 +128,7 @@ if [ -n "$RESTORE" ]; then say "restoring slot $RESTORE from this medium (${ver}, ${bytes} bytes); the state partition is untouched" if [ "$off" -gt 0 ]; then dd if="$src" of="$sd" bs=4M iflag=skip_bytes,count_bytes skip="$off" count="$bytes" conv=fsync status=none else dd if="$src" of="$sd" bs=4M iflag=count_bytes count="$bytes" conv=fsync status=none; fi + blockdev --flushbufs "$sd" # so the read-back is of the disk, not of the page cache got="$(dd if="$sd" bs=4M iflag=count_bytes count="$bytes" status=none | sha256sum | cut -c1-64)" [ "$got" = "$sha" ] || die "slot $RESTORE reads back as $got, expected $sha" say "slot $RESTORE verifies" @@ -129,4 +151,4 @@ if [ -n "$COMMIT" ]; then umount /run/kryptik-recover exit 0 fi -die "one of --status, --commit-slot or --restore-slot is required" +die "one of --status, --commit-slot, --restore-slot, --backup-state-header or --restore-state-header is required" diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index 383af3b..bcb1b16 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -8,6 +8,8 @@ # kryptik-update apply DIR [--retry] [--recovery] # kryptik-update rollback # kryptik-update status +# kryptik-update check-manifest DIR for the update channel: the signature, +# kryptik-update check-pointer FILE SIG role and version steps below, no writes # # DIR holds: manifest, manifest.sig, kryptik-root.img, kryptik-a.efi, # kryptik-b.efi, root.json - exactly those and nothing else. @@ -35,6 +37,8 @@ die() { printf '%s: FAILED: %s\n' "$PROG" "$*" >&2; exit 1; } NAMESPACE=kryptik-release MAGIC=KRYPTIK-MANIFEST-1 +LATEST_NAMESPACE=kryptik-latest +LATEST_MAGIC=KRYPTIK-LATEST-1 SIGNERS=/usr/share/kryptik/trust/release-signers ROLE_FILE=/usr/share/kryptik/trust/required-role DEGRADED=/run/kryptik/state-degraded @@ -42,17 +46,35 @@ B=/var/lib/kryptik/boot ESP_MNT=/run/kryptik/update-esp LOCK=/run/kryptik/update.lock LOG=/var/log/kryptik/update.log +# The directory the manifest is copied into is this script's to make, never +# the caller's to name: the exit trap removes it, whatever it is. (It is a +# variable at all so that the suite, which runs the functions below in a +# shell of its own, can say where to look.) +SNAP="" -[ "$(id -u)" = 0 ] || die "must run as root" -mkdir -p "$B" /run/kryptik /var/log/kryptik -for tool in ssh-keygen sha256sum blkid dd cp mv sync flock mount umount cmp kryptik-efiboot awk sed sort head wc stat grep blockdev; do +# The two checks the update channel calls read what they are given and write +# nothing outside a directory of their own: no root, no devices, and only the +# tools a signature and a hash need. +case "${1:-}" in + check-manifest|check-pointer) + tools="ssh-keygen sha256sum cp awk sed sort head mktemp" ;; + *) + tools="ssh-keygen sha256sum blkid dd cp mv sync flock mount umount cmp kryptik-efiboot awk sed sort head wc stat grep blockdev" + [ "$(id -u)" = 0 ] || die "must run as root" + mkdir -p "$B" /run/kryptik /var/log/kryptik ;; +esac +for tool in $tools; do command -v "$tool" >/dev/null 2>&1 || die "missing tool: $tool" done # Partitions are this installation's - the ones on the disk the root came # from - and nothing else's; an ambiguity is refused, never resolved by # taking the first (devices.sh). -# shellcheck source=/dev/null -. /usr/libexec/kryptik/devices.sh +case "${1:-}" in + check-manifest|check-pointer) ;; + *) + # shellcheck source=/dev/null + . /usr/libexec/kryptik/devices.sh ;; +esac running_slot() { sed -n 's/^slot=//p' /run/kryptik/boot-identity 2>/dev/null; } other_slot() { case "$1" in a) echo b ;; b) echo a ;; *) echo "" ;; esac; } @@ -91,8 +113,93 @@ cmd_status() { kryptik-efiboot list 2>/dev/null | sed 's/^/ efi: /' || echo " efi: (efivarfs unavailable)" } +# The four files a release is made of are opened once, and from then on they +# are read through those descriptors: hashing a name and opening the name +# again later is two files if the directory changed in between, and a payload +# directory can change (the net zone staged it; a medium can be swapped). +# What is hashed is then what is written. A link is refused, and so is a name +# that stopped being the opened file while it was being opened. +pin() { # pin FD NAME + 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" +} +payload_path() { # NAME -> where to read it + case "$1" in + kryptik-root.img) 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" ;; + esac +} + # --- verification, all of it before any write ------------------------------ verify_payload() { # verify_payload DIR RECOVERY -> sets VERSION ROOT_HASH + verify_manifest "$1" "$2" + pin 3 kryptik-root.img; pin 4 kryptik-a.efi; pin 5 kryptik-b.efi; pin 6 root.json + + # 3. Nothing unlisted, which costs a directory listing, before every + # listed file's hash, which costs a pass over gigabytes. Names are + # compared whole, as text, and a name that begins with a dot is a name. + names="$(sed -n '/^--$/,$p' "$m" | sed '1d' | awk '{print $3}')" + for f in "$dir"/* "$dir"/.[!.]* "$dir"/..?*; do + [ -e "$f" ] || [ -L "$f" ] || continue + n="$(basename "$f")" + case "$n" in manifest|manifest.sig) continue ;; esac + # A payload that is the root of an ext4 medium carries the + # filesystem's own lost+found. Empty, it is nothing and is passed + # over; anything inside it is a stowaway like any other and refused. + if [ "$n" = "lost+found" ] && [ -d "$f" ] && [ ! -L "$f" ]; then + [ -z "$(ls -A "$f")" ] || die "unlisted file in the payload: lost+found is not empty" + continue + fi + printf '%s\n' "$names" | grep -Fxq -- "$n" || die "unlisted file in the payload: $n" + done + + listed=0 + while read -r want_hash want_size rel; do + [ -n "$rel" ] || continue + case "$rel" in */*|..*) die "manifest lists a path with a directory component: $rel" ;; esac + f="$(payload_path "$rel")" + [ -f "$f" ] || die "listed file missing: $rel" + got_size="$(stat -L -c %s "$f")" + [ "$got_size" = "$want_size" ] || die "$rel: size $got_size, manifest says $want_size (truncated or altered)" + got_hash="$(sha256sum "$f" | cut -c1-64)" + [ "$got_hash" = "$want_hash" ] || die "$rel: sha256 does not match the manifest" + listed=$((listed + 1)) + done < sets dir m sig VERSION count dir="$1"; recovery="$2" [ -f "$dir/manifest" ] || die "no manifest in $dir" [ -f "$dir/manifest.sig" ] || die "no manifest.sig in $dir: an unsigned manifest verifies nothing" @@ -108,15 +215,10 @@ verify_payload() { # verify_payload DIR RECOVERY -> sets VERSION ROOT_HASH cp -- "$dir/manifest" "$SNAP/manifest" || die "cannot copy the manifest" cp -- "$dir/manifest.sig" "$SNAP/manifest.sig" || die "cannot copy the manifest signature" m="$SNAP/manifest"; sig="$SNAP/manifest.sig" - [ -f "$SIGNERS" ] || die "no trust anchor at $SIGNERS in this image" [ "$(head -1 "$m")" = "$MAGIC" ] || die "manifest is not a $MAGIC" # 1. Signature, by an enrolled key, over the manifest bytes. - principal="$(ssh-keygen -Y find-principals -s "$sig" -f "$SIGNERS" 2>/dev/null | head -1 || true)" - [ -n "$principal" ] || die "the manifest's signing key is not enrolled in $SIGNERS" - ssh-keygen -Y verify -f "$SIGNERS" -I "$principal" -n "$NAMESPACE" -s "$sig" < "$m" >/dev/null 2>&1 \ - || die "the manifest signature does NOT verify (principal $principal)" - say "signature verifies (signed by $principal)" + verify_signed "$m" "$sig" "$NAMESPACE" manifest # 2. Headers inside the signed bytes. role="$(hdr "$m" role)"; VERSION="$(hdr "$m" version)"; count="$(hdr "$m" files)" @@ -137,60 +239,65 @@ verify_payload() { # verify_payload DIR RECOVERY -> sets VERSION ROOT_HASH an older signed release can reintroduce a fixed defect. --recovery accepts it deliberately." fi fi +} - # 3. Every listed file, hash and size; and nothing unlisted. - listed=0 - while read -r want_hash want_size rel; do +# A signature by an enrolled key, in one namespace, over one file's bytes. A +# manifest is signed in kryptik-release and a statement of what is current in +# kryptik-latest, so neither signature can be presented as the other - and +# the trust anchor says so too: each line of it names the namespaces its key +# is honoured in (`namespaces="..."`), so a key enrolled for one kind of +# statement cannot sign the other whatever namespace it claims. A key may be +# enrolled under more than one principal; each is tried. +verify_signed() { # verify_signed FILE SIG NAMESPACE WHAT + [ -f "$SIGNERS" ] || die "no trust anchor at $SIGNERS in this image" + principals="$(ssh-keygen -Y find-principals -s "$2" -f "$SIGNERS" 2>/dev/null || true)" + [ -n "$principals" ] || die "the $4's signing key is not enrolled in $SIGNERS" + for principal in $principals; do + if ssh-keygen -Y verify -f "$SIGNERS" -I "$principal" -n "$3" -s "$2" < "$1" >/dev/null 2>&1; then + say "signature verifies (signed by $principal)" + return 0 + fi + done + die "the $4 signature does NOT verify (its key is enrolled as: $(echo $principals), and not for $3)" +} + +# For the update channel (docs/design/update-channel.md). DIR holds a manifest +# and its signature and, so far, nothing else: what is printed is what zone 0 +# will then accept from the net zone, and nothing is accepted before this has +# exited 0. The hash is of the copy that was verified; the channel compares it +# with the one the signed pointer announced. A downgrade is refused as in +# `apply`: nothing that arrives over the network is a recovery. +cmd_check_manifest() { + [ $# = 1 ] && [ -d "$1" ] || die "check-manifest needs the directory holding manifest and manifest.sig" + verify_manifest "$1" 0 + n=0 + while read -r _hash size rel; do [ -n "$rel" ] || continue case "$rel" in */*|..*) die "manifest lists a path with a directory component: $rel" ;; esac - f="$dir/$rel" - [ -f "$f" ] || die "listed file missing: $rel" - got_size="$(stat -c %s "$f")" - [ "$got_size" = "$want_size" ] || die "$rel: size $got_size, manifest says $want_size (truncated or altered)" - got_hash="$(sha256sum "$f" | cut -c1-64)" - [ "$got_hash" = "$want_hash" ] || die "$rel: sha256 does not match the manifest" - listed=$((listed + 1)) + case "$size" in ''|*[!0-9]*) die "$rel: '$size' is not a size" ;; esac + n=$((n + 1)) done < "$ESP_MNT/kryptik/version-$target.new" mv -f "$ESP_MNT/kryptik/version-$target.new" "$ESP_MNT/kryptik/version-$target" sync @@ -293,5 +405,7 @@ case "${1:-}" in apply) shift; cmd_apply "$@" ;; rollback) cmd_rollback ;; status) cmd_status ;; - *) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;; + check-manifest) shift; cmd_check_manifest "$@" ;; + check-pointer) shift; cmd_check_pointer "$@" ;; + *) sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;; esac