From 30cd9063ab93ff00d677936dc7b1f3f2d29de947 Mon Sep 17 00:00:00 2001 From: Ron McCorkle <31546545+mack42@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:52:23 +0000 Subject: [PATCH 1/3] feat(release): sign and attest update artifacts --- .github/workflows/build-iso.yml | 81 ++++++++++++++++++++++++++++++++ aifw-api/src/updates.rs | 1 + aifw-core/src/updater.rs | 83 ++++++++++++++++++++++++++------- 3 files changed, 147 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-iso.yml b/.github/workflows/build-iso.yml index d6181cff..4606808d 100644 --- a/.github/workflows/build-iso.yml +++ b/.github/workflows/build-iso.yml @@ -12,9 +12,12 @@ on: permissions: contents: write + id-token: write + attestations: write env: FREEBSD_VERSION: "15.0" + MINISIGN_PUBLIC_KEY: ${{ vars.MINISIGN_PUBLIC_KEY }} jobs: build-ui: @@ -85,6 +88,10 @@ jobs: echo "=== Building AiFw binaries ===" cd /home/runner/work/AiFw/AiFw + test -n "$MINISIGN_PUBLIC_KEY" || { echo "MINISIGN_PUBLIC_KEY repository variable is required" >&2; exit 1; } + mkdir -p freebsd/overlay/usr/local/etc/aifw + printf '%s\n' "$MINISIGN_PUBLIC_KEY" > freebsd/overlay/usr/local/etc/aifw/update-signing.pub + chmod 644 freebsd/overlay/usr/local/etc/aifw/update-signing.pub cargo build --release echo "=== Validating sudoers content (visudo -cf, #204) ===" @@ -195,6 +202,47 @@ jobs: mkdir -p /home/runner/work/AiFw/AiFw/output cp /usr/obj/aifw-iso/output/* /home/runner/work/AiFw/AiFw/output/ + - name: Generate ISO CycloneDX SBOM + uses: anchore/sbom-action@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6 + with: + path: output/aifw-*.iso.xz + format: cyclonedx-json + output-file: output/aifw-iso.cdx.json + upload-artifact: false + + - name: Generate IMG CycloneDX SBOM + uses: anchore/sbom-action@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6 + with: + path: output/aifw-*.img.xz + format: cyclonedx-json + output-file: output/aifw-img.cdx.json + upload-artifact: false + + - name: Generate update CycloneDX SBOM + uses: anchore/sbom-action@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6 + with: + path: output/aifw-update-*.tar.xz + format: cyclonedx-json + output-file: output/aifw-update.cdx.json + upload-artifact: false + + - name: Sign update checksums + env: + MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }} + MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }} + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y minisign + key_file="$RUNNER_TEMP/minisign.key" + trap 'rm -f "$key_file"' EXIT + printf '%s' "$MINISIGN_SECRET_KEY" > "$key_file" + chmod 600 "$key_file" + for checksum in output/aifw-update-*.tar.xz.sha256; do + [ -f "$checksum" ] || continue + minisign -S -s "$key_file" -m "$checksum" -x "${checksum}.minisig" + done + - name: Upload ISO artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: @@ -263,6 +311,22 @@ jobs: name: boot-smoke-artifacts path: smoke-artifacts/ retention-days: 7 + - name: Upload SBOMs and signatures + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: supply-chain + path: | + output/*.cdx.json + output/*.minisig + retention-days: 5 + + - name: Attest release artifacts + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 + with: + subject-path: | + output/aifw-*.iso.xz + output/aifw-*.img.xz + output/aifw-update-*.tar.xz release: name: Create GitHub Release @@ -296,6 +360,23 @@ jobs: name: components path: release-assets/ + - name: Download supply-chain assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: supply-chain + path: release-assets/ + + - name: Verify update signatures before publication + env: + MINISIGN_PUBLIC_KEY: ${{ vars.MINISIGN_PUBLIC_KEY }} + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y minisign + for checksum in release-assets/aifw-update-*.tar.xz.sha256; do + minisign -Vm "$checksum" -x "${checksum}.minisig" -P "$MINISIGN_PUBLIC_KEY" + done + - name: Extract version from tag id: version run: if [ -n "${{ inputs.version }}" ]; then diff --git a/aifw-api/src/updates.rs b/aifw-api/src/updates.rs index d0605ab7..d7810803 100644 --- a/aifw-api/src/updates.rs +++ b/aifw-api/src/updates.rs @@ -565,6 +565,7 @@ pub async fn aifw_update_status( published_at: String::new(), tarball_url: None, checksum_url: None, + checksum_signature_url: None, has_backup: std::path::Path::new("/usr/local/share/aifw/backup/version").exists(), backup_version: tokio::fs::read_to_string("/usr/local/share/aifw/backup/version") .await diff --git a/aifw-core/src/updater.rs b/aifw-core/src/updater.rs index 082c484e..75a2ed15 100644 --- a/aifw-core/src/updater.rs +++ b/aifw-core/src/updater.rs @@ -251,6 +251,9 @@ pub struct AifwUpdateInfo { pub tarball_url: Option, /// Download URL of the tarball's SHA-256 checksum asset, if present pub checksum_url: Option, + /// Download URL of the checksum's detached minisign signature. + #[serde(default)] + pub checksum_signature_url: Option, /// True when a rollback backup exists on disk pub has_backup: bool, /// Version the on-disk backup was taken from, when known @@ -343,24 +346,7 @@ pub async fn check_for_update(include_prereleases: bool) -> Result Result Result Result Result Result<(), UpdaterError> { + const PUBKEY_PATH: &str = "/usr/local/etc/aifw/update-signing.pub"; + let output = Command::new("minisign") + .args(["-Vm", checksum, "-x", signature, "-p", PUBKEY_PATH]) + .output() + .await + .map_err(|e| UpdaterError::Download(format!("signature verification unavailable: {e}")))?; + if output.status.success() { + Ok(()) + } else { + Err(UpdaterError::Checksum) + } +} + /// Services that may have had their rc.d script replaced by an update and /// therefore need a restart for the new script to take effect. Order /// matters for aifw_api (last) so HTTP stays up as long as possible. @@ -1323,6 +1335,28 @@ fn extract_hash(checksum_content: &str) -> String { line.split_whitespace().next().unwrap_or("").to_string() } +fn release_asset_urls( + release: &serde_json::Value, +) -> (Option, Option, Option) { + let mut tarball = None; + let mut checksum = None; + let mut signature = None; + if let Some(assets) = release["assets"].as_array() { + for asset in assets { + let name = asset["name"].as_str().unwrap_or(""); + let url = asset["browser_download_url"].as_str().unwrap_or(""); + if name.starts_with("aifw-update-") && name.ends_with(".tar.xz") { + tarball = Some(url.to_string()); + } else if name.starts_with("aifw-update-") && name.ends_with(".tar.xz.sha256.minisig") { + signature = Some(url.to_string()); + } else if name.starts_with("aifw-update-") && name.ends_with(".tar.xz.sha256") { + checksum = Some(url.to_string()); + } + } + } + (tarball, checksum, signature) +} + async fn http_get(url: &str) -> Result { // Try fetch (FreeBSD) first, fall back to curl if let Ok(o) = Command::new("fetch").args(["-qo", "-", url]).output().await @@ -1544,6 +1578,19 @@ mod tests { assert_eq!(extract_hash(input), "abc123def456"); } + #[test] + fn release_assets_require_distinct_checksum_and_signature_sidecars() { + let release = serde_json::json!({"assets": [ + {"name": "aifw-update-6.0.0-amd64.tar.xz", "browser_download_url": "tar"}, + {"name": "aifw-update-6.0.0-amd64.tar.xz.sha256", "browser_download_url": "sum"}, + {"name": "aifw-update-6.0.0-amd64.tar.xz.sha256.minisig", "browser_download_url": "sig"} + ]}); + assert_eq!( + release_asset_urls(&release), + (Some("tar".into()), Some("sum".into()), Some("sig".into())) + ); + } + // Regression gate for #469: every overlay libexec script must carry the // execute bit in git. Eight aifw-sudo-* helpers were committed mode 644, // build-iso.sh's `cp -a` preserved that onto installed systems, and sudo From 7cd3e517fe0d255e4788f1a3d62bf76486b3a257 Mon Sep 17 00:00:00 2001 From: TheDancingDeveloper Date: Wed, 22 Jul 2026 00:29:37 +0000 Subject: [PATCH 2/3] feat: enforce signed release verification end to end --- .github/workflows/build-iso.yml | 87 +++++++++---------- aifw-core/src/updater.rs | 10 ++- .../app/updates/components/FirmwareCard.tsx | 21 ++++- aifw-ui/src/lib/api/updates.ts | 1 + freebsd/build-iso.sh | 2 +- freebsd/deploy.sh | 2 +- freebsd/manifest.json | 2 +- 7 files changed, 75 insertions(+), 50 deletions(-) diff --git a/.github/workflows/build-iso.yml b/.github/workflows/build-iso.yml index 4606808d..7e52f0aa 100644 --- a/.github/workflows/build-iso.yml +++ b/.github/workflows/build-iso.yml @@ -202,44 +202,30 @@ jobs: mkdir -p /home/runner/work/AiFw/AiFw/output cp /usr/obj/aifw-iso/output/* /home/runner/work/AiFw/AiFw/output/ - - name: Generate ISO CycloneDX SBOM + - name: Generate release CycloneDX SBOM uses: anchore/sbom-action@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6 with: - path: output/aifw-*.iso.xz - format: cyclonedx-json - output-file: output/aifw-iso.cdx.json - upload-artifact: false - - - name: Generate IMG CycloneDX SBOM - uses: anchore/sbom-action@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6 - with: - path: output/aifw-*.img.xz + path: output format: cyclonedx-json - output-file: output/aifw-img.cdx.json + output-file: output/aifw-release.cdx.json upload-artifact: false - - name: Generate update CycloneDX SBOM - uses: anchore/sbom-action@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6 - with: - path: output/aifw-update-*.tar.xz - format: cyclonedx-json - output-file: output/aifw-update.cdx.json - upload-artifact: false - - - name: Sign update checksums + - name: Sign release checksums env: MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }} MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }} run: | set -euo pipefail + test -n "$MINISIGN_SECRET_KEY" || { echo "MINISIGN_SECRET_KEY is required" >&2; exit 1; } + test -n "$MINISIGN_PUBLIC_KEY" || { echo "MINISIGN_PUBLIC_KEY is required" >&2; exit 1; } sudo apt-get update sudo apt-get install -y minisign key_file="$RUNNER_TEMP/minisign.key" trap 'rm -f "$key_file"' EXIT printf '%s' "$MINISIGN_SECRET_KEY" > "$key_file" + printf '%s\n' "$MINISIGN_PUBLIC_KEY" > output/update-signing.pub chmod 600 "$key_file" - for checksum in output/aifw-update-*.tar.xz.sha256; do - [ -f "$checksum" ] || continue + for checksum in output/*.sha256; do minisign -S -s "$key_file" -m "$checksum" -x "${checksum}.minisig" done @@ -271,6 +257,24 @@ jobs: path: output/aifw-components-*.json retention-days: 5 + - name: Upload SBOMs and signatures + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: supply-chain + path: | + output/*.cdx.json + output/*.minisig + output/update-signing.pub + retention-days: 5 + + - name: Attest release artifacts + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 + with: + subject-path: | + output/aifw-*.iso.xz + output/aifw-*.img.xz + output/aifw-update-*.tar.xz + smoke-boot: name: Appliance boot smoke (qemu) needs: build-iso @@ -311,23 +315,6 @@ jobs: name: boot-smoke-artifacts path: smoke-artifacts/ retention-days: 7 - - name: Upload SBOMs and signatures - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: supply-chain - path: | - output/*.cdx.json - output/*.minisig - retention-days: 5 - - - name: Attest release artifacts - uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 - with: - subject-path: | - output/aifw-*.iso.xz - output/aifw-*.img.xz - output/aifw-update-*.tar.xz - release: name: Create GitHub Release # smoke-boot gates the release: an image nobody booted never ships (#533). @@ -366,15 +353,18 @@ jobs: name: supply-chain path: release-assets/ - - name: Verify update signatures before publication + - name: Verify signatures before publication env: MINISIGN_PUBLIC_KEY: ${{ vars.MINISIGN_PUBLIC_KEY }} run: | set -euo pipefail + test -n "$MINISIGN_PUBLIC_KEY" || { echo "MINISIGN_PUBLIC_KEY is required" >&2; exit 1; } sudo apt-get update sudo apt-get install -y minisign - for checksum in release-assets/aifw-update-*.tar.xz.sha256; do - minisign -Vm "$checksum" -x "${checksum}.minisig" -P "$MINISIGN_PUBLIC_KEY" + public_key_file="$RUNNER_TEMP/minisign.pub" + printf '%s\n' "$MINISIGN_PUBLIC_KEY" > "$public_key_file" + for checksum in release-assets/*.sha256; do + minisign -Vm "$checksum" -x "${checksum}.minisig" -p "$public_key_file" done - name: Extract version from tag @@ -416,6 +406,15 @@ jobs: ### Verify Downloads ```bash - sha256sum -c aifw-${{ steps.version.outputs.version }}-amd64.iso.sha256 - sha256sum -c aifw-${{ steps.version.outputs.version }}-amd64.img.sha256 + minisign -Vm aifw-${{ steps.version.outputs.version }}-amd64.iso.xz.sha256 \ + -x aifw-${{ steps.version.outputs.version }}-amd64.iso.xz.sha256.minisig \ + -p update-signing.pub + sha256sum -c aifw-${{ steps.version.outputs.version }}-amd64.iso.xz.sha256 + ``` + + GitHub build provenance can also be verified with: + + ```bash + gh attestation verify aifw-${{ steps.version.outputs.version }}-amd64.iso.xz \ + --repo ${{ github.repository }} ``` diff --git a/aifw-core/src/updater.rs b/aifw-core/src/updater.rs index 75a2ed15..eba7dba6 100644 --- a/aifw-core/src/updater.rs +++ b/aifw-core/src/updater.rs @@ -223,6 +223,12 @@ pub enum UpdaterError { /// Downloaded tarball didn't match its published SHA-256 #[error("Checksum verification failed")] Checksum, + /// The release did not contain a detached signature for its checksum + #[error("Release checksum signature is missing")] + NoSignature, + /// The checksum's publisher signature could not be verified + #[error("Release signature verification failed")] + Signature, /// Extracting or installing the update failed #[error("Installation failed: {0}")] Install(String), @@ -845,7 +851,7 @@ pub async fn download_and_install(info: &AifwUpdateInfo) -> Result Result<(), if output.status.success() { Ok(()) } else { - Err(UpdaterError::Checksum) + Err(UpdaterError::Signature) } } diff --git a/aifw-ui/src/app/updates/components/FirmwareCard.tsx b/aifw-ui/src/app/updates/components/FirmwareCard.tsx index 6d3337e9..0a1ecf9c 100644 --- a/aifw-ui/src/app/updates/components/FirmwareCard.tsx +++ b/aifw-ui/src/app/updates/components/FirmwareCard.tsx @@ -55,6 +55,16 @@ export function FirmwareCard({ )} + {info?.checksum_signature_url && ( + + Verify signed checksum + + )}