From b7bb667e3574fc3f4e5c193e2bf9936b43f6aa41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C4=8Cerm=C3=A1k?= Date: Mon, 8 Jun 2026 17:31:55 +0200 Subject: [PATCH] Add CI check for sufficient validity of the OTA signing certificate (#4751) * Add CI check for sufficient validity of the OTA signing certificate To ensure that we don't miss expiration of the signing certificate, add a CI check that checks its validity and validity of the full chain of trust, ensuring CA doesn't expire either. Currently set to 25 months, which should be enough to allow migration of existing devices to new CA and allow some wiggle room for upgrades and downgrades. Refs #4743 * Find real issuer when walking the chain * Add number of certs checked to the suceess message --- .github/workflows/build.yaml | 10 +++++ scripts/check-signing-validity.sh | 62 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100755 scripts/check-signing-validity.sh diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 57a785ca0..7bdba8b3a 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -206,6 +206,16 @@ jobs: buildroot-external/scripts/generate-signing-key.sh cert.pem key.pem echo "self_signed_cert=true" >> $GITHUB_OUTPUT + - name: Check signing certificate validity + env: + RAUC_CERTIFICATE: ${{ secrets.RAUC_CERTIFICATE }} + if: env.RAUC_CERTIFICATE != '' + run: | + echo -e "-----BEGIN CERTIFICATE-----\n${RAUC_CERTIFICATE}\n-----END CERTIFICATE-----" > signing-cert.pem + # 760 days ~ 25 months (the OS support window, with margin) + scripts/check-signing-validity.sh signing-cert.pem buildroot-external/ota/rel-ca.pem 760 + rm -f signing-cert.pem + - name: Create signing key uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: steps.generate_signing_key.outcome == 'success' diff --git a/scripts/check-signing-validity.sh b/scripts/check-signing-validity.sh new file mode 100755 index 000000000..b256fd513 --- /dev/null +++ b/scripts/check-signing-validity.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Fail the build if the signing certificate, or any CA certificate in its chain, +# expires within MIN_DAYS. Bundles signed now must stay verifiable across the +# whole support window, so we rotate proactively rather than ship a bundle that +# stops validating before the window ends. Run in CI before building. +# +# Usage: check-signing-validity.sh [min_days] +set -euo pipefail + +CERT="${1:?usage: $0 [min_days]}" +KEYRING="${2:?usage: $0 [min_days]}" +MIN_DAYS="${3:-760}" + +THRESHOLD=$(date -u -d "+${MIN_DAYS} days" +%s) +THRESHOLD_DATE=$(date -u -d "+${MIN_DAYS} days" +%Y-%m-%d) + +fail() { echo "::error::$*"; exit 1; } +enddate() { openssl x509 -in "$1" -noout -enddate | cut -d= -f2; } +subject() { openssl x509 -in "$1" -noout -subject | sed 's/^subject=//'; } +too_soon() { [ "$(date -u -d "$(enddate "$1")" +%s)" -lt "$THRESHOLD" ]; } + +# 1) CA chain (most urgent: a CA rotation is a full migration). Walk the chain +# leaf -> intermediate -> root through the keyring, checking each CA cert. Only +# the chain the leaf actually uses is walked, so the deliberately-expiring old CA +# certs (kept in the keyring for downgrades) are ignored. +tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT +awk -v d="$tmp" '/-----BEGIN CERTIFICATE-----/{n++} {print > (d"/"n".pem")}' "$KEYRING" + +# Issuer of $1 within the keyring, picked by signature (not just subject name) so +# same-subject certs - e.g. a future same-CN key rotation - are disambiguated. +find_issuer() { + local cand ih + ih=$(openssl x509 -in "$1" -noout -issuer_hash) + for cand in "$tmp"/*.pem; do + # Narrow by subject name, then confirm by signature (-partial_chain treats + # the candidate as a standalone anchor, -no_check_time leaves expiry to us). + # Only the real signer verifies, so same-subject siblings are disambiguated. + [ "$(openssl x509 -in "$cand" -noout -subject_hash)" = "$ih" ] || continue + openssl verify -partial_chain -no_check_time -CAfile "$cand" "$1" >/dev/null 2>&1 \ + && { printf '%s\n' "$cand"; return 0; } + done + return 1 +} + +cur="$CERT"; hops=0 +while :; do + [ "$(openssl x509 -in "$cur" -noout -subject_hash)" = \ + "$(openssl x509 -in "$cur" -noout -issuer_hash)" ] && break # self-signed root + next=$(find_issuer "$cur") || fail "Issuer of '$(subject "$cur")' not found in ${KEYRING} - the signing certificate does not chain to this keyring." + if too_soon "$next"; then + fail "CA certificate '$(subject "$next")' expires $(enddate "$next") (within ${MIN_DAYS} days, before ${THRESHOLD_DATE}). A new CA should be generated, the keyrings (buildroot-external/ota/*-ca.pem) updated, and the cross-signed intermediate added to the bundle." + fi + cur="$next"; hops=$((hops + 1)) + [ "$hops" -le 10 ] || fail "certificate chain too long (loop?)" +done + +# 2) signing leaf +if too_soon "$CERT"; then + fail "Signing certificate expires $(enddate "$CERT") (within ${MIN_DAYS} days, before ${THRESHOLD_DATE}). A new leaf certificate needs to be generated and the CI signing key updated." +fi + +echo "Signing certificate and CA chain (in total $((hops + 1)) certificates) are valid for at least ${MIN_DAYS} days (past ${THRESHOLD_DATE})."