diff --git a/.github/workflows/build-publish-container-goreleaser.yml b/.github/workflows/build-publish-container-goreleaser.yml new file mode 100644 index 0000000..0e812f4 --- /dev/null +++ b/.github/workflows/build-publish-container-goreleaser.yml @@ -0,0 +1,104 @@ +# Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +# SPDX-License-Identifier: MIT +# +# Reusable workflow: builds and publishes a container image via GoReleaser, +# with multi-arch builds, build provenance attestation, and PR snapshot +# support. + +name: Build and publish container using goreleaser +on: + workflow_call: + inputs: + cgo_enabled: + type: number + required: false + default: 0 + is_pr_build: + type: boolean + required: false + default: false + pr_number: + type: number + required: false + default: ${{ github.event.number || 0 }} + registry_subject_name: + type: string + required: true +jobs: + container_build_publish: + runs-on: ubuntu-latest + steps: + - name: Set up latest stable Go + uses: actions/setup-go@v6.4.0 + with: + go-version: stable + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver-opts: | + image=moby/buildkit:master + network=host + - name: Docker Login + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Checkout + uses: actions/checkout@v6.0.2 + with: + fetch-tags: true + fetch-depth: 0 + # Set environment variables required by GoReleaser + - name: Set build environment variables + run: | + { + GIT_STATE='dirty' + if git diff-index --quiet HEAD -- >/dev/null 2>&1; then + GIT_STATE='clean' + fi + + echo "GIT_STATE=${GIT_STATE}" + echo "BUILD_HOST=$(hostname)" + echo "GO_VERSION=$(go version | awk '{print $3}')" + echo "BUILD_USER=$(whoami)" + echo "CGO_ENABLED=${{ inputs.cgo_enabled }}" + echo "IS_PR_BUILD=${{ inputs.is_pr_build }}" + } >> "${GITHUB_ENV}" + - name: Create Tag for PR + if: ${{ inputs.is_pr_build }} + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git tag -f -a pr-${{ inputs.pr_number }} -m "PR Release" + - name: Build/Push/Release container with goreleaser + uses: goreleaser/goreleaser-action@v6 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + version: '~> 2' + args: release --clean ${{ inputs.is_pr_build && '--skip=announce,validate,archive' || '' }} + id: goreleaser + - name: Process goreleaser output + id: process_goreleaser_output + run: | + node - <<'EOF' + const fs = require('fs'); + const artifacts = ${{ steps.goreleaser.outputs.artifacts }}; + const firstNonNullDigest = artifacts.find(artifact => artifact.extra && artifact.extra.Digest != null)?.extra.Digest; + console.log(firstNonNullDigest); + fs.writeFileSync('digest.txt', firstNonNullDigest); + EOF + echo "digest=$(cat digest.txt)" >> "${GITHUB_OUTPUT}" + - name: Attest Binaries + uses: actions/attest-build-provenance@v4.1.0 + with: + subject-path: dist/** + - name: generate build provenance + uses: actions/attest-build-provenance@v4.1.0 + with: + subject-name: ${{ inputs.registry_subject_name }} + subject-digest: ${{ steps.process_goreleaser_output.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/build-rpm-quadlet.yml b/.github/workflows/build-rpm-quadlet.yml new file mode 100644 index 0000000..4cf0614 --- /dev/null +++ b/.github/workflows/build-rpm-quadlet.yml @@ -0,0 +1,41 @@ +# Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +# SPDX-License-Identifier: MIT +# +# Reusable workflow: builds the caller repo's podman quadlet RPM and +# uploads it as an unsigned artifact for downstream signing. + +name: Build RPM for Podman Quadlet Files +run-name: Create Podman Quadlet RPM for ${{ github.ref }} +on: + workflow_call: + inputs: + artifact-name-unsigned-rpms: + description: 'Artifact-name for unsigned RPM artifacts' + default: 'rpms-unsigned' + type: string +jobs: + rpmbuild: + runs-on: ubuntu-latest + container: + image: rockylinux:9 + steps: + - name: Install build dependencies + run: dnf install -y -q git make rpm-build rpmlint tar gzip + + - name: Mark workspace as a safe git directory + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Checkout + uses: actions/checkout@v6.0.2 + with: + fetch-tags: true + fetch-depth: 0 + + - name: Build RPM + run: make rpm-build + + - name: Upload RPM + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.artifact-name-unsigned-rpms }} + path: '**/*.rpm' diff --git a/.github/workflows/gpg-sign-artifacts.yml b/.github/workflows/gpg-sign-artifacts.yml new file mode 100644 index 0000000..66822b2 --- /dev/null +++ b/.github/workflows/gpg-sign-artifacts.yml @@ -0,0 +1,114 @@ +# Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +# SPDX-License-Identifier: MIT +# +# Reusable workflow: intended as the common entry point for signing all +# release artifact types (RPMs today; other formats later) with a per-run +# ephemeral key certified through the repo's release key chain. +# +# Keep this file up to date and maintained as we add other package signing +# tasks (e.g. .deb, Arch packages). + +name: GPG Sign artifacts +run-name: Create signed artifacts for ${{ github.ref }} +on: + workflow_call: + inputs: + artifact-name-unsigned-rpms: + description: 'Artifact-name for unsigned RPM artifacts' + default: 'rpms-unsigned' + type: string + artifact-name-signed-rpms: + description: 'Name for the signed RPM composite artifact' + type: string + default: 'rpms-signed' + artifact-name-public-keys: + description: 'Name for the public key composite artifact' + type: string + default: 'public-keys' +jobs: + artifacts-sign: + runs-on: ubuntu-latest + container: + image: rockylinux:9 + steps: + + - name: Install build dependencies + run: | + dnf install -y -q git make rpm-build rpmlint tar gzip + + - name: Mark workspace as a safe git directory + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Checkout + uses: actions/checkout@v6.0.2 + with: + fetch-tags: true + fetch-depth: 0 + + - name: Check for repo key expiry + uses: OpenCHAMI/github-actions/actions/gpg-check-key-expiration@dev-rpm-quadlets + with: + repo-key-armored-b64: ${{ secrets.GPG_REPO_KEY_B64 }} + warn-days: '30' + + - name: Configure GPG release keys + id: gpg + uses: OpenCHAMI/github-actions/actions/gpg-configure-release-keys@dev-rpm-quadlets + with: + repo-cert-key-armored-b64: ${{ secrets.GPG_REPO_CERT_KEY_B64 }} + master-public-key-asc: ${{ secrets.MASTER_PUBLIC_ASC }} + master-fpr: ${{ secrets.MASTER_FPR }} + name: '${{ github.repository }} Release' + comment: 'ephemeral key for ${{ github.ref_name }}' + email: 'release@packages.openchami.org' + expire-days: '1' + + - name: Download RPM artifacts requested for release + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.artifact-name-unsigned-rpms }} + path: dist + + - name: Sign rpms + id: rpmsign + uses: OpenCHAMI/github-actions/actions/gpg-sign-rpm@dev-rpm-quadlets + with: + resign: true + gnupg-home: ${{ steps.gpg.outputs.gnupg-home }} + gpg-fingerprint: ${{ steps.gpg.outputs.ephemeral-fingerprint }} + + - name: Verify trust chain + uses: OpenCHAMI/github-actions/actions/gpg-verify-trust-chain@dev-rpm-quadlets + with: + master-public-key: ${{ secrets.MASTER_PUBLIC_ASC }} + require-master: false + repo-public-key-file: ${{ steps.gpg.outputs.repo-cert-public-key-file }} + ephemeral-public-key-file: ${{ steps.gpg.outputs.ephemeral-public-key-file }} + rpm-dir: . + + - name: rpmlint + run: rpmlint "$(find . -name '*.rpm')" || true + + - name: Upload signed RPMs + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.artifact-name-signed-rpms }} + path: '**/*.rpm' + overwrite: true + + - name: Upload public signing keys + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.artifact-name-public-keys }} + path: '**/*.pub.asc' + overwrite: true + + - name: Cleanup GNUPGHOME + if: always() + env: + GNUPGHOME_PATH: ${{ steps.gpg.outputs.gnupg-home }} + run: | + set -euo pipefail + [ -n "$GNUPGHOME_PATH" ] && [ -d "$GNUPGHOME_PATH" ] || exit 0 + find "$GNUPGHOME_PATH" -type f -exec shred -u {} + 2>/dev/null || true + rm -rf "$GNUPGHOME_PATH" diff --git a/.github/workflows/release-signed-artifacts.yml b/.github/workflows/release-signed-artifacts.yml new file mode 100644 index 0000000..526f895 --- /dev/null +++ b/.github/workflows/release-signed-artifacts.yml @@ -0,0 +1,95 @@ +# Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +# SPDX-License-Identifier: MIT +# +# Reusable workflow: publishes a GitHub Release for a tag, attaching signed +# RPMs and public keys, with trust-chain verification instructions in the +# release body. +name: Release signed artifacts +run-name: Generate release with signed artifacts for ${{ github.ref }} +permissions: + contents: write +on: + workflow_call: + inputs: + artifact-name-signed-rpms: + description: 'Name for the signed RPM composite artifact' + type: string + default: 'rpms-signed' + required: false + artifact-name-public-keys: + description: 'Name for the public key composite artifact' + type: string + default: 'public-keys' + required: false +jobs: + artifacts-release: + runs-on: ubuntu-latest + container: + image: rockylinux:9 + steps: + - name: Install dependencies + run: | + dnf install -y -q git tar gzip zip + - name: Mark workspace as a safe git directory + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Checkout + uses: actions/checkout@v6.0.2 + with: + fetch-tags: true + fetch-depth: 0 + - name: Download signed RPM artifacts + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.artifact-name-signed-rpms }} + path: dist/rpms + - name: Download public key artifacts + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.artifact-name-public-keys }} + path: dist/keys + - name: Create GitHub Release + uses: softprops/action-gh-release@v3.0.2 + with: + tag_name: ${{ github.ref_name }} + name: Release ${{ github.ref_name }} + fail_on_unmatched_files: true + files: | + dist/rpms/**/*.rpm + dist/keys/**/*.asc + body: |- + ## GPG Signature Verification + + Each RPM in this release is signed with a short-lived ephemeral key that + is certified by the repository signing key, which is itself certified by + the OpenCHAMI offline master key. + + ### Trust chain + + ``` + offline master key + └─[certifies]─> repo key + └─[certifies]─> ephemeral key (${{ github.ref_name }}) + └─[signs]─> RPM files + ``` + + ### How to verify + + 1. Download `repo-cert.pub.asc` and `ephemeral.pub.asc` from this release. + 2. Import both keys: + ```bash + gpg --import repo-public.asc ephemeral-public.asc + ``` + 3. Verify each RPM: + ```bash + rpm --checksig *.rpm + ``` + 4. For full chain verification (requires the master public key): + ```bash + curl -LO \ + https://raw.githubusercontent.com/OpenCHAMI/gpg-signing-manager/main/scripts/verify-chain.sh + bash verify-chain.sh \ + --master master.pub.asc \ + --repo repo-cert.pub.asc \ + --ephemeral ephemeral.pub.asc \ + --rpm *.rpm + ``` diff --git a/.github/workflows/validate-rpm-quadlet.yml b/.github/workflows/validate-rpm-quadlet.yml new file mode 100644 index 0000000..962345a --- /dev/null +++ b/.github/workflows/validate-rpm-quadlet.yml @@ -0,0 +1,66 @@ +# Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +# SPDX-License-Identifier: MIT +# +# Reusable workflow: validates a signed quadlet RPM's installed file list +# against the set of files the caller expects it to ship. + +name: Validate Podman Quadlet RPM +run-name: Validate Podman Quadlet RPM for ${{ github.ref }} +on: + workflow_call: + inputs: + artifact-name-signed-rpms: + description: 'Artifact-name for signed RPM artifacts' + default: 'rpms-signed' + type: string + expected-files: + description: 'a list of files the RPM is expected to install (newline delimited or multiline yaml string)' + required: true + type: string + +jobs: + rpmvalidate: + runs-on: ubuntu-latest + container: + image: rockylinux:9 + steps: + + - name: Install build dependencies + run: dnf install -y -q git rpmlint tar gzip diffutils + + - name: Mark workspace as a safe git directory + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - uses: actions/checkout@v6.0.2 + with: + fetch-tags: true + fetch-depth: 0 + + - name: Download signed RPM artifacts + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.artifact-name-signed-rpms }} + path: dist/rpms + + - name: Find Quadlet RPM + run: | + set -euo pipefail + + quadlet_rpm=$(find . -type f -iname "*.rpm" | head -n 1) + if [ -z "${quadlet_rpm}" ]; then + echo "could not locate quadlet rpm file" + exit 1 + fi + + echo "using QUADLET_RPM_PATH=${quadlet_rpm}" + echo "QUADLET_RPM_PATH=${quadlet_rpm}" >> "$GITHUB_ENV" + + - name: Verify installed file list is exactly what's expected + shell: bash + env: + EXPECTED_FILES: ${{ inputs.expected-files }} + run: | + set -euo pipefail + rpm -qlp "${QUADLET_RPM_PATH}" | sort | grep -v '^$' > /tmp/actual-files.txt + printf '%s\n' "${EXPECTED_FILES}" | sort | grep -v '^$' > /tmp/expected-files.txt + diff /tmp/expected-files.txt /tmp/actual-files.txt diff --git a/README.md b/README.md index a398d65..ee82ca8 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,18 @@ Reusable GitHub Actions for CI/CD. ## Structure -- `actions/gpg-ephemeral-key`: Ephemeral key generation for RPM/GPG signing -- `actions/sign-rpm`: RPM signing with ephemeral keys +- `actions/gpg-ephemeral-key`: **Deprecated** - use `actions/gpg-configure-release-keys` instead +- `actions/gpg-configure-release-keys`: Generates and certifies a per-run ephemeral GPG key through the repo's release key chain +- `actions/gpg-sign-rpm`: RPM signing with ephemeral keys +- `actions/gpg-check-key-expiration`: Fails CI if a signing key is expired or expiring soon +- `actions/gpg-verify-trust-chain`: Verifies the master/repo-cert/ephemeral trust chain and optionally checksigs RPMs - `.github/workflows/go-build-release.yml`: Reusable workflow for GoReleaser builds - `.github/workflows/docker-build-release.yml`: Reusable workflow for multi-arch container image builds +- `.github/workflows/build-publish-container-goreleaser.yml`: Builds and publishes a container image via GoReleaser +- `.github/workflows/build-rpm-quadlet.yml`: Builds a caller repo's podman quadlet RPM +- `.github/workflows/gpg-sign-artifacts.yml`: Signs unsigned RPM artifacts with a per-run ephemeral key +- `.github/workflows/validate-rpm-quadlet.yml`: Validates a signed quadlet RPM's installed file list +- `.github/workflows/release-signed-artifacts.yml`: Publishes a GitHub Release with signed RPMs and public keys - `.github/workflows/lint-workflows.yml`: Reusable workflow that lints workflow files (actionlint + zizmor) - `.github/workflows/govulncheck.yml`: Reusable workflow that scans Go modules for known CVEs - `.github/workflows/dependency-review.yml`: Reusable workflow that gates PRs introducing CVE-flagged deps @@ -24,8 +32,8 @@ Use major version tags for stability: ```yaml # For actions -- uses: OpenCHAMI/github-actions/actions/gpg-ephemeral-key@v1 -- uses: OpenCHAMI/github-actions/actions/sign-rpm@v1 +- uses: OpenCHAMI/github-actions/actions/gpg-configure-release-keys@v1 +- uses: OpenCHAMI/github-actions/actions/gpg-sign-rpm@v1 # For reusable workflows jobs: @@ -33,9 +41,9 @@ jobs: uses: OpenCHAMI/github-actions/.github/workflows/go-build-release.yml@v3.3 ``` -Pin a commit SHA internally for maximum supply‑chain safety if desired. +Pin a commit SHA internally for maximum supply-chain safety if desired. -## Actions and Workflows Overview +## Workflows ### go-build-release (Reusable Workflow) Standardized GoReleaser workflow for building and releasing Go applications with: @@ -75,8 +83,8 @@ See the [workflow](.github/workflows/go-build-release.yml) for additional input ### lint-workflows (Reusable Workflow) Lints the caller repo's GitHub Actions workflow files. -- **actionlint** — syntax validation, shellcheck on `run:` steps, deprecated-action checks. -- **zizmor** — security-focused static analysis (script injection, excessive permissions, unpinned third-party actions). Uploads SARIF findings to the caller's GitHub Advanced Security tab. +- **actionlint** - syntax validation, shellcheck on `run:` steps, deprecated-action checks. +- **zizmor** - security-focused static analysis (script injection, excessive permissions, unpinned third-party actions). Uploads SARIF findings to the caller's GitHub Advanced Security tab. **Usage:** ```yaml @@ -145,68 +153,176 @@ jobs: image-ref: ghcr.io/openchami/foo:${{ github.sha }} ``` -### gpg-ephemeral-key -Generates a short‑lived RSA key (default 3072‑bit, 1 day) using an isolated `GNUPGHOME`, signs it with a repo‑scoped subkey you provide, and outputs: -- `ephemeral-fingerprint` -- `ephemeral-public-key` (base64 of armored) -- `gnupg-home` (path for downstream steps) +### build-publish-container-goreleaser (Reusable Workflow) +Builds and publishes a container image via GoReleaser, with multi-arch builds, build provenance attestation, and PR snapshot support. -### sign-rpm -Signs an RPM using a provided GPG fingerprint (works with the ephemeral key output) and exposes signature verification output. +**Usage:** +```yaml +jobs: + build: + uses: OpenCHAMI/github-actions/.github/workflows/build-publish-container-goreleaser.yml@v3.5 + with: + registry_subject_name: ghcr.io/openchami/foo +``` + +### build-rpm-quadlet (Reusable Workflow) +Builds the caller repo's podman quadlet RPM and uploads it as an unsigned artifact for downstream signing. + +**Usage:** +```yaml +jobs: + build: + uses: OpenCHAMI/github-actions/.github/workflows/build-rpm-quadlet.yml@v3.5 +``` + +### gpg-sign-artifacts (Reusable Workflow) +Signs unsigned RPM artifacts with a per-run ephemeral key certified through the repo's release key chain, verifies the chain, and uploads the signed RPMs and public keys. Intended as the common entry point for signing all release artifact types (RPMs today; other formats later). + +**Usage:** +```yaml +jobs: + sign: + uses: OpenCHAMI/github-actions/.github/workflows/gpg-sign-artifacts.yml@v3.5 + secrets: inherit +``` + +### validate-rpm-quadlet (Reusable Workflow) +Validates a signed quadlet RPM's installed file list against the set of files the caller expects it to ship. + +**Usage:** +```yaml +jobs: + validate: + uses: OpenCHAMI/github-actions/.github/workflows/validate-rpm-quadlet.yml@v3.5 + with: + expected-files: | + /etc/containers/systemd/foo.container +``` + +### release-signed-artifacts (Reusable Workflow) +Publishes a GitHub Release for a tag, attaching signed RPMs and public keys, with trust-chain verification instructions in the release body. + +**Usage:** +```yaml +jobs: + release: + uses: OpenCHAMI/github-actions/.github/workflows/release-signed-artifacts.yml@v3.5 +``` + +## Actions + +### gpg-ephemeral-key (Deprecated - use gpg-configure-release-keys) +Generates a short-lived RSA key and signs it with a repo-scoped subkey. See the [action README](actions/gpg-ephemeral-key/README.md). + +### gpg-configure-release-keys +Generates a per-run ephemeral GPG key, certified through the repo's release key chain (master certifies a repo cert key, which certifies the ephemeral key). See the [action README](actions/gpg-configure-release-keys/README.md). + +### gpg-sign-rpm +Signs an RPM using a provided GPG fingerprint (works with the ephemeral key output from `gpg-configure-release-keys`) and exposes signature verification output. See the [action README](actions/gpg-sign-rpm/README.md). + +### gpg-check-key-expiration +Fails CI if the provided signing key is expired or expiring within a threshold. See the [action README](actions/gpg-check-key-expiration/README.md). + +### gpg-verify-trust-chain +Verifies the master/repo-cert/ephemeral trust chain and optionally checksigs RPMs. See the [action README](actions/gpg-verify-trust-chain/README.md). ## Security Model -Trust chain: `Ephemeral Key ← Repo Subkey ← Offline Master Key`. +Trust chain: `Ephemeral Key <- Repo Cert Key <- Offline Master Key`. Design principles: - Ephemeral keys reduce exposure window. -- Repo subkeys are easily revocable & rotated. +- Repo cert keys are easily revocable & rotated. - Isolated `GNUPGHOME` avoids polluting runner defaults. -- Optional cleanup to remove secrets post‑sign. +- GNUPGHOME cleanup is the calling workflow's responsibility, not optional. Key expiration limits future signing only; existing signatures remain valid if the trust chain remains intact. ## Example Workflow (Combined) +Adapted from metadata-service's PR build workflow, chaining container build, RPM build, signing, and validation: + ```yaml +name: Build each PR for testing and validation +on: + pull_request: + branches: + - main + types: [opened, synchronize, reopened, edited] + workflow_dispatch: + inputs: + pr_number: + description: 'PR Number to build (optional, for manual PR builds)' + required: false + type: string + +permissions: write-all # Necessary for the generate-build-provenance action with containers jobs: - build-and-sign: + + config: runs-on: ubuntu-latest + outputs: + rpm-unsigned: ${{ steps.names.outputs.rpm-unsigned }} + rpm-signed: ${{ steps.names.outputs.rpm-signed }} + keys-public: ${{ steps.names.outputs.keys-public }} steps: - - uses: actions/checkout@v4 - - name: Generate ephemeral key - id: gpg - uses: OpenCHAMI/github-actions/actions/gpg-ephemeral-key@v1 - with: - subkey-armored: ${{ secrets.GPG_SUBKEY_B64 }} - comment: build:${{ github.run_id }} - cleanup: false # keep for subsequent signing - - name: Build RPM - run: ./scripts/build-rpm.sh - - name: Sign RPM - id: sign - uses: OpenCHAMI/github-actions/actions/sign-rpm@v1 - with: - rpm-path: dist/my.rpm - gpg-fingerprint: ${{ steps.gpg.outputs.ephemeral-fingerprint }} - gnupg-home: ${{ steps.gpg.outputs.gnupg-home }} - - name: (Optional) Cleanup GNUPGHOME - if: always() - run: rm -rf "${{ steps.gpg.outputs.gnupg-home }}" + - id: names + run: | + { + echo "rpm-unsigned=rpms-unsigned" + echo "rpm-signed=rpms-signed" + echo "keys-public=public-keys" + } >> "$GITHUB_OUTPUT" + + build: + uses: OpenCHAMI/github-actions/.github/workflows/build-publish-container-goreleaser.yml@v3.5 + secrets: inherit + with: + cgo_enabled: 0 + registry_subject_name: ghcr.io/openchami/metadata-service + is_pr_build: true + pr_number: ${{ inputs.pr_number || github.event.pull_request.number || 0 }} + + rpmbuild: + needs: [config, build] + uses: OpenCHAMI/github-actions/.github/workflows/build-rpm-quadlet.yml@v3.5 + secrets: inherit + with: + artifact-name-unsigned-rpms: ${{ needs.config.outputs.rpm-unsigned }} + + rpmsign: + needs: [config, rpmbuild] + uses: OpenCHAMI/github-actions/.github/workflows/gpg-sign-artifacts.yml@v3.5 + secrets: inherit + with: + artifact-name-unsigned-rpms: ${{ needs.config.outputs.rpm-unsigned }} + artifact-name-signed-rpms: ${{ needs.config.outputs.rpm-signed }} + artifact-name-public-keys: ${{ needs.config.outputs.keys-public }} + + rpmvalidate: + needs: [config, rpmsign] + uses: OpenCHAMI/github-actions/.github/workflows/validate-rpm-quadlet.yml@v3.5 + secrets: inherit + with: + artifact-name-signed-rpms: ${{ needs.config.outputs.rpm-signed }} + expected-files: | + /usr/share/containers/systemd/metadata-data.volume + /usr/share/containers/systemd/metadata-service.container + /usr/share/licenses/metadata-service + /usr/share/licenses/metadata-service/MIT.txt ``` ## Continuous Integration -A future CI workflow will: -- Lint action metadata (actionlint) -- Perform a matrix test invoking each action -- Validate RPM signing round‑trip +- Workflow files are linted via `lint-workflows.yml` (actionlint + zizmor). +- RPM/quadlet output is validated via `validate-rpm-quadlet.yml`. +- TODO: matrix test invoking each action directly. ## Rotation & Revocation -1. Revoke and replace repo subkeys periodically. -2. Update `GPG_SUBKEY_B64` secret. -3. Tag a new release if behavior changes. +Repo cert key and master key rotation/revocation procedures live in +[gpg-signing-manager](https://github.com/OpenCHAMI/gpg-signing-manager). Tag +a new release here if this repo's actions or workflows change as a result. ## Contributing diff --git a/actions/gpg-check-key-expiration/README.md b/actions/gpg-check-key-expiration/README.md new file mode 100644 index 0000000..0d36c08 --- /dev/null +++ b/actions/gpg-check-key-expiration/README.md @@ -0,0 +1,14 @@ + + +# gpg-check-key-expiration + +Fails the job if a provided secret key is expired or expires within +`warn-days`. Imports into an isolated `GNUPGHOME` that's shredded on exit; +never touches the runner's default keyring. + +Expiry check itself is fetched at runtime, pinned to a commit SHA, from +[gpg-signing-manager](https://github.com/OpenCHAMI/gpg-signing-manager)'s +`check-key-expiry.sh`. diff --git a/actions/gpg-check-key-expiration/action.yml b/actions/gpg-check-key-expiration/action.yml new file mode 100644 index 0000000..3522307 --- /dev/null +++ b/actions/gpg-check-key-expiration/action.yml @@ -0,0 +1,95 @@ +# SPDX-FileCopyrightText: 2026 OpenCHAMI a Series of LF Projects, LLC +# SPDX-License-Identifier: MIT + +name: 'Check key expiration' +author: 'OpenCHAMI' +branding: { icon: 'clock', color: 'orange' } +description: >- + Fails if the provided signing secret material is expired or expiring within + warn-days. Imports into an isolated, shredded GNUPGHOME; never touches the + runner default keyring. + +inputs: + repo-key-armored-b64: + description: >- + Base64-encoded ASCII-armored secret key export to check. b64 wrapping + exists only to survive newline mangling when provisioning the secret + (gh secret set); it is decoded exactly once here. + required: true + secret-name: + description: 'GitHub secret name expected to hold repo-key-armored-b64 (used in error messages only)' + required: false + default: 'GPG_REPO_KEY_B64' + legacy-secret-name: + description: 'Legacy secret name mentioned in migration hints (error messages only)' + required: false + default: 'GPG_SUBKEY_B64' + warn-days: + description: 'Fail if any key expires within this many days' + required: false + default: '30' + +runs: + using: composite + steps: + - name: Install GnuPG + shell: bash + run: | + set -euo pipefail + command -v gpg >/dev/null 2>&1 && exit 0 + SUDO=''; command -v sudo >/dev/null 2>&1 && SUDO=sudo + if command -v apt-get >/dev/null 2>&1; then + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq --no-install-recommends gnupg + elif command -v dnf >/dev/null 2>&1; then + $SUDO dnf install -y -q gnupg2 + else + echo '::error::Unsupported package manager: need apt-get or dnf' + exit 1 + fi + + - name: Check signing key expiry + shell: bash + env: + REPO_KEY_ARMORED_B64: ${{ inputs.repo-key-armored-b64 }} + SECRET_NAME: ${{ inputs.secret-name }} + LEGACY_SECRET_NAME: ${{ inputs.legacy-secret-name }} + WARN_DAYS: ${{ inputs.warn-days }} + run: | + set -euo pipefail + if [[ -z "${REPO_KEY_ARMORED_B64//[[:space:]]/}" ]]; then + echo "::error::Input 'repo-key-armored-b64' is empty. No key material was provided." + echo "::error::Expected repo secret '${SECRET_NAME}' to contain a base64 armored secret signing key." + echo "::notice::Set secret: gh secret set ${SECRET_NAME} --repo ${GITHUB_REPOSITORY} < -secret-subkeys.b64" + echo "::notice::If you still use '${LEGACY_SECRET_NAME}', rename it or pass it explicitly." + exit 1 + fi + + export GNUPGHOME="$(mktemp -d)" + chmod 700 "$GNUPGHOME" + trap 'find "$GNUPGHOME" -type f -exec shred -u {} + 2>/dev/null || true; rm -rf "$GNUPGHOME"' EXIT + + if ! printf '%s' "$REPO_KEY_ARMORED_B64" | base64 -d \ + > "$GNUPGHOME/signing-key.asc" 2>"$GNUPGHOME/decode.log"; then + echo "::error::Failed to decode repo-key-armored-b64 as base64." + sed 's/^/::error::base64: /' "$GNUPGHOME/decode.log" + echo "::notice::Re-export the secret payload and store it as ${SECRET_NAME}." + exit 1 + fi + if ! gpg --batch --import "$GNUPGHOME/signing-key.asc" >/dev/null 2>"$GNUPGHOME/import.log"; then + echo "::error::Failed to import decoded key material into a temporary keyring." + sed 's/^/::error::gpg: /' "$GNUPGHOME/import.log" + echo "::notice::Ensure ${SECRET_NAME} contains a SECRET key export (not a public key)." + exit 1 + fi + + workdir=$(mktemp -d) + trap 'rm -rf "$workdir"' EXIT + script="$workdir/check-key-expiry.sh" + curl -fsSL --retry 3 -o "$script" \ + "https://raw.githubusercontent.com/OpenCHAMI/gpg-signing-manager/d4366a45c94be50cbb7f90d229c10f395b6d10d8/scripts/check-key-expiry.sh" + + bash "$script" \ + --gnupghome "$GNUPGHOME" \ + --threshold-days "$WARN_DAYS" \ + --github-annotations diff --git a/actions/gpg-configure-release-keys/README.md b/actions/gpg-configure-release-keys/README.md new file mode 100644 index 0000000..c35f50d --- /dev/null +++ b/actions/gpg-configure-release-keys/README.md @@ -0,0 +1,31 @@ + + +# gpg-configure-release-keys + +Creates a per-run ephemeral GPG key and certifies it with a repo-scoped +certification key, for use by downstream signing steps. + +## Key Lifecycle + +- **Master key**: public-only here — only `master-public-key-asc`/`master-fpr` + are consumed, to pass through for trust-chain export. Never imported as a + secret in this action. +- **Repo cert key**: imported from `repo-cert-key-armored-b64` (secret), used + only to certify the ephemeral key, then deleted with + `--delete-secret-keys` before this action returns. +- **Ephemeral key**: intentionally left in the exported `GNUPGHOME` — the + caller's next step (e.g. `gpg-sign-rpm`) needs the secret key. The calling + workflow, not this action, is responsible for shredding that `GNUPGHOME` + once signing is done. + +See [gpg-signing-manager](https://github.com/OpenCHAMI/gpg-signing-manager) +for key generation/rotation details. + +## Secrets vs. variables + +`MASTER_PUBLIC_ASC`/`MASTER_FPR` are public (a public key + fingerprint) — +stored as secrets here for consistency with the other key inputs, not +because they need confidentiality. Fine to move to repo/org variables. diff --git a/actions/gpg-configure-release-keys/action.yml b/actions/gpg-configure-release-keys/action.yml new file mode 100644 index 0000000..51b1f02 --- /dev/null +++ b/actions/gpg-configure-release-keys/action.yml @@ -0,0 +1,215 @@ +# SPDX-FileCopyrightText: 2025 OpenCHAMI a Series of LF Projects, LLC +# SPDX-License-Identifier: MIT + +name: 'configure gpg release keys for downstream tasks' +author: 'OpenCHAMI' +branding: + icon: 'lock' + color: 'purple' +description: 'Creates an ephemeral GPG key per build and certifies it with a repo-scoped certification key' + +inputs: + repo-cert-key-armored-b64: + required: true + description: 'Base64-encoded ASCII-armored repo certification secret key used to certify the ephemeral key' + master-public-key-asc: + required: true + description: 'ASCII-armored public key for the offline master key that certified the repo certification key' + master-fpr: + required: true + description: 'Full fingerprint of the offline master key' + name: + description: 'Name (real) for the ephemeral key' + default: 'Ephemeral Key' + comment: + description: 'Additional comment / metadata (will have a random suffix appended)' + default: '' + email: + description: 'Email for the ephemeral key' + default: 'ci@build.local' + key-length: + description: 'RSA key length' + default: '4096' + expire-days: + description: 'Expiration in days for the ephemeral key' + default: '1' + +outputs: + # keep private cryptographical info on disk; avoid leaking it out to logs + gnupg-home: + description: 'Path to isolated GNUPGHOME for subsequent actions' + value: ${{ steps.setup.outputs.gnupghome }} + + # directly export public key cryptography data to simplify downstream ops + ephemeral-fingerprint: + description: 'Fingerprint of the generated ephemeral key' + value: ${{ steps.export.outputs.ephemeral-fingerprint }} + ephemeral-public-key-b64: + description: 'Base64 of ASCII-armored ephemeral public key' + value: ${{ steps.export.outputs.ephemeral-public-b64 }} + ephemeral-public-key-file: + description: 'Path to ASCII-armored ephemeral public key file' + value: ${{ steps.export.outputs.ephemeral-public-file }} + repo-cert-fingerprint: + description: 'Fingerprint of the repo-cert key' + value: ${{ steps.export.outputs.repo-cert-fingerprint }} + repo-cert-public-key-b64: + description: 'Base64 of ASCII-armored repo certification public key (primary only)' + value: ${{ steps.export.outputs.repo-cert-public-b64 }} + repo-cert-public-key-file: + description: 'Path to ASCII-armored repo-cert public key file' + value: ${{ steps.export.outputs.repo-cert-public-file }} + master-fingerprint: + description: 'Fingerprint of the master key' + value: ${{ steps.export.outputs.master-fingerprint }} + master-public-key-b64: + description: 'Base64 of ASCII-armored master public key (primary only)' + value: ${{ steps.export.outputs.master-public-b64 }} + master-public-key-file: + description: 'Path to ASCII-armored master public key file' + value: ${{ steps.export.outputs.master-public-file }} + +runs: + using: "composite" + steps: + - id: setup + shell: bash + run: | + set -euo pipefail + + GNUPGHOME="$(mktemp -d)" + chmod 700 "$GNUPGHOME" + echo "GNUPGHOME=$GNUPGHOME" >> "$GITHUB_ENV" + echo "gnupghome=$GNUPGHOME" >> "$GITHUB_OUTPUT" + + command -v gpg >/dev/null 2>&1 && exit 0 + SUDO=''; command -v sudo >/dev/null 2>&1 && SUDO=sudo + if command -v apt-get >/dev/null 2>&1; then + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq --no-install-recommends gnupg + elif command -v dnf >/dev/null 2>&1; then + $SUDO dnf install -y -q gnupg2 + else + echo '::error::Unsupported package manager: need apt-get or dnf' + exit 1 + fi + + - id: import + shell: bash + run: | + set -euo pipefail + echo "Importing repo certification key..." + + # Decode repo certification key + decoded=$(echo "${{ inputs.repo-cert-key-armored-b64 }}" | base64 -d 2>/dev/null || true) + if [[ -z "$decoded" ]]; then + echo "ERROR: repo-cert-key-armored-b64 is invalid or empty!" >&2 + exit 1 + fi + + # Import key + gpg --batch --import <(echo "$decoded") || { + echo "ERROR: Failed to import repo certification key" >&2 + exit 1 + } + + # List keys for debug + echo "::group::GPG secret keys" + gpg --list-secret-keys + echo "::endgroup::" + + # Get repo_cert fingerprint + repo_cert_fpr=$(gpg --batch --with-colons --list-secret-keys | awk -F: '/^fpr:/ {print $10; exit}') + if [[ -z "$repo_cert_fpr" ]]; then + echo "ERROR: No secret key found after import" >&2 + exit 1 + fi + + echo "Using REPO_CERT_FPR=$repo_cert_fpr" + echo "REPO_CERT_FPR=$repo_cert_fpr" >> "$GITHUB_ENV" + + - id: generate + shell: bash + run: | + set -euo pipefail + # Sanitize user inputs to avoid config injection + safe_name=$(printf '%s' "${{ inputs.name }}" | tr -cd '[:alnum:] ._@-') + safe_comment=$(printf '%s' "${{ inputs.comment }}" | tr -cd '[:alnum:] ._@:-') + safe_email=$(printf '%s' "${{ inputs.email }}" | tr -cd '[:alnum:]@._-') + marker=$(openssl rand -hex 6 2>/dev/null || date +%s) + safe_comment="$safe_comment build-${GITHUB_RUN_ID:-0}-$marker" + echo "MARKER=$marker" >> "$GITHUB_ENV" + expire_days="${{ inputs.expire-days }}" + key_length="${{ inputs.key-length }}" + + # No passphrase: key is ephemeral (short expiry, per expire-days + # input), lives only in this run's isolated GNUPGHOME, and is + # shredded on teardown. + printf "%%no-protection\n" > keygen.conf + printf "Key-Type: RSA\n" >> keygen.conf + printf "Key-Length: %s\n" "$key_length" >> keygen.conf + printf "Name-Real: %s\n" "$safe_name" >> keygen.conf + printf "Name-Comment: %s\n" "$safe_comment" >> keygen.conf + printf "Name-Email: %s\n" "$safe_email" >> keygen.conf + printf "Expire-Date: %sd\n" "$expire_days" >> keygen.conf + printf "Key-Usage: sign\n" >> keygen.conf + printf "%%commit\n" >> keygen.conf + + gpg --batch --gen-key keygen.conf + shred -u keygen.conf || rm -f keygen.conf + + - id: fpr + shell: bash + run: | + set -euo pipefail + echo "Available keys for debug:" + gpg --list-keys + + ephemeral_fpr=$(gpg --with-colons --list-keys | awk -F: -v m="${MARKER:-}" ' + /^fpr:/ { fpr=$10 } + /^uid:/ { + if (index($10, m) > 0) { + print fpr + exit + } + }') + + if [ -z "$ephemeral_fpr" ]; then + echo "Failed to locate ephemeral key fingerprint" >&2 + exit 1 + fi + echo "EPHEMERAL_FPR=$ephemeral_fpr" >> "$GITHUB_ENV" + + - id: sign-ephemeral + shell: bash + run: | + set -euo pipefail + gpg --batch --yes --quick-sign-key --local-user "$REPO_CERT_FPR" "$EPHEMERAL_FPR" + gpg --batch --yes --delete-secret-keys "$REPO_CERT_FPR" + + - id: export + shell: bash + env: + MASTER_PUBLIC_KEY_ASC: ${{ inputs.master-public-key-asc }} + run: | + set -euo pipefail + + gpg --armor --export "$EPHEMERAL_FPR" > "ephemeral.pub.asc" + gpg --armor --export "${REPO_CERT_FPR}!" > "repo-cert.pub.asc" + printf '%s\n' "$MASTER_PUBLIC_KEY_ASC" > "master.pub.asc" + + for k in ephemeral repo-cert master; do + test -s "$k.pub.asc" || { echo "ERROR: empty export for $k" >&2; exit 1; } + done + + { + echo "ephemeral-fingerprint=${EPHEMERAL_FPR}" + echo "ephemeral-public-b64=$(base64 -w0 < "ephemeral.pub.asc")" + echo "ephemeral-public-file=ephemeral.pub.asc" + echo "repo-cert-fingerprint=${REPO_CERT_FPR}" + echo "repo-cert-public-b64=$(base64 -w0 < "repo-cert.pub.asc")" + echo "repo-cert-public-file=repo-cert.pub.asc" + echo "master-fingerprint=${{ inputs.master-fpr }}" + echo "master-public-b64=$(base64 -w0 < "master.pub.asc")" + echo "master-public-file=master.pub.asc" + } >> "$GITHUB_OUTPUT" diff --git a/actions/gpg-ephemeral-key/README.md b/actions/gpg-ephemeral-key/README.md index 69dc2a5..aa4e146 100644 --- a/actions/gpg-ephemeral-key/README.md +++ b/actions/gpg-ephemeral-key/README.md @@ -5,6 +5,10 @@ SPDX-License-Identifier: MIT # 🛡️ GPG Ephemeral Key Generator +> [!WARNING] +> +> Deprecated: use [`gpg-configure-release-keys`](../gpg-configure-release-keys) instead. + This GitHub composite action generates a new ephemeral GPG key on every build, signs it using a repo‑scoped subkey, and exports the fingerprint and public key. It’s designed for use in CI pipelines where artifacts need secure signing without long‑lived keys in GitHub Actions. --- diff --git a/actions/gpg-ephemeral-key/action.yml b/actions/gpg-ephemeral-key/action.yml index 87b3507..3d6ce89 100644 --- a/actions/gpg-ephemeral-key/action.yml +++ b/actions/gpg-ephemeral-key/action.yml @@ -1,12 +1,12 @@ # SPDX-FileCopyrightText: 2025 OpenCHAMI a Series of LF Projects, LLC # SPDX-License-Identifier: MIT -name: 'Generate and Sign Ephemeral GPG Key' +name: '[DEPRECATED] Generate and Sign Ephemeral GPG Key' author: 'OpenCHAMI' branding: icon: 'lock' color: 'purple' -description: 'Creates an ephemeral GPG key per build and signs it with a repo-scoped subkey' +description: 'Deprecated: use gpg-configure-release-keys instead. Creates an ephemeral GPG key per build and signs it with a repo-scoped subkey' inputs: subkey-armored: @@ -45,6 +45,11 @@ outputs: runs: using: "composite" steps: + - id: deprecation-warning + shell: bash + run: | + echo "::warning::gpg-ephemeral-key is deprecated; migrate to gpg-configure-release-keys." + - id: setup shell: bash run: | diff --git a/actions/gpg-sign-rpm/README.md b/actions/gpg-sign-rpm/README.md new file mode 100644 index 0000000..3b61fdb --- /dev/null +++ b/actions/gpg-sign-rpm/README.md @@ -0,0 +1,59 @@ + + +# RPM Signing Action + +Signs an RPM file using a GPG key fingerprint (typically an ephemeral key produced by the `gpg-configure-release-keys` action). Designed to pair with ephemeral, short-lived keys to reduce long-term key exposure in CI. + +## How It Works + +1. Assumes the GPG key (secret) is already present in the GNUPGHOME (e.g. from `gpg-configure-release-keys`). +2. Configures RPM macros to use GPG (sets `%_signature`, `%_gpg_name`, `%__gpg`, and SHA-256 digest). +3. Signs the RPM with `rpmsign --addsign` (or re-signs if `resign: true`). +4. Verifies the signature and exposes the verification output. + +Notes: +- The action supports Ubuntu and Fedora runners (installs via `apt-get` or `dnf`). +- This action imports the signer's public key into rpmdb immediately before its own verify step, so its `verification` output won't normally show `NOKEY`. Downstream consumers who checksig the RPM without importing that public key first will see `NOKEY` there; the signature is still valid, they just haven't imported the key locally. + +## Inputs + +| Name | Required | Description | +|------|----------|-------------| +| `gpg-fingerprint` | Yes | Fingerprint of the (secret) GPG key to use | +| `gnupg-home` | No | GNUPGHOME directory produced by previous step (optional) | +| `resign` | No | If `true`, remove existing signature before adding new one (default: `false`) | + +## Outputs + +| Name | Description | +|------|-------------| +| `verification` | Raw output of `rpm --checksig` after signing | + +## Example Usage + +See [`gpg-sign-artifacts.yml`](../../.github/workflows/gpg-sign-artifacts.yml) in this repo for a real usage example. + +## Security Notes + +- Prefer ephemeral keys: generate -> sign -> cleanup (see Key Lifecycle below; cleanup is the calling workflow's responsibility, not optional). +- Provide `gnupg-home` explicitly to avoid leaking into default `~/.gnupg`. +- Set `resign: true` only if you intentionally need to replace a signature. + +## Key Lifecycle + +Combine with `gpg-configure-release-keys`. That action leaves the ephemeral secret key in `GNUPGHOME` for this step to use and does not clean it up itself. The calling workflow is responsible for shredding `GNUPGHOME` once signing is done. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `no RPMs found under $(pwd)` | No `*.rpm` files under the working directory | Check the build step produced RPMs before this action runs | +| `NOKEY` when a consumer checksigs the RPM elsewhere | They haven't imported the signer's public key into their own rpmdb | Import the exported public key into rpmdb before checking | +| `BAD` in verification output | Signature mismatch or corruption | Rebuild and re-sign; ensure correct key | +| Already signed message | Existing signature and `resign` not set | Set `resign: true` | + +## License +MIT diff --git a/actions/sign-rpm/action.yml b/actions/gpg-sign-rpm/action.yml similarity index 52% rename from actions/sign-rpm/action.yml rename to actions/gpg-sign-rpm/action.yml index 72381d8..16ab580 100644 --- a/actions/sign-rpm/action.yml +++ b/actions/gpg-sign-rpm/action.yml @@ -9,9 +9,6 @@ branding: description: 'Signs an RPM using the provided GPG key fingerprint (expects GNUPGHOME from previous step)' inputs: - rpm-path: - description: 'Path to the RPM file to sign' - required: true gpg-fingerprint: description: 'Fingerprint of the GPG key to use for signing' required: true @@ -38,23 +35,22 @@ runs: apt-get update -y apt-get install -y --no-install-recommends rpm gnupg elif command -v dnf >/dev/null 2>&1; then - dnf install -y rpm-build gnupg + dnf install -y rpm-build rpm-sign gnupg fi - - - name: Configure RPM to use GPG key shell: bash env: GNUPGHOME: ${{ inputs['gnupg-home'] }} + GPG_FINGERPRINT: ${{ inputs['gpg-fingerprint'] }} run: | set -euo pipefail GPG_BIN="$(command -v gpg || command -v gpg2)" { echo "%_signature gpg" - echo "%_gpg_name ${{ inputs['gpg-fingerprint'] }}" + echo "%_gpg_name $GPG_FINGERPRINT" echo "%__gpg $GPG_BIN" - echo "%_gpg_digest_algo sha256" # <— + echo "%_gpg_digest_algo sha256" } >> "$HOME/.rpmmacros" echo "Using gpg at: $GPG_BIN" rpm --eval "%{__gpg}" || true @@ -63,35 +59,46 @@ runs: shell: bash env: GNUPGHOME: ${{ inputs['gnupg-home'] }} + GPG_FINGERPRINT: ${{ inputs['gpg-fingerprint'] }} + RESIGN: ${{ inputs.resign }} run: | set -euo pipefail echo "GNUPGHOME=$GNUPGHOME" gpg --list-secret-keys || { echo "No secret keys in GNUPGHOME"; exit 1; } - # If already signed and resign=false, skip - if rpm --checksig "${{ inputs['rpm-path'] }}" 2>/dev/null | grep -qi 'pgp signature'; then - if [ "${{ inputs.resign }}" = "true" ]; then - rpm --delsign "${{ inputs['rpm-path'] }}" || true - else - echo "Already signed; skipping (set resign=true to force)." - exit 0 + found=0 + while IFS= read -r f; do + found=1 + if rpm --checksig "$f" 2>/dev/null | grep -qi 'pgp signature'; then + if [ "$RESIGN" = "true" ]; then + rpm --delsign "$f" || true + else + echo "Already signed; skipping $f (set resign=true to force)." + continue + fi fi + # Use rpmsign (more reliable) with explicit defines + rpmsign --addsign \ + --define "_signature gpg" \ + --define "_gpg_name $GPG_FINGERPRINT" \ + --define "__gpg $(command -v gpg)" \ + --define "_gpg_digest_algo sha256" \ + "$f" + done < <(find . -type f -name '*.rpm') + if [ "$found" -eq 0 ]; then + echo "::error::no RPMs found under $(pwd)"; exit 1 fi - # Use rpmsign (more reliable) with explicit defines - rpmsign --addsign \ - --define "_signature gpg" \ - --define "_gpg_name ${{ inputs['gpg-fingerprint'] }}" \ - --define "__gpg $(command -v gpg)" \ - --define "_gpg_digest_algo sha256" \ - "${{ inputs['rpm-path'] }}" - - name: Import Signer Public Key for Verification shell: bash + env: + GNUPGHOME: ${{ inputs['gnupg-home'] }} + GPG_FINGERPRINT: ${{ inputs['gpg-fingerprint'] }} run: | set -euo pipefail - gpg --armor --export "${{ inputs.gpg-fingerprint }}" > signer.pub - rpm --import signer.pub 2>/dev/null || sudo rpm --import signer.pub 2>/dev/null || true + gpg --armor --export "$GPG_FINGERPRINT" > signer.pub + test -s signer.pub || { echo "Export produced no key for $GPG_FINGERPRINT"; exit 1; } + rpm --import signer.pub || sudo rpm --import signer.pub rm -f signer.pub - name: Verify RPM signature @@ -99,6 +106,19 @@ runs: shell: bash run: | set -euo pipefail - out=$(rpm --checksig "${{ inputs.rpm-path }}") + + RPMS=() + while IFS= read -r f; do + RPMS+=("$f") + done < <(find . -type f -name '*.rpm') + if [ "${#RPMS[@]}" -eq 0 ]; then + echo "::error::no RPMs found under $(pwd)"; exit 1 + fi + + out=$(rpm --checksig "${RPMS[@]}") echo "$out" - echo "result=$out" >> "$GITHUB_OUTPUT" + { + echo "result<> "$GITHUB_OUTPUT" diff --git a/actions/gpg-verify-trust-chain/README.md b/actions/gpg-verify-trust-chain/README.md new file mode 100644 index 0000000..201ed28 --- /dev/null +++ b/actions/gpg-verify-trust-chain/README.md @@ -0,0 +1,14 @@ + + +# gpg-verify-trust-chain + +Verifies the release trust chain (master certifies repo key, repo key +certifies ephemeral key) and optionally checksigs any RPMs found under +`rpm-dir`. Standalone: installs its own deps, no prior GPG state assumed. + +Verification itself is fetched at runtime, pinned to a commit SHA, from +[gpg-signing-manager](https://github.com/OpenCHAMI/gpg-signing-manager)'s +`verify-chain.sh`. diff --git a/actions/gpg-verify-trust-chain/action.yml b/actions/gpg-verify-trust-chain/action.yml new file mode 100644 index 0000000..b25ea2a --- /dev/null +++ b/actions/gpg-verify-trust-chain/action.yml @@ -0,0 +1,120 @@ +name: 'Verify gpg trust chain' +description: >- + Verifies the release GPG trust chain (master certifies repo key, repo key certifies ephemeral key) and optionally checks RPM signatures. Standalone: installs its own dependencies and runs verify-chain.sh, fetched at runtime from a pinned commit in gpg-signing-manager. +inputs: + master-public-key: + description: >- + ASCII-armored master public key content (e.g. from a secret). If empty and master-public-key-file is also empty, the chain check is skipped with a warning (exit 0) unless require-master is 'true'. + required: false + default: '' + master-public-key-file: + description: 'Path to the master public key file. Takes precedence over master-public-key.' + required: false + default: '' + repo-public-key-file: + description: 'Path to the repo public key, certified by the master key' + required: false + default: 'repo-public.asc' + ephemeral-public-key-file: + description: 'Path to the ephemeral public key, certified by the repo key' + required: false + default: 'ephemeral-public.asc' + rpm-dir: + description: >- + Directory searched recursively for *.rpm to signature-check. Empty dir or no matches is not an error; key-chain checks still run. + required: false + default: 'dist' + require-master: + description: "If 'true', fail instead of skipping when no master key is provided" + required: false + default: 'false' +outputs: + verified: + description: "'true' if the chain was verified, 'skipped' if no master key was provided" + value: ${{ steps.verify.outputs.verified }} +runs: + using: 'composite' + steps: + - name: Install dependencies + shell: bash + run: | + need=() + command -v gpg >/dev/null 2>&1 || need+=(gnupg2) + command -v rpm >/dev/null 2>&1 || need+=(rpm) + [[ ${#need[@]} -eq 0 ]] && exit 0 + SUDO='' + command -v sudo >/dev/null 2>&1 && SUDO=sudo + if command -v apt-get >/dev/null 2>&1; then + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq "${need[@]/#rpm/rpm}" + elif command -v dnf >/dev/null 2>&1; then + $SUDO dnf install -y -q "${need[@]}" + else + echo '::error::Unsupported package manager: need apt-get or dnf' >&2 + exit 1 + fi + - name: Verify trust chain + id: verify + shell: bash + env: + MASTER_KEY_CONTENT: ${{ inputs.master-public-key }} + MASTER_KEY_FILE: ${{ inputs.master-public-key-file }} + REPO_KEY_FILE: ${{ inputs.repo-public-key-file }} + EPHEMERAL_KEY_FILE: ${{ inputs.ephemeral-public-key-file }} + RPM_DIR: ${{ inputs.rpm-dir }} + REQUIRE_MASTER: ${{ inputs.require-master }} + run: |- + set -euo pipefail + + # --- Resolve the master public key ----------------------------------- + workdir=$(mktemp -d) + trap 'rm -rf "$workdir"' EXIT + + master_key='' + if [[ -n "$MASTER_KEY_FILE" ]]; then + master_key="$MASTER_KEY_FILE" + elif [[ -n "$MASTER_KEY_CONTENT" ]]; then + master_key="$workdir/master-public.asc" + printf '%s\n' "$MASTER_KEY_CONTENT" > "$master_key" + fi + + if [[ -z "$master_key" ]]; then + if [[ "$REQUIRE_MASTER" == 'true' ]]; then + echo '::error::No master public key provided and require-master is true' + exit 1 + fi + echo '::warning::No master public key provided; skipping trust chain verification' + echo 'verified=skipped' >> "$GITHUB_OUTPUT" + exit 0 + fi + + for f in "$master_key" "$REPO_KEY_FILE" "$EPHEMERAL_KEY_FILE"; do + if [[ ! -f "$f" ]]; then + echo "::error::Key file not found: $f" + exit 1 + fi + done + + # --- Collect RPMs (optional) ----------------------------------------- + rpm_args=() + if [[ -n "$RPM_DIR" && -d "$RPM_DIR" ]]; then + while IFS= read -r -d '' r; do + rpm_args+=(--rpm "$r") + done < <(find "$RPM_DIR" -name '*.rpm' -print0) + fi + if [[ ${#rpm_args[@]} -eq 0 ]]; then + echo "No RPMs found under '${RPM_DIR:-}'; verifying key chain only." + fi + + # --- Run the verifier --------------------------------------- + script="$workdir/verify-chain.sh" + curl -fsSL --retry 3 -o "$script" \ + "https://raw.githubusercontent.com/OpenCHAMI/gpg-signing-manager/d4366a45c94be50cbb7f90d229c10f395b6d10d8/scripts/verify-chain.sh" + + bash "$script" \ + --master "$master_key" \ + --repo "$REPO_KEY_FILE" \ + --ephemeral "$EPHEMERAL_KEY_FILE" \ + "${rpm_args[@]+"${rpm_args[@]}"}" + + echo 'verified=true' >> "$GITHUB_OUTPUT" diff --git a/actions/sign-rpm/README.md b/actions/sign-rpm/README.md deleted file mode 100644 index f50ca24..0000000 --- a/actions/sign-rpm/README.md +++ /dev/null @@ -1,98 +0,0 @@ - - -# 📦 RPM Signing Action - -Signs an RPM file using a GPG key fingerprint (typically an ephemeral key produced by the `gpg-ephemeral-key` action). Designed to pair with ephemeral, short‑lived keys to reduce long‑term key exposure in CI. - -## 🔧 How It Works - -1. Assumes the GPG key (secret) is already present in the GNUPGHOME (e.g. from `gpg-ephemeral-key`). -2. Configures RPM macros to use GPG (sets `%_signature`, `%_gpg_name`, `%__gpg`, and SHA‑256 digest). -3. Signs the RPM with `rpmsign --addsign` (or re‑signs if `resign: true`). -4. Verifies the signature and exposes the verification output. - -Notes: -- The action supports Ubuntu and Fedora runners (installs via `apt-get` or `dnf`). -- Verification may show `NOKEY` if the rpm database doesn’t have the public key; the signature is still valid, but rpm cannot confirm it without the public key imported. - -## 📥 Inputs - -| Name | Required | Description | -|------|----------|-------------| -| `rpm-path` | ✅ | Path to the RPM file to sign | -| `gpg-fingerprint` | ✅ | Fingerprint of the (secret) GPG key to use | -| `gnupg-home` | ❌ | GNUPGHOME directory produced by previous step (optional) | -| `resign` | ❌ | If `true`, remove existing signature before adding new one (default: `false`) | - -## 📤 Outputs - -| Name | Description | -|------|-------------| -| `verification` | Raw output of `rpm --checksig` after signing | - -## 🚀 Example Usage - -```yaml -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Generate ephemeral key - id: gpg - uses: OpenCHAMI/github-actions/actions/gpg-ephemeral-key@v1 - with: - subkey-armored: ${{ secrets.GPG_SUBKEY_B64 }} - comment: build:${{ github.run_id }} - - - name: Build RPM - run: ./scripts/build-rpm.sh - - - name: Sign RPM - id: sign - uses: OpenCHAMI/github-actions/actions/sign-rpm@v1 - with: - rpm-path: dist/my.rpm - gpg-fingerprint: ${{ steps.gpg.outputs.ephemeral-fingerprint }} - gnupg-home: ${{ steps.gpg.outputs.gnupg-home }} - - - name: Show verification - run: echo "${{ steps.sign.outputs.verification }}" -``` - -Optional: Import the public key into rpmdb to avoid `NOKEY` in verification output: - -```yaml - - name: Import public key for rpm verification - run: | - GNUPGHOME="${{ steps.gpg.outputs.gnupg-home }}"; export GNUPGHOME - gpg --armor --export "${{ steps.gpg.outputs.ephemeral-fingerprint }}" > signer.pub - sudo rpm --import signer.pub - rm -f signer.pub -``` - -## 🔐 Security Notes - -- Prefer ephemeral keys: generate -> sign -> (optionally) cleanup. -- Provide `gnupg-home` explicitly to avoid leaking into default `~/.gnupg`. -- Set `resign: true` only if you intentionally need to replace a signature. - -## ♻️ Key Lifecycle - -Combine with the ephemeral key action and set `cleanup: true` unless later steps need the secret key. - -## 🛠 Troubleshooting - -| Symptom | Cause | Fix | -|---------|-------|-----| -| `No such file or directory` | Wrong `rpm-path` | Check artifact path and build step | -| `NOKEY` in verification output | rpmdb missing public key | Import the exported public key into rpmdb | -| `BAD` in verification output | Signature mismatch or corruption | Rebuild and re‑sign; ensure correct key | -| Already signed message | Existing signature and `resign` not set | Set `resign: true` | - -## 📝 License -MIT