diff --git a/.github/workflows/release-keyvault-cert.yaml b/.github/workflows/release-keyvault-cert.yaml new file mode 100644 index 00000000..2023e324 --- /dev/null +++ b/.github/workflows/release-keyvault-cert.yaml @@ -0,0 +1,64 @@ +name: Download certificate from keyvault and publish it as release + +on: + workflow_dispatch: + inputs: + branch: + description: 'branch to build' + type: string + required: false + default: 'main' + schedule: + # Each day at 5am UTC + - cron: '0 5 * * *' + +jobs: + create-cert-release: + runs-on: ubuntu-24.04 + permissions: + # allow the action to create a release + contents: write + id-token: write + steps: + - name: Check out the bakery repo + uses: actions/checkout@v4 + with: + ref: ${{ inputs.branch != '' && inputs.branch || 'main' }} + path: bakery + - name: build + id: build + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + bakery: ${{ github.repository }} + + KEYVAULT_CERT_NAME: ${{ secrets.KEYVAULT_CERT_NAME }} + AZURE_KEYVAULT_URL: https://${{ secrets.KEYVAULT_NAME }}.vault.azure.net/ + + AZURE_FEDERATED_TOKEN_FILE: /tmp/azure-oidc-token + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_LOG_LEVEL: verbose + AZURE_KEYVAULT_PKCS11_DEBUG: 1 + PKCS11_MODULE_PATH: /usr/lib64/pkcs11/azure-keyvault-pkcs11.so + run: | + cd bakery + ./flatcar-container-wrap.sh \ + 'cert_name="${bakery//\//-}.crt" && \ + curl -sSL \ + -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=api://AzureADTokenExchange" \ + | jq -r '.value' | sudo tee "$AZURE_FEDERATED_TOKEN_FILE" && \ + p11-kit export-object "pkcs11:token=$KEYVAULT_CERT_NAME;type=cert" | sudo tee "$cert_name"' + - name: create a new release + uses: softprops/action-gh-release@v2 + with: + make_latest: true + tag_name: signing_cert + body: | + The public key used for verification of sysexts produced and signed + in sysext-bakery. You should place this key to one of the verity.d + directories (e.g. /etc/verity.d/) to trust this key on your system. + files: | + bakery/*.crt diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0210e525..2d8edf64 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -59,6 +59,7 @@ jobs: permissions: # allow the action to create a release contents: write + id-token: write steps: - name: Set up qemu / binmft misc for cross-platform builds uses: docker/setup-qemu-action@v3 @@ -78,15 +79,30 @@ jobs: jq \ squashfs-tools \ xz-utils \ - erofs-utils + erofs-utils \ + skopeo - name: build id: build + shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + KEYVAULT_CERT_NAME: ${{ secrets.KEYVAULT_CERT_NAME }} + AZURE_KEYVAULT_URL: https://${{ secrets.KEYVAULT_NAME }}.vault.azure.net/ + + AZURE_FEDERATED_TOKEN_FILE: /tmp/azure-oidc-token + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_LOG_LEVEL: verbose + AZURE_KEYVAULT_PKCS11_DEBUG: 1 + PKCS11_MODULE_PATH: /usr/lib64/pkcs11/azure-keyvault-pkcs11.so run: | pushd bakery + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin ./release.sh ${{ matrix.release }} + ./flatcar-container-wrap.sh \ + "sudo -E ./sign_sysext_keyvault.sh ${{ matrix.release }} ${{ secrets.KEYVAULT_CERT_NAME }}" - name: create a new release uses: softprops/action-gh-release@v2 diff --git a/flatcar-container-wrap.sh b/flatcar-container-wrap.sh new file mode 100755 index 00000000..867a7443 --- /dev/null +++ b/flatcar-container-wrap.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +FLATCAR_SDK_CONTAINER="ghcr.io/flatcar/flatcar-sdk-all" +LATEST_FLATCAR_SDK_VERSION=$(skopeo list-tags docker://${FLATCAR_SDK_CONTAINER} | \ + jq -r '.Tags[]' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1) +EXCLUDED_VARS="^(DIRSTACK|HOME|HOSTNAME|LOGNAME|MAIL|OLDPWD|PATH|PWD|USER|USERNAME|XDG_DATA_DIRS|BASH.*|EPOCH|RANDOM)$" + +ARGS=("--rm" "--volume" "/tmp:/tmp" "--volume" "$PWD:/app" --entrypoint "" --workdir "/app" "--privileged" "--user" "sdk") +# Extra "sh -c" is needed to only export the exported variables +for VARNAME in $(bash -c "compgen -v | grep -vE \"$EXCLUDED_VARS\""); do + set +u + VAL="${!VARNAME}" + set -u + ARGS+=("--env" "${VARNAME}=${VAL}") +done + +docker run "${ARGS[@]}" "${FLATCAR_SDK_CONTAINER}:$LATEST_FLATCAR_SDK_VERSION" \ + bash -c "sudo ./setup_scripts_repo.sh && $*" diff --git a/lib/setup_azure_keyvault.sh b/lib/setup_azure_keyvault.sh new file mode 100755 index 00000000..e2e5a196 --- /dev/null +++ b/lib/setup_azure_keyvault.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +set -euo pipefail + +libroot="$(dirname "$(readlink -f "$0")")" + +if [[ $# -eq 0 || $# -ge 3 ]]; then + echo "Usage: $0 []" + echo "Note: AZURE_SUBSCRIPTION_ID can also be specified via environment variable" + exit 1 +fi + +set +u +AZURE_SUBSCRIPTION_ID="${AZURE_SUBSCRIPTION_ID:-$2}" +set -u +RG_NAME="$1" +LOCATION="westeurope" +KV_NAME="sysext-bakery" +CERT_NAME="sysext-bakery-signing" +IDENTITY_NAME="sysext-bakery-github-actions" +FED_CRED_NAME="gh-main" +GITHUB_REPO=$(source "${libroot}/libbakery.sh"; echo "$bakery") # owner/repo +GITHUB_BRANCH="main" + +echo "Setting subscription..." +az account set --subscription "$AZURE_SUBSCRIPTION_ID" +TENANT_ID="$(az account show --query tenantId -o tsv)" + +echo "Creating resource group $RG_NAME in $LOCATION..." +az group create -n "$RG_NAME" -l "$LOCATION" -o none + +# ============ +# KEY VAULT +# ============ +echo "Creating Key Vault $KV_NAME..." +# Enable RBAC authorization and purge protection (soft-delete is on by default) +# +az keyvault show -n "$KV_NAME" -g "$RG_NAME" -o none 2>/dev/null || \ + az keyvault create \ + --name "$KV_NAME" \ + --resource-group "$RG_NAME" \ + --location "$LOCATION" \ + --sku standard \ + --enable-purge-protection true \ + --enable-rbac-authorization true \ + -o none +KV_ID="$(az keyvault show -n "$KV_NAME" -g "$RG_NAME" --query id -o tsv)" + +# 2) Get your Azure AD object id (the caller shown in the error) +# This works when you're logged in as a user (not a service principal) +USER_OID=$(az ad signed-in-user show --query id -o tsv) +USER_NAME=$(az ad signed-in-user show --query userPrincipalName -o tsv) +RG_ID=$(az group show -n "$RG_NAME" --query id -o tsv) + +# 3) Grant yourself a data-plane role that includes certificate create +# Pick ONE of the two; Administrator is broader than Certificates Officer. +for role in "Key Vault Administrator" "Owner"; do + echo "Granting $role role in RG $RG_NAME to $USER_NAME" + az role assignment create \ + --assignee-object-id "$USER_OID" \ + --assignee-principal-type User \ + --role "$role" \ + --scope "$RG_ID" \ + -o none +done + +echo "Waiting 30 seconds for the permissions to apply. If the script fails, please wait and run it again." +sleep 30 + +# ============ +# CERTIFICATE +# ============ +echo "Creating self-signed certificate $CERT_NAME in $KV_NAME (default policy)..." +DEFAULT_POLICY="$(az keyvault certificate get-default-policy)" +az keyvault certificate show --vault-name "$KV_NAME" -n "$CERT_NAME" -o none 2>/dev/null || \ + az keyvault certificate create \ + --vault-name "$KV_NAME" \ + --name "$CERT_NAME" \ + --policy "$DEFAULT_POLICY" \ + -o none + +# =============================== +# USER-ASSIGNED MANAGED IDENTITY +# =============================== +echo "Creating user-assigned managed identity $IDENTITY_NAME..." +az identity show --name "$IDENTITY_NAME" --resource-group "$RG_NAME" -o none 2>/dev/null || \ + az identity create \ + --name "$IDENTITY_NAME" \ + --resource-group "$RG_NAME" \ + --location "$LOCATION" \ + -o none + +IDENTITY_JSON="$(az identity show -n "$IDENTITY_NAME" -g "$RG_NAME")" +IDENTITY_CLIENT_ID="$(echo "$IDENTITY_JSON" | jq -r .clientId)" +IDENTITY_PRINCIPAL_ID="$(echo "$IDENTITY_JSON" | jq -r .principalId)" +# IDENTITY_ID="$(echo "$IDENTITY_JSON" | jq -r .id)" + +echo "Identity clientId: $IDENTITY_CLIENT_ID" +echo "Identity principalId: $IDENTITY_PRINCIPAL_ID" + +# ===================================== +# FEDERATED CREDENTIAL FOR GITHUB OIDC +# ===================================== +echo "Creating federated credential $FED_CRED_NAME on identity $IDENTITY_NAME for repo $GITHUB_REPO branch ${GITHUB_BRANCH}..." +ISSUER="https://token.actions.githubusercontent.com" +AUDIENCE="api://AzureADTokenExchange" +SUBJECT="repo:${GITHUB_REPO}:ref:refs/heads/${GITHUB_BRANCH}" + +az identity federated-credential show --name "$FED_CRED_NAME" --identity-name "$IDENTITY_NAME" --resource-group "$RG_NAME" -o none > /dev/null 2>&1 || \ + az identity federated-credential create \ + --name "$FED_CRED_NAME" \ + --identity-name "$IDENTITY_NAME" \ + --resource-group "$RG_NAME" \ + --issuer "$ISSUER" \ + --subject "$SUBJECT" \ + --audiences "$AUDIENCE" \ + -o none + +# ================================== +# ROLE ASSIGNMENTS (Key Vault RBAC) +# ================================== + +IDENTITY_NAME="sysext-bakery-github-actions" +PRINCIPAL_ID=$(az identity show -n "$IDENTITY_NAME" -g "$RG_NAME" --query principalId -o tsv) +for role in "Key Vault Crypto User" "Key Vault Secrets User" "Key Vault Reader"; do + echo "Granting $role role in RG $RG_NAME to $IDENTITY_NAME" + az role assignment create \ + --assignee-object-id "$PRINCIPAL_ID" \ + --assignee-principal-type ServicePrincipal \ + --role "$role" \ + --scope "$RG_ID" \ + -o none +done + +# az role assignment create \ +# --assignee-object-id "$PRINCIPAL_ID" \ +# --assignee-principal-type ServicePrincipal \ +# --role "Reader" \ +# --scope "$KV_ID" + +echo "All done." +echo "---------------------------------------------" +echo "Key Vault: $KV_NAME" +echo "Certificate: $CERT_NAME" +echo "Managed Identity: $IDENTITY_NAME" +echo "Identity clientId: $IDENTITY_CLIENT_ID" +echo "Tenant ID: $TENANT_ID" +echo "Subscription ID: $AZURE_SUBSCRIPTION_ID" +echo "---------------------------------------------" +echo "" +echo "Please set these GitHub secrets on repo ${GITHUB_REPO}:" +echo "" +echo "---------------------------------------------" +echo "AZURE_CLIENT_ID : $IDENTITY_CLIENT_ID" +echo "AZURE_SUBSCRIPTION_ID : $AZURE_SUBSCRIPTION_ID" +echo "AZURE_TENANT_ID : $TENANT_ID" +echo "KEYVAULT_CERT_NAME : $CERT_NAME" +echo "KEYVAULT_NAME : $KV_NAME" +echo "" +echo "You can do it like this:" +echo "" +echo "echo '$IDENTITY_CLIENT_ID' | gh secret set --repo '$GITHUB_REPO' AZURE_CLIENT_ID" +echo "echo '$AZURE_SUBSCRIPTION_ID' | gh secret set --repo '$GITHUB_REPO' AZURE_SUBSCRIPTION_ID" +echo "echo '$TENANT_ID' | gh secret set --repo '$GITHUB_REPO' AZURE_TENANT_ID" +echo "echo '$CERT_NAME' | gh secret set --repo '$GITHUB_REPO' KEYVAULT_CERT_NAME" +echo "echo '$KV_NAME' | gh secret set --repo '$GITHUB_REPO' KEYVAULT_NAME" +echo "---------------------------------------------" diff --git a/repart.d/wrap-fs-in-ddi/10-root.conf b/repart.d/wrap-fs-in-ddi/10-root.conf new file mode 100644 index 00000000..834488bc --- /dev/null +++ b/repart.d/wrap-fs-in-ddi/10-root.conf @@ -0,0 +1,5 @@ +[Partition] +Type=root +Verity=data +VerityMatchKey=root +CopyBlocks=$FS_IMAGE diff --git a/repart.d/wrap-fs-in-ddi/20-verity.conf b/repart.d/wrap-fs-in-ddi/20-verity.conf new file mode 100644 index 00000000..1168c17a --- /dev/null +++ b/repart.d/wrap-fs-in-ddi/20-verity.conf @@ -0,0 +1,5 @@ +[Partition] +Type=root-verity +Verity=hash +VerityMatchKey=root +Minimize=best diff --git a/repart.d/wrap-fs-in-ddi/30-signature.conf b/repart.d/wrap-fs-in-ddi/30-signature.conf new file mode 100644 index 00000000..6a4fb956 --- /dev/null +++ b/repart.d/wrap-fs-in-ddi/30-signature.conf @@ -0,0 +1,4 @@ +[Partition] +Type=root-verity-sig +Verity=signature +VerityMatchKey=root diff --git a/setup_scripts_repo.sh b/setup_scripts_repo.sh new file mode 100755 index 00000000..32a61ea3 --- /dev/null +++ b/setup_scripts_repo.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# clone scripts with a custom branch and set up symlinks, +# where the run_sdk_container normally mounts stuff +git -C /tmp clone --depth=1 https://github.com/flatcar/scripts.git -b danzatt/sign-sysexts-nopatch +sudo rm -rf /mnt/host/source +sudo mkdir -p /tmp/scripts/__build__/images +sudo ln -s /tmp/scripts/sdk_container /mnt/host/source +sudo ln -s /tmp/scripts/__build__/images /mnt/host/source/src/build +sudo ln -s /tmp/scripts/ /mnt/host/source/src/scripts + +# reinstall packages changed in the branch +sudo emerge systemd azure-keyvault-pkcs11 +sudo touch /etc/pkcs11/pkcs11.conf +echo "module: /usr/lib64/pkcs11/azure-keyvault-pkcs11.so" | sudo tee /etc/pkcs11/modules/azure-keyvault-pkcs11.module diff --git a/sign_sysext.sh b/sign_sysext.sh new file mode 100755 index 00000000..ee5d646a --- /dev/null +++ b/sign_sysext.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +set -euo pipefail +shopt -s nullglob + +cd "$(dirname "$0")" + +function fail() { + echo "$@" + exit 1 +} + +function generate_repart_config() { + FS_IMAGE="$1" + + # Create temporary working directory + WORKDIR=$(mktemp -d) + FS_IMAGE="$FS_IMAGE" envsubst < repart.d/wrap-fs-in-ddi/10-root.conf > "$WORKDIR/10-root.conf" + cp repart.d/wrap-fs-in-ddi/20-verity.conf "$WORKDIR" + cp repart.d/wrap-fs-in-ddi/30-signature.conf "$WORKDIR" + + echo "$WORKDIR" +} + +function usage() { + echo "Usage: $0 []" + exit 1 +} + +function sign_sysext() { + FS_IMAGE="$1" + OUTPUT_IMAGE="$2" + KEY_SPEC="$3" + CERT_SPEC="$4" + + REPART_CONFIG_PATH=$(generate_repart_config "$FS_IMAGE") + trap 'rm -rf "$REPART_CONFIG_PATH"' EXIT + + PKCS11_ENV=() + AZURE_VARS=( + AZURE_CLIENT_ID + AZURE_TENANT_ID + AZURE_SUBSCRIPTION_ID + AZURE_KEYVAULT_URL + PKCS11_MODULE_PATH + AZURE_KEYVAULT_PKCS11_DEBUG + ) + for VARNAME in "${AZURE_VARS[@]}"; do + if [ -n "${!VARNAME+x}" ]; then + set +u + VAL="${!VARNAME}" + set -u + + PKCS11_ENV+=("${VARNAME}=${VAL}") + fi + done + + PRIVATE_KEY_SOURCE="file" + if [[ ${KEY_SPEC} == pkcs11:* ]]; then + PRIVATE_KEY_SOURCE="engine:pkcs11" + fi + + if [[ ${CERT_SPEC} == pkcs11:* ]]; then + CERT_CONTENT=$(env "${PKCS11_ENV[@]}" p11-kit export-object "$CERT_SPEC") + else + CERT_CONTENT=$(cat "$CERT_SPEC") + fi + env "${PKCS11_ENV[@]}" systemd-repart \ + --empty=create \ + --size=auto \ + --private-key-source=$PRIVATE_KEY_SOURCE \ + --private-key="$KEY_SPEC" \ + --certificate=<(echo "$CERT_CONTENT") \ + --definitions="$REPART_CONFIG_PATH" \ + "$OUTPUT_IMAGE" +} + +if [[ "$#" -lt 2 || "$#" -gt 3 ]]; then + usage +fi + +# strip the version after colon +SYSEXT_NAME="${1%:*}" + +KEY_SPEC="$2" +if [[ "$#" -eq 2 && ${KEY_SPEC} != pkcs11:* ]]; then + fail "You have to specify cert_path when not using PKCS11 token." +fi +CERT_NAME="${3:-${KEY_SPEC};type=cert}" + +for raw_image in "${SYSEXT_NAME}"*.raw; do + echo "Signing $raw_image with key $KEY_SPEC and cert $CERT_NAME" + signed_image_path="${raw_image%.raw}-signed-ddi.raw" + raw_image=$(readlink -f "$raw_image") + sign_sysext "$raw_image" "$signed_image_path" "$KEY_SPEC" "$CERT_NAME" +done + +for conf_file in "$SYSEXT_NAME"*.conf; do + echo "Modifying sysupdate config $conf_file" + sed -E 's/^(MatchPattern.*)\.raw/\1-signed-ddi.raw/g' > "${conf_file%.conf}-signed-ddi.conf" < "$conf_file" +done diff --git a/sign_sysext_keyvault.sh b/sign_sysext_keyvault.sh new file mode 100755 index 00000000..7bdcf3fb --- /dev/null +++ b/sign_sysext_keyvault.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +SYSEXT_NAME="$1" +KEYVAULT_CERT_NAME="$2" + +if [ -n "${AZURE_FEDERATED_TOKEN_FILE+x}" ]; then + echo "Obtaining OIDC token..." + token=$(curl -sSL \ + -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=api://AzureADTokenExchange" \ + | jq -r '.value') + echo "$token" > "$AZURE_FEDERATED_TOKEN_FILE" +fi + +./sign_sysext.sh "$SYSEXT_NAME" pkcs11:token="$KEYVAULT_CERT_NAME"