Skip to content

Commit e3c975d

Browse files
authored
Add option to require explicit gpg key verification for image builds (#15462)
Signed-off-by: Daniel McIlvaney <damcilva@microsoft.com>
1 parent 4016ca1 commit e3c975d

10 files changed

Lines changed: 161 additions & 7 deletions

File tree

toolkit/Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,13 @@ else
164164
VALIDATE_TOOLCHAIN_GPG ?= y
165165
endif
166166
endif
167+
##help:var:VALIDATE_IMAGE_GPG:{y,n}=Enable RPM GPG signature verification during package fetching and image builds. When enabled, all packages must be signed - this validates that packages have completed the signing process. Default is 'n' for local development with unsigned packages. Production builds use a multi-step workflow (build packages -> sign packages -> build images) and should set 'y' for the final image build step to enforce that all packages are signed. Keys used for validation can be modified with the IMAGE_GPG_VALIDATION_KEYS variable.
168+
VALIDATE_IMAGE_GPG ?= n
167169

168-
TOOLCHAIN_GPG_VALIDATION_KEYS ?= $(wildcard $(PROJECT_ROOT)/SPECS/azurelinux-repos/MICROSOFT-*-GPG-KEY) $(wildcard $(toolkit_root)/repos/MICROSOFT-*-GPG-KEY)
170+
# Default GPG keys for package GPG validation, used with VALIDATE_TOOLCHAIN_GPG and VALIDATE_IMAGE_GPG
171+
default_gpg_keys := $(wildcard $(PROJECT_ROOT)/SPECS/azurelinux-repos/MICROSOFT-*-GPG-KEY) $(wildcard $(toolkit_root)/repos/MICROSOFT-*-GPG-KEY)
172+
TOOLCHAIN_GPG_VALIDATION_KEYS ?= $(default_gpg_keys)
173+
IMAGE_GPG_VALIDATION_KEYS ?= $(default_gpg_keys)
169174

170175
######## COMMON MAKEFILE UTILITIES ########
171176

toolkit/docs/building/building.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,10 @@ Authentication mode for downloading source files for SRPM packing. Valid options
865865
| INCREMENTAL_TOOLCHAIN | n | Only build toolchain RPM packages if they are not already present
866866
| RUN_CHECK | n | Run the %check sections when compiling packages
867867
| ALLOW_TOOLCHAIN_REBUILDS | n | Do not treat rebuilds of toolchain packages during regular package build phase as errors.
868+
| VALIDATE_TOOLCHAIN_GPG | (auto - based on toolchain build mode) | Enable RPM GPG signature verification for toolchain packages. Automatically set to `y` when downloading pre-built toolchain packages (`REBUILD_TOOLCHAIN=n`), and `n` when rebuilding locally or using `DAILY_BUILD_ID`. Packages are validated against keys specified in `TOOLCHAIN_GPG_VALIDATION_KEYS`.
869+
| TOOLCHAIN_GPG_VALIDATION_KEYS | `$(PROJECT_ROOT)/SPECS/azurelinux-repos/MICROSOFT-*-GPG-KEY $(toolkit_root)/repos/MICROSOFT-*-GPG-KEY` | Space separated list of GPG key files used to validate RPM signatures when `VALIDATE_TOOLCHAIN_GPG=y`.
870+
| VALIDATE_IMAGE_GPG | n | Enable RPM GPG signature verification during image builds. When set to `y`, all packages fetched for image generation must have valid GPG signatures. Packages are validated against keys specified in `IMAGE_GPG_VALIDATION_KEYS`. Production builds should enable this to ensure all packages have completed the signing process.
871+
| IMAGE_GPG_VALIDATION_KEYS | `$(PROJECT_ROOT)/SPECS/azurelinux-repos/MICROSOFT-*-GPG-KEY $(toolkit_root)/repos/MICROSOFT-*-GPG-KEY` | Space separated list of GPG key files used to validate RPM signatures when `VALIDATE_IMAGE_GPG=y`.
868872
| PACKAGE_BUILD_RETRIES | 1 | Number of build retries for each package
869873
| CHECK_BUILD_RETRIES | 1 | Minimum number of check section retries for each package if RUN_CHECK=y and tests fail.
870874
| MAX_CASCADING_REBUILDS | | When a package rebuilds, how many additional layers of dependent packages will be forced to rebuild (leave unset for unbounded, i.e., all downstream packages will rebuild)

toolkit/docs/security/intro.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ Below topics are dedicated to security-related details of the operating system.
44

55
## 1. [Security features](security-features.md)
66
## 2. [SSL CA certificates management](ca-certificates.md)
7+
## 3. [Verifying ISO images](iso-image-verification.md)
8+
## 4. [Production build recommendations](production-builds.md)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Production Build Recommendations
2+
3+
When building images or ISOs for production deployment, enable explicit GPG signature verification to ensure all packages have completed the signing process:
4+
5+
```bash
6+
sudo make image VALIDATE_IMAGE_GPG=y CONFIG_FILE=<your-config>
7+
```
8+
9+
This validates that all RPM packages fetched during image generation have valid GPG signatures from the expected signing keys.
10+
11+
## Build Workflow
12+
13+
A typical production workflow separates package building from image generation:
14+
15+
1. **Build packages** - Compile packages from source
16+
2. **Sign packages** - Sign built packages with your GPG key
17+
3. **Build images** - Generate images with `VALIDATE_IMAGE_GPG=y` to enforce all packages are signed
18+
19+
This separation ensures unsigned or improperly signed packages cannot be included in final images.
20+
21+
## Related Variables
22+
23+
| Variable | Description |
24+
|:---------|:------------|
25+
| `VALIDATE_IMAGE_GPG` | Set to `y` to require valid GPG signatures on all image packages |
26+
| `IMAGE_GPG_VALIDATION_KEYS` | GPG key files for signature validation |
27+
| `VALIDATE_TOOLCHAIN_GPG` | Automatically enabled when downloading pre-built toolchain |
28+
| `TOOLCHAIN_GPG_VALIDATION_KEYS` | GPG key files for toolchain validation |
29+
30+
See [build variables](../building/building.md#all-build-variables) for full details.

toolkit/scripts/imggen.mk

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,12 @@ ifneq ($(REPO_SNAPSHOT_TIME),)
140140
imagepkgfetcher_extra_flags += --repo-snapshot-time=$(REPO_SNAPSHOT_TIME)
141141
endif
142142

143-
$(image_package_cache_summary): $(go-imagepkgfetcher) $(chroot_worker) $(toolchain_rpms) $(imggen_local_repo) $(depend_REPO_LIST) $(REPO_LIST) $(depend_CONFIG_FILE) $(CONFIG_FILE) $(validate-config) $(RPMS_DIR) $(imggen_rpms) $(depend_REPO_SNAPSHOT_TIME) $(STATUS_FLAGS_DIR)/imagegen_cleanup.flag
143+
ifeq ($(VALIDATE_IMAGE_GPG),y)
144+
imagepkgfetcher_extra_flags += --enable-gpg-check
145+
imagepkgfetcher_extra_flags += $(foreach key,$(IMAGE_GPG_VALIDATION_KEYS),--gpg-key=$(key))
146+
endif
147+
148+
$(image_package_cache_summary): $(go-imagepkgfetcher) $(chroot_worker) $(toolchain_rpms) $(imggen_local_repo) $(depend_REPO_LIST) $(REPO_LIST) $(depend_CONFIG_FILE) $(CONFIG_FILE) $(validate-config) $(RPMS_DIR) $(imggen_rpms) $(depend_REPO_SNAPSHOT_TIME) $(depend_VALIDATE_IMAGE_GPG) $(depend_IMAGE_GPG_VALIDATION_KEYS) $(IMAGE_GPG_VALIDATION_KEYS) $(STATUS_FLAGS_DIR)/imagegen_cleanup.flag
144149
$(if $(CONFIG_FILE),,$(error Must set CONFIG_FILE=))
145150
$(go-imagepkgfetcher) \
146151
--input=$(CONFIG_FILE) \

toolkit/scripts/toolchain.mk

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ $(toolchain_rpms): $(TOOLCHAIN_MANIFEST) $(STATUS_FLAGS_DIR)/toolchain_local_tem
309309

310310
# No archive was selected, so download from online package server instead. All packages must be available for this step to succeed.
311311
else
312-
$(toolchain_rpms): $(TOOLCHAIN_MANIFEST) $(STATUS_FLAGS_DIR)/toolchain_auto_cleanup.flag $(depend_REBUILD_TOOLCHAIN) $(go-downloader) $(SCRIPTS_DIR)/toolchain/download_toolchain_rpm.sh $(TOOLCHAIN_GPG_VALIDATION_KEYS)
312+
$(toolchain_rpms): $(TOOLCHAIN_MANIFEST) $(STATUS_FLAGS_DIR)/toolchain_auto_cleanup.flag $(depend_REBUILD_TOOLCHAIN) $(go-downloader) $(SCRIPTS_DIR)/toolchain/download_toolchain_rpm.sh $(depend_TOOLCHAIN_GPG_VALIDATION_KEYS) $(TOOLCHAIN_GPG_VALIDATION_KEYS)
313313
@log_file="$(toolchain_downloads_logs_dir)/$(notdir $@).log" && \
314314
rm -f "$$log_file" && \
315315
$(SCRIPTS_DIR)/toolchain/download_toolchain_rpm.sh \

toolkit/scripts/utils.mk

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,11 @@ endef
6666
######## VARIABLE DEPENDENCY TRACKING ########
6767

6868
# List of variables to watch for changes.
69-
watch_vars=PACKAGE_BUILD_LIST PACKAGE_REBUILD_LIST PACKAGE_IGNORE_LIST REPO_LIST CONFIG_FILE STOP_ON_PKG_FAIL TOOLCHAIN_ARCHIVE REBUILD_TOOLCHAIN SRPM_PACK_LIST SPECS_DIR MAX_CASCADING_REBUILDS RUN_CHECK TEST_RUN_LIST TEST_RERUN_LIST TEST_IGNORE_LIST EXTRA_BUILD_LAYERS LICENSE_CHECK_MODE VALIDATE_TOOLCHAIN_GPG REPO_SNAPSHOT_TIME PACKAGE_CACHE_SUMMARY
69+
watch_vars=PACKAGE_BUILD_LIST PACKAGE_REBUILD_LIST PACKAGE_IGNORE_LIST REPO_LIST CONFIG_FILE STOP_ON_PKG_FAIL TOOLCHAIN_ARCHIVE REBUILD_TOOLCHAIN SRPM_PACK_LIST SPECS_DIR MAX_CASCADING_REBUILDS RUN_CHECK TEST_RUN_LIST TEST_RERUN_LIST TEST_IGNORE_LIST EXTRA_BUILD_LAYERS LICENSE_CHECK_MODE VALIDATE_TOOLCHAIN_GPG TOOLCHAIN_GPG_VALIDATION_KEYS VALIDATE_IMAGE_GPG IMAGE_GPG_VALIDATION_KEYS REPO_SNAPSHOT_TIME PACKAGE_CACHE_SUMMARY
7070
# Current list: $(depend_PACKAGE_BUILD_LIST) $(depend_PACKAGE_REBUILD_LIST) $(depend_PACKAGE_IGNORE_LIST) $(depend_REPO_LIST) $(depend_CONFIG_FILE) $(depend_STOP_ON_PKG_FAIL)
7171
# $(depend_TOOLCHAIN_ARCHIVE) $(depend_REBUILD_TOOLCHAIN) $(depend_SRPM_PACK_LIST) $(depend_SPECS_DIR) $(depend_EXTRA_BUILD_LAYERS) $(depend_MAX_CASCADING_REBUILDS) $(depend_RUN_CHECK) $(depend_TEST_RUN_LIST)
72-
# $(depend_TEST_RERUN_LIST) $(depend_TEST_IGNORE_LIST) $(depend_LICENSE_CHECK_MODE) $(depend_VALIDATE_TOOLCHAIN_GPG) $(depend_REPO_SNAPSHOT_TIME) $(depend_PACKAGE_CACHE_SUMMARY)
72+
# $(depend_TEST_RERUN_LIST) $(depend_TEST_IGNORE_LIST) $(depend_LICENSE_CHECK_MODE) $(depend_VALIDATE_TOOLCHAIN_GPG) $(depend_TOOLCHAIN_GPG_VALIDATION_KEYS) $(depend_VALIDATE_IMAGE_GPG)
73+
# $(depend_IMAGE_GPG_VALIDATION_KEYS) $(depend_REPO_SNAPSHOT_TIME) $(depend_PACKAGE_CACHE_SUMMARY)
7374

7475
.PHONY: variable_depends_on_phony clean-variable_depends_on_phony setfacl_always_run_phony
7576
clean: clean-variable_depends_on_phony

toolkit/tools/imagegen/installutils/installutils.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -766,7 +766,9 @@ func TdnfInstallWithProgress(packageName, installRoot string, currentPackagesIns
766766
return
767767
}
768768

769-
// TDNF 3.x uses repositories from installchroot instead of host. Passing setopt for repo files directory to use local repo for installroot installation
769+
// TDNF 3.x uses repositories from installchroot instead of host. Passing setopt for repo files directory to use local repo for installroot installation.
770+
// Note: --nogpgcheck is used here because GPG signature validation is performed earlier during package fetching (imagepkgfetcher)
771+
// when VALIDATE_IMAGE_GPG=y is set. Packages in the local repo have already been verified.
770772
err = shell.NewExecBuilder("tdnf", "-v", "install", packageName, "--installroot", installRoot, "--nogpgcheck",
771773
"--assumeyes", "--setopt", "reposdir=/etc/yum.repos.d/", releaseverCliArg).
772774
StdoutCallback(onStdout).
@@ -830,7 +832,9 @@ func calculateTotalPackages(packages []string, installRoot string) (installedPac
830832
stderr string
831833
)
832834

833-
// Issue an install request but stop right before actually performing the install (assumeno)
835+
// Issue an install request but stop right before actually performing the install (assumeno).
836+
// Note: --nogpgcheck is safe here because this is a dry-run (--assumeno) and packages are validated
837+
// during fetching when VALIDATE_IMAGE_GPG=y is set.
834838
stdout, stderr, err = shell.Execute("tdnf", "install", releaseverCliArg, "--assumeno", "--nogpgcheck", pkg, "--installroot", installRoot)
835839
if err != nil {
836840
// tdnf aborts the process when it detects an install with --assumeno.

toolkit/tools/imagepkgfetcher/imagepkgfetcher.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/microsoft/azurelinux/toolkit/tools/internal/packagerepo/repoutils"
1717
"github.com/microsoft/azurelinux/toolkit/tools/internal/pkggraph"
1818
"github.com/microsoft/azurelinux/toolkit/tools/internal/pkgjson"
19+
"github.com/microsoft/azurelinux/toolkit/tools/internal/rpm"
1920
"github.com/microsoft/azurelinux/toolkit/tools/internal/timestamp"
2021
"github.com/microsoft/azurelinux/toolkit/tools/pkg/profile"
2122

@@ -49,6 +50,9 @@ var (
4950
inputSummaryFile = app.Flag("input-summary-file", "Path to a file with the summary of packages cloned to be restored").String()
5051
outputSummaryFile = app.Flag("output-summary-file", "Path to save the summary of packages cloned").String()
5152

53+
enableGpgCheck = app.Flag("enable-gpg-check", "Enable RPM GPG signature verification for all repositories during package fetching.").Bool()
54+
gpgKeyPaths = app.Flag("gpg-key", "Path to a GPG key file for signature validation. May be specified multiple times. Required if enable-gpg-check is set.").ExistingFiles()
55+
5256
logFlags = exe.SetupLogFlags(app)
5357
profFlags = exe.SetupProfileFlags(app)
5458
timestampFile = app.Flag("timestamp-file", "File that stores timestamps for this program.").String()
@@ -73,6 +77,10 @@ func main() {
7377
logger.Log.Fatal("input-graph must be provided if external-only is set.")
7478
}
7579

80+
if *enableGpgCheck && len(*gpgKeyPaths) == 0 {
81+
logger.Log.Fatal("--enable-gpg-check requires at least one --gpg-key path")
82+
}
83+
7684
timestamp.StartEvent("initialize and configure cloner", nil)
7785

7886
cloner, err := rpmrepocloner.ConstructCloner(*outDir, *tmpDir, *workertar, *existingRpmDir, *existingToolchainRpmDir, *tlsClientCert, *tlsClientKey, *repoFiles, *repoSnapshotTime)
@@ -110,6 +118,14 @@ func main() {
110118
logger.Log.Panicf("Failed to clone RPM repo. Error: %s", err)
111119
}
112120

121+
// Validate GPG signatures of downloaded packages if enabled
122+
if *enableGpgCheck {
123+
err = rpm.ValidateDirectoryRPMSignatures(cloner.CloneDirectory(), *gpgKeyPaths)
124+
if err != nil {
125+
logger.Log.Panicf("Failed to validate RPM signatures. Error: %s", err)
126+
}
127+
}
128+
113129
timestamp.StartEvent("finalize cloned packages", nil)
114130

115131
err = cloner.ConvertDownloadedPackagesIntoRepo()

toolkit/tools/internal/rpm/rpm.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package rpm
55

66
import (
77
"fmt"
8+
"os"
9+
"os/exec"
810
"path/filepath"
911
"regexp"
1012
"runtime"
@@ -501,6 +503,91 @@ func InstallRPM(rpmFile string) (err error) {
501503
return
502504
}
503505

506+
const rpmKeysProgram = "rpmkeys"
507+
508+
// importGPGKeysToRPMDb imports GPG keys into an RPM database for signature verification.
509+
// - rpmDbRoot: path to a directory to use as the RPM database root (will be created if it doesn't exist)
510+
// - gpgKeyPaths: paths to GPG key files to import into the RPM database
511+
// This should be called once before validating multiple RPMs with checkRPMSignature.
512+
func importGPGKeysToRPMDb(rpmDbRoot string, gpgKeyPaths []string) (err error) {
513+
if _, err := exec.LookPath(rpmKeysProgram); err != nil {
514+
return fmt.Errorf("%s command not found - explicit GPG signature enforcement requires this tool:\n%w", rpmKeysProgram, err)
515+
}
516+
for _, keyPath := range gpgKeyPaths {
517+
_, stderr, importErr := shell.Execute(rpmKeysProgram, "--root", rpmDbRoot, "--import", keyPath)
518+
if importErr != nil {
519+
return fmt.Errorf("failed to import GPG key (%s) into RPM database: %v:\n%w", keyPath, stderr, importErr)
520+
}
521+
}
522+
return nil
523+
}
524+
525+
// checkRPMSignature validates the GPG signature of an RPM file.
526+
// - rpmFile: path to the RPM file to validate
527+
// - rpmDbRoot: path to a directory used as the RPM database root (must have GPG keys already imported via importGPGKeysToRpmDb)
528+
// Returns an error if the RPM signature is missing or invalid.
529+
func checkRPMSignature(rpmFile string, rpmDbRoot string) (err error) {
530+
_, stderr, err := shell.Execute(rpmKeysProgram, "--root", rpmDbRoot, "--checksig", rpmFile, "-D", "%_pkgverify_level signature")
531+
if err != nil {
532+
return fmt.Errorf("RPM signature validation failed for (%s): %v\n%w", rpmFile, stderr, err)
533+
}
534+
return nil
535+
}
536+
537+
// ValidateDirectoryRPMSignatures validates the GPG signatures of all RPM files in a directory.
538+
// It creates an isolated RPM database, imports the provided GPG keys, and validates each RPM.
539+
// Returns an error if any RPM has a missing or invalid signature.
540+
func ValidateDirectoryRPMSignatures(rpmDir string, gpgKeyPaths []string) (err error) {
541+
logger.Log.Info("Validating GPG signatures of downloaded packages")
542+
543+
// Create a temporary directory for the isolated RPM database
544+
rpmDbRoot, err := os.MkdirTemp("", "rpm-gpg-check-*")
545+
if err != nil {
546+
return fmt.Errorf("failed to create temporary directory for RPM database:\n%w", err)
547+
}
548+
defer os.RemoveAll(rpmDbRoot)
549+
550+
// Import GPG keys once before validating all RPMs
551+
err = importGPGKeysToRPMDb(rpmDbRoot, gpgKeyPaths)
552+
if err != nil {
553+
return err
554+
}
555+
556+
// Find all RPM files in the directory (recursively)
557+
var rpmFiles []string
558+
err = filepath.WalkDir(rpmDir, func(path string, d os.DirEntry, walkErr error) error {
559+
if walkErr != nil {
560+
return walkErr
561+
}
562+
if !d.IsDir() && filepath.Ext(path) == ".rpm" {
563+
rpmFiles = append(rpmFiles, path)
564+
}
565+
return nil
566+
})
567+
if err != nil {
568+
return fmt.Errorf("failed to find RPM files in (%s):\n%w", rpmDir, err)
569+
}
570+
571+
if len(rpmFiles) == 0 {
572+
logger.Log.Debug("No RPM files found to validate")
573+
return nil
574+
}
575+
576+
logger.Log.Infof("Validating %d RPM files", len(rpmFiles))
577+
578+
// Validate each RPM
579+
for _, rpmFile := range rpmFiles {
580+
logger.Log.Debugf("Validating signature of: %s", filepath.Base(rpmFile))
581+
err = checkRPMSignature(rpmFile, rpmDbRoot)
582+
if err != nil {
583+
return fmt.Errorf("GPG signature validation failed:\n%w", err)
584+
}
585+
}
586+
587+
logger.Log.Info("All downloaded RPMs have valid GPG signatures")
588+
return nil
589+
}
590+
504591
// QueryRPMProvides returns what an RPM file provides.
505592
// This includes any provides made by a generator and files provided by the rpm.
506593
func QueryRPMProvides(rpmFile string) (provides []string, err error) {

0 commit comments

Comments
 (0)