8000 Sign gvproxy, skip adhoc signing in installer by twitchyliquid64 · Pull Request #680 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 44 additions & 27 deletions .github/workflows/release.yml
10000
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ jobs:
- name: Upload unsigned libkrun dylib (temp)
# This GitHub-hosted runner has no access to the Developer ID signing
# keychain, so the dylib leaves here UNSIGNED under a `temp-` prefixed
# name. sign-libkrun-macos-arm64 (self-hosted) downloads it, signs it,
# name. sign-macos-artifacts (self-hosted) downloads it, signs it,
# and re-uploads under the final `libkrun-macos-arm64` name. The
# release job drops any leftover `temp-` artifact so the unsigned copy
# never ships.
Expand All @@ -410,25 +410,32 @@ jobs:
if-no-files-found: error
retention-days: 7

sign-libkrun-macos-arm64:
# Developer ID signing for the shipped libkrun.dylib. While built on
# a Github-hosted runner, it is signed on a self-hosted runner with
# access to our signing keychain.
sign-macos-artifacts:
# Developer ID signing for the macOS artifacts built on normal runners.
#
# Artifacts come as the output of other jobs under a `temp-` prefixed
# artifact, are signed on the self-hosted runner that holds the keychain,
# then are re-uploaded under their final names.
runs-on: [self-hosted, macOS, ARM64]
timeout-minutes: 15
if: vars.RUN_MACOS_CI != 'false'
needs: [build-libkrun-macos-arm64]
needs: [build-libkrun-macos-arm64, fetch-release-guest-artifacts]
steps:
- name: Download unsigned libkrun dylib (temp)
- name: Download unsigned macOS artifacts (temp)
uses: actions/download-artifact@v8
with:
name: temp-libkrun-macos-arm64
path: ${{ runner.temp }}/libkrun-in
- name: Codesign libkrun with Developer ID
# A shipped dylib must be signed with the same Developer ID identity
# (and a secure --timestamp) as the binaries so the whole install is
# notarization-ready. --force replaces cargo's ad-hoc signature. No
# entitlements: libkrun is a library, not the hypervisor entry point.
pattern: temp-*
path: ${{ runner.temp }}/unsigned
merge-multiple: true
- name: Codesign macOS artifacts with Developer ID
# Sign with the same Developer ID identity, secure --timestamp, and
# hardened runtime (--options runtime) as the binaries so the whole
# install is notarization-ready. --force replaces each file's existing
# signature (cargo's ad-hoc on libkrun, the Go linker's ad-hoc on
# gvproxy). No entitlements: neither libkrun (a library) nor gvproxy
# (the network switch) is the hypervisor entry point. The temp- prefix
# is dropped so each file carries the final basename the
# archive/installer expect.
env:
KC_PW: ${{ secrets.KC_PW }}
run: |
Expand All @@ -438,19 +445,28 @@ jobs:
security unlock-keychain -p "$KC_PW" "$KC"
security list-keychains -d user -s "$KC" login.keychain-db

# Drop the temp- prefix so the shipped file carries the final
# basename the archive/installer expect.
out="$RUNNER_TEMP/libkrun-macos-arm64.dylib"
cp "$RUNNER_TEMP/libkrun-in/temp-libkrun-macos-arm64.dylib" "$out"
in="$RUNNER_TEMP/unsigned"
out="$RUNNER_TEMP/signed"
mkdir -p "$out"
cp "$in/temp-libkrun-macos-arm64.dylib" "$out/libkrun-macos-arm64.dylib"
cp "$in/temp-gvproxy-darwin-arm64" "$out/gvproxy-darwin-arm64"

