diff --git a/.github/workflows/build-iso.yml b/.github/workflows/build-iso.yml index d6181cff..caf2a38c 100644 --- a/.github/workflows/build-iso.yml +++ b/.github/workflows/build-iso.yml @@ -12,6 +12,8 @@ on: permissions: contents: write + id-token: write + attestations: write env: FREEBSD_VERSION: "15.0" @@ -187,6 +189,10 @@ jobs: if [ -f "$f" ] && [ ! -f "${f}.xz" ]; then xz -T0 -9 "$f" sha256 "${f}.xz" > "${f}.xz.sha256" + # build-iso.sh hashed the uncompressed file, which no + # longer exists after xz — drop its checksum so we never + # sign or publish a sum nobody can verify. + rm -f "${f}.sha256" fi done cd /home/runner/work/AiFw/AiFw @@ -195,6 +201,45 @@ jobs: mkdir -p /home/runner/work/AiFw/AiFw/output cp /usr/obj/aifw-iso/output/* /home/runner/work/AiFw/AiFw/output/ + # Scan the SOURCE tree, not output/: the release artifacts are + # xz-compressed images syft cannot see inside, so an SBOM of output/ + # would be an empty inventory. The source scan covers Cargo.lock and + # aifw-ui/package-lock.json — the dependency set actually compiled + # into this release. + - name: Generate release CycloneDX SBOM + uses: anchore/sbom-action@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6 + with: + path: . + format: cyclonedx-json + output-file: output/aifw-release.cdx.json + upload-artifact: false + + - 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; } + 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" + pubkey=freebsd/overlay/usr/local/etc/aifw/update-signing.pub + cp "$pubkey" output/update-signing.pub + for checksum in output/*.sha256; do + # minisign reads the key password from stdin when there is no + # tty; harmless if the key is passwordless. + printf '%s\n' "${MINISIGN_PASSWORD:-}" | \ + minisign -S -s "$key_file" -m "$checksum" -x "${checksum}.minisig" + # Verify against the COMMITTED public key immediately: catches a + # rotated/mismatched MINISIGN_SECRET_KEY before anything ships, + # since appliances verify with the key compiled from this file. + minisign -Vm "$checksum" -x "${checksum}.minisig" -p "$pubkey" + done + - name: Upload ISO artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: @@ -223,6 +268,25 @@ 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 + output/aifw-release.cdx.json + smoke-boot: name: Appliance boot smoke (qemu) needs: build-iso @@ -263,7 +327,6 @@ jobs: name: boot-smoke-artifacts path: smoke-artifacts/ retention-days: 7 - release: name: Create GitHub Release # smoke-boot gates the release: an image nobody booted never ships (#533). @@ -296,6 +359,25 @@ 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 signatures before publication + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y minisign + # The committed key from checkout — the same file compiled into + # the appliance updater — not a copy that traveled with the + # artifacts being verified. + pubkey=freebsd/overlay/usr/local/etc/aifw/update-signing.pub + for checksum in release-assets/*.sha256; do + minisign -Vm "$checksum" -x "${checksum}.minisig" -p "$pubkey" + done + - name: Extract version from tag id: version run: if [ -n "${{ inputs.version }}" ]; then @@ -335,6 +417,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-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..18cac5fb 100644 --- a/aifw-core/src/updater.rs +++ b/aifw-core/src/updater.rs @@ -223,6 +223,17 @@ 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, + /// minisign could not be located or executed, so the signature could + /// not be checked at all (distinct from a signature that checked and + /// failed — the operator fixes this one with `pkg install minisign`) + #[error("Signature verification unavailable: {0}")] + VerifyUnavailable(String), /// Extracting or installing the update failed #[error("Installation failed: {0}")] Install(String), @@ -251,6 +262,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 +357,7 @@ pub async fn check_for_update(include_prereleases: bool) -> Result Result Result Result Result Result<&'static str, UpdaterError> { + EMBEDDED_PUBKEY + .lines() + .map(str::trim) + .rfind(|l| !l.is_empty() && !l.starts_with("untrusted comment:")) + .filter(|l| l.starts_with("RW")) + .ok_or_else(|| { + UpdaterError::VerifyUnavailable( + "embedded update-signing public key is malformed".into(), + ) + }) +} + +async fn verify_minisign_checksum(checksum: &str, signature: &str) -> Result<(), UpdaterError> { + let pubkey = embedded_pubkey_b64()?; + let args = ["-Vm", checksum, "-x", signature, "-P", pubkey]; + + let mut result = Command::new("minisign").args(args).output().await; + if matches!(&result, Err(e) if e.kind() == std::io::ErrorKind::NotFound) { + // An appliance upgraded from a pre-signing build doesn't have + // minisign yet: the OLD updater that installed this build worked + // from its own embedded package list, which predates the minisign + // entry in the manifest. The pkg sudo grant does exist on such + // appliances, so install it here rather than failing the upgrade. + info!("minisign not found; installing via pkg"); + if let Some(err) = step_failure(&crate::sudo::pkg("install", &["-y", "minisign"]).await) { + return Err(UpdaterError::VerifyUnavailable(format!( + "minisign is not installed and installing it failed: {err}" + ))); + } + result = Command::new("minisign").args(args).output().await; + } + let output = result + .map_err(|e| UpdaterError::VerifyUnavailable(format!("failed to run minisign: {e}")))?; + if output.status.success() { + Ok(()) + } else { + warn!( + stderr = %String::from_utf8_lossy(&output.stderr), + "release checksum signature rejected" + ); + Err(UpdaterError::Signature) + } +} + /// 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 +1418,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 +1661,33 @@ mod tests { assert_eq!(extract_hash(input), "abc123def456"); } + // The compiled-in signing key is the trust root for every self-update: + // if the committed .pub file is reformatted into something this parser + // rejects, all appliances fail closed on the next release. + #[test] + fn embedded_update_signing_pubkey_parses() { + let key = embedded_pubkey_b64().expect("embedded public key must parse"); + assert!( + key.starts_with("RW"), + "minisign keys are RW-prefixed: {key}" + ); + assert!(!key.contains(char::is_whitespace)); + assert_eq!(key.len(), 56, "Ed25519 minisign pubkey is 56 base64 chars"); + } + + #[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 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 + + )}