codesign --force --timestamp --options runtime \
--sign "$IDENTITY" "$out"
codesign --verify --strict --verbose=2 "$out"
for f in "$out/libkrun-macos-arm64.dylib" "$out/gvproxy-darwin-arm64"; do
codesign --force --timestamp --options runtime --sign "$IDENTITY" "$f"
codesign --verify --strict --verbose=2 "$f"
done
- name: Upload signed libkrun dylib
uses: actions/upload-artifact@v7
with:
name: libkrun-macos-arm64
path: ${{ runner.temp }}/libkrun-macos-arm64.dylib
path: ${{ runner.temp }}/signed/libkrun-macos-arm64.dylib
if-no-files-found: error
retention-days: 7
- name: Upload signed gvproxy (darwin-arm64)
uses: actions/upload-artifact@v7
with:
name: gvproxy-darwin-arm64
path: ${{ runner.temp }}/signed/gvproxy-darwin-arm64
if-no-files-found: error
retention-days: 7

Expand Down Expand Up @@ -513,7 +529,8 @@ jobs:
- name: Fetch pinned gvproxy (linux-arm64)
run: ./scripts/fetch-gvproxy.sh "$RUNNER_TEMP/gvproxy-linux-arm64" gvproxy-linux-arm64
- name: Fetch pinned gvproxy (darwin-arm64)
run: ./scripts/fetch-gvproxy.sh "$RUNNER_TEMP/gvproxy-darwin-arm64" gvproxy-darwin
# Stashed as a `temp-` so sign-macos-artifacts (self-hosted) can sign it
run: ./scripts/fetch-gvproxy.sh "$RUNNER_TEMP/temp-gvproxy-darwin-arm64" gvproxy-darwin
- name: Upload kernel (amd64)
uses: actions/upload-artifact@v7
with:
Expand Down Expand Up @@ -556,11 +573,11 @@ jobs:
path: ${{ runner.temp }}/gvproxy-linux-arm64
if-no-files-found: error
retention-days: 7
- name: Upload gvproxy (darwin-arm64)
- name: Upload gvproxy (darwin-arm64, temp/unsigned)
uses: actions/upload-artifact@v7
with:
name: gvproxy-darwin-arm64
path: ${{ runner.temp }}/gvproxy-darwin-arm64
name: temp-gvproxy-darwin-arm64
path: ${{ runner.temp }}/temp-gvproxy-darwin-arm64
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if-no-files-found: error
retention-days: 7

Expand Down Expand Up @@ -625,7 +642,7 @@ jobs:
build-release-linux-amd64,
build-release-linux-arm64,
build-release-macos-arm64,
sign-libkrun-macos-arm64,
sign-macos-artifacts,
fetch-release-guest-artifacts,
build-release-initramfs,
]
Expand Down
79 changes: 20 additions & 59 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,9 @@ resolve_prefix() {
# Offline teardown driven solely by the local install record (R6.1). The record
# is a tab-delimited table of `component<TAB>dest<TAB>manifest-hash<TAB>installed-hash`
# rows, where `dest` is absolute and `installed-hash` is the SHA-256 of the bytes
# actually written (the post-sign digest for a macOS `bin` file). Uninstall keys
# off `installed-hash`; the manifest-hash column is unused here. A file is removed
# actually written. Uninstall keys off `installed-hash`; the manifest-hash column
# is unused here (it equals installed-hash for records written by this installer,
# but may differ in records written by older signing installers). A file is removed
# only if it is still byte-for-byte what we recorded writing (R7.3/R7.4), so a
# user's edited or replaced file is kept unless --force. Runs entirely on local
# state: no network, manifest, or bucket.
Expand Down Expand Up @@ -198,7 +199,7 @@ do_uninstall() {
fi

# R7.3/R7.4 — remove only if the on-disk bytes still equal the recorded
# installed-hash (matches a macOS ad-hoc-signed bin), unless --force.
# installed-hash, unless --force.
if [ "$(sha256 "$dest")" != "$want" ] && [ "$uninstall_force" -eq 0 ]; then
say " $comp: kept (modified since install; pass --force to remove)"
kept_modified=$((kept_modified + 1))
Expand Down Expand Up @@ -328,30 +329,12 @@ records="$tmpdir/installed"
installed=0 skipped=0

# The prior run's install record (R6.1) maps each component to the hash of the
# file it actually placed on disk, paired with the manifest `sha256` that file
# was built from. On macOS a `bin` file is ad-hoc code-signed after download (see
# below), so its installed bytes — and hash — differ from the manifest `sha256`;
# the pairing lets a signed component be recognized as already installed without
# re-downloading. It is keyed on the manifest hash for exactly that reason: the
# recorded installed hash only means "up to date" while the manifest still wants
# the SAME artifact. A new release changes the manifest `sha256`, so the recorded
# row no longer matches the current `want` and the component is re-downloaded
# rather than wrongly judged current (the signed on-disk bytes would otherwise
# still equal the old recorded installed hash). It is only a positive-match
# optimization: a deleted or tampered file matches nothing and is reinstalled, so
# the on-disk file remains the source of truth (R5.1). Read before the new record
# is written into place at the end of the run.
# file it placed on disk. Written into place at the end of this run; here we only
# need its path so a completed run can replace it. The on-disk file — not this
# record — is the skip oracle (R5.1): a deleted or tampered file matches nothing
# and is reinstalled.
state_dir="$(resolve_prefix state)"
prev_record="$state_dir/installed"
# Emit the installed hash recorded for component $1, but only if the manifest
# hash recorded alongside it ($3) equals the manifest's current want ($2) — so a
# signed macOS bin is skipped only when this release wants the same artifact.
prev_installed_hash() {
[ -f "$prev_record" ] || return 0
# Records are tab-delimited; split on tab so a dest containing spaces (e.g. a
# $HOME with a space) still parses. Columns: comp, dest, manifest-sha, installed-sha.
awk -F'\t' -v c="$1" -v w="$2" '$1==c && $3==w {print $4; exit}' "$prev_record"
}

# No field ever contains whitespace (R3.2), so default IFS splitting is exact.
# The os/arch/version columns are consumed into `_` (already matched in awk, or
Expand All @@ -378,14 +361,12 @@ while read -r comp _ _ _ want kind dest src; do
dir="$(resolve_prefix "$prefix")"
target_file="$dir/$subpath"

# R5.1 — the on-disk file is the skip oracle. It is up to date if its hash
# matches the manifest `sha256` OR (for a macOS `bin` file we signed last run)
# the installed hash recorded against THIS manifest hash, since signing
# diverges the bytes. Passing `$want` to the lookup is what stops a new
# release — whose `want` changed — from matching the old signed file.
# R5.1 — the on-disk file is the skip oracle: it is up to date when its hash
# equals the manifest `sha256`. A changed manifest hash (a new release) fails
# this check and is re-downloaded.
if [ -f "$target_file" ]; then
on_disk="$(sha256 "$target_file")"
if [ "$on_disk" = "$want" ] || [ "$on_disk" = "$(prev_installed_hash "$comp" "$want")" ]; then
if [ "$on_disk" = "$want" ]; then
say " $comp: up to date"
skipped=$((skipped + 1))
printf '%s\t%s\t%s\t%s\n' "$comp" "$target_file" "$want" "$on_disk" >>"$records"
Expand All @@ -410,40 +391,20 @@ while read -r comp _ _ _ want kind dest src; do
# chmod the temp file, then rename.
[ "$prefix" = bin ] && chmod +x "$tmp"

# macOS: make a freshly-downloaded Mach-O (a bin executable or a shipped lib
# dylib) actually loadable before it is placed.
#
# * Strip the Gatekeeper quarantine attribute so it can run
# * ad-hoc self-sign: arm64 macOS refuses to exec/dlopen an unsigned or
# transfer-invalidated Mach-O, and our release artifacts are not yet signed
# with a developer identity.
# macOS: strip the Gatekeeper quarantine attribute from a freshly-downloaded
# Mach-O (a bin executable or the shipped lib dylib) so it runs without a
# Gatekeeper prompt.
if [ "$os" = darwin ] && { [ "$prefix" = bin ] || [ "$prefix" = lib ]; }; then
xattr -d com.apple.quarantine "$tmp" 2>/dev/null || true
# Special case: minvmd needs the hypervisor entitlement.
if [ "$comp" = minvmd ]; then
ents="$tmpdir/minvmd.entitlements"
cat >"$ents" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.hypervisor</key>
<true/>
</dict>
</plist>
PLIST
codesign --sign - --force --entitlements "$ents" "$tmp" \
|| { rm -f "$tmp"; die "codesign failed: $comp"; }
else
codesign --sign - --force "$tmp" || { rm -f "$tmp"; die "codesign failed: $comp"; }
fi
fi

mv -f "$tmp" "$target_file"
installed=$((installed + 1))
# Record the manifest hash paired with the hash actually on disk now (the
# latter == manifest hash except for a signed macOS bin file), so a later run
# recognizes it only while the manifest still wants this artifact (R5.1/R6.1).
# Record the manifest hash paired with the on-disk hash, so a later
# run and `--uninstall` know what this run placed (R5.1/R6.1). The
#
# paired-column format is retained for compatibility with records
# written by earlier installer versions.
printf '%s\t%s\t%s\t%s\n' "$comp" "$target_file" "$want" "$(sha256 "$target_file")" >>"$records"
done <"$applicable"

Expand Down
63 changes: 21 additions & 42 deletions scripts/install_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -144,19 +144,6 @@ esac
STUB
chmod +x "$stubbin/uname"

# Stub `codesign`: the real tool needs a valid Mach-O (our mock artifacts are
# plain files) and is macOS-only. It records the signed path and APPENDS to the
# file, emulating how signing rewrites the binary so its hash diverges from the
# manifest — which is exactly what the skip oracle must tolerate.
cat >"$stubbin/codesign" <<STUB
#!/bin/sh
f=
for a in "\$@"; do f="\$a"; done # target is the last argument
printf '%s\n' "\$f" >>"$root/codesign.calls"
printf 'adhoc-signature\n' >>"\$f"
STUB
chmod +x "$stubbin/codesign"

# Stub `xattr`: record the invocation; exit 0 (the installer swallows failure).
cat >"$stubbin/xattr" <<STUB
#!/bin/sh
Expand Down Expand Up @@ -339,71 +326,63 @@ env -i PATH="$stubbin:$H1/bin:/usr/bin:/bin" HOME="$H1" MINIMAL_BIN="$H1/bin" \
set -e
want_err "PATH advisory suppressed when bin present (R6.2)" grep -q "is not on your PATH" "$OUT"

# --- Unit 5 (darwin): dequarantine + ad-hoc codesign for bin files ---------
# --- Unit 5 (darwin): dequarantine Mach-O bin/lib components ---------------
# Force a darwin/arm64 platform (via the uname stub) so this runs on every lane.
# The applicable rows are then `minimal` (bin) and `rootfs` (data). A bin file
# must be quarantine-stripped and ad-hoc codesigned; a data file must not be.
# must be quarantine-stripped; a data file must not be. Release artifacts are
# Developer ID signed at build time, so the installer does NO local signing —
# it places the downloaded bytes verbatim.
PLAT_S=Darwin
PLAT_M=arm64
: >"$root/codesign.calls"
: >"$root/xattr.calls"
HD="$root/hd"; mkdir -p "$HD"
reset_dl
run darwin1 "$HD"
check 0 "$rc" "darwin install exits 0"
want_ok "darwin bin component installed" test -f "$HD/bin/minimal"
want_ok "quarantine stripped from bin (xattr)" grep -q "com.apple.quarantine" "$root/xattr.calls"
want_ok "bin file ad-hoc codesigned" grep -q "/bin/minimal" "$root/codesign.calls"
want_err "data component not codesigned" grep -q "rootfs" "$root/codesign.calls"

# A darwin `lib` dylib gets the SAME Mach-O treatment as a bin file — quarantine
# strip + ad-hoc codesign (the plain else-branch sign, no minvmd entitlement) —
# so the shipped libkrun.1.dylib is loadable. Isolated home + one-off manifest
# with a single darwin lib row, so it doesn't perturb the rerun counts below.
want_ok "quarantine stripped from bin (xattr)" grep -q "/bin/minimal" "$root/xattr.calls"
want_err "data component not dequarantined" grep -q "rootfs" "$root/xattr.calls"

# A darwin `lib` dylib gets the SAME dequarantine treatment as a bin file so the
# shipped libkrun.1.dylib runs without a Gatekeeper prompt. Isolated home +
# one-off manifest with a single darwin lib row, so it doesn't perturb the rerun
# counts below.
{
printf '# format: 1\n'
printf '# c o a v s k d s\n'
printf '%-12s %-7s %-7s %-9s %-64s %-6s %-20s %s\n' \
libkrun darwin arm64 v1 "$h_rootfs" file lib/libkrun.1.dylib versions/v1/rootfs-arm64.img
} >"$mock/versions/v1/components"
: >"$root/codesign.calls"; : >"$root/xattr.calls"
: >"$root/xattr.calls"
HDL="$root/hdl"; mkdir -p "$HDL"
reset_dl
run darwinlib "$HDL"
check 0 "$rc" "darwin lib install exits 0"
want_ok "darwin lib dylib installed" test -f "$HDL/.local/lib/libkrun.1.dylib"
want_ok "quarantine stripped from lib dylib (xattr)" \
grep -q "com.apple.quarantine" "$root/xattr.calls"
want_ok "lib dylib ad-hoc codesigned" \
grep -q "/lib/libkrun.1.dylib" "$root/codesign.calls"
grep -q "/lib/libkrun.1.dylib" "$root/xattr.calls"
cp "$root/good-components" "$mock/versions/v1/components" # restore
: >"$root/codesign.calls"; : >"$root/xattr.calls"
: >"$root/xattr.calls"

# Signing diverged the on-disk bytes from the manifest hash. A rerun must still
# recognize the component as installed (via the recorded hash) and not download
# or re-sign it — otherwise macOS reruns would never be cheap (Goal 2).
# The installer places the artifact bytes verbatim, so the on-disk hash equals
# the manifest hash. A rerun recognizes the component as already installed and
# downloads nothing (Goal 2 — cheap reruns).
reset_dl
: >"$root/codesign.calls"
run darwin2 "$HD"
check 0 "$rc" "darwin rerun exits 0"
check 0 "$(downloads)" "signed darwin bin: rerun downloads nothing"
want_err "signed darwin bin: not re-codesigned on rerun" grep -q "/bin/minimal" "$root/codesign.calls"
check 0 "$(downloads)" "darwin bin: rerun downloads nothing"

# A NEW release of a signed darwin bin (its manifest sha256 changes) must
# re-download and re-sign — NOT be judged up to date because the old signed
# bytes still equal the previously-recorded installed hash. The skip-by-record
# path is keyed on the manifest hash for exactly this staleness case.
# A NEW release (its manifest sha256 changes) is re-downloaded rather than judged
# up to date, since the on-disk bytes no longer match the new manifest hash.
printf 'darwin-arm64-minimal-body-v2\n' >"$mock/versions/v1/minimal-darwin-arm64-v2"
h_dmin2="$(hash_file "$mock/versions/v1/minimal-darwin-arm64-v2")"
awk -v h="$h_dmin2" \
'$1=="minimal" && $2=="darwin" {$5=h; $8="versions/v1/minimal-darwin-arm64-v2"} {print}' \
"$root/good-components" >"$mock/versions/v1/components"
reset_dl
: >"$root/codesign.calls"
run darwin3 "$HD"
check 0 "$rc" "darwin new-version rerun exits 0"
check 1 "$(downloads)" "new manifest hash re-downloads the signed darwin bin (staleness fix)"
want_ok "new darwin bin is re-codesigned" grep -q "/bin/minimal" "$root/codesign.calls"
check 1 "$(downloads)" "new manifest hash re-dow 5491 nloads the darwin bin"
cp "$root/good-components" "$mock/versions/v1/components" # restore
PLAT_S=Linux
PLAT_M=x86_64
Expand Down
Loading
0