Add signing implementation to ImageBuilder#1957
Add signing implementation to ImageBuilder#1957lbussell wants to merge 22 commits intodotnet:mainfrom
Conversation
Introduce configuration and model types for container image signing: - SigningConfiguration: holds ESRP certificate IDs for images and referrers - BuildConfiguration: holds ArtifactStagingDirectory for build artifacts - Add Signing property to PublishConfiguration - ImageSigningRequest, PayloadSigningResult, ImageSigningResult records This is Phase 1 of the signing implementation.
Introduce ORAS services using OrasProject.Oras 0.4.0 for pushing Notary v2 signatures to registries: - IOrasDescriptorService: resolves OCI descriptors from references - IOrasSignatureService: pushes signatures as referrer artifacts - OrasCredentialProviderAdapter: bridges IRegistryCredentialsProvider - Uses Packer.PackManifestAsync with Subject for referrer relationship Existing IOrasClient remains unchanged for other functionality.
Test credential mapping, null handling, and host passthrough.
Implement the core signing service layer: - IEsrpSigningService: invokes DDSignFiles.dll via MicroBuild plugin - IPayloadSigningService: writes payloads, signs via ESRP, calculates cert chain - IBulkImageSigningService: orchestrates signing and ORAS push - CertificateChainCalculator: extracts x5chain thumbprints from COSE envelopes Also adds GetEnvironmentVariable to IEnvironmentService and SigningServiceExtensions for DI registration.
Implements ISigningRequestGenerator with two methods: - GeneratePlatformSigningRequestsAsync: Converts platform digests to signing requests - GenerateManifestListSigningRequestsAsync: Converts manifest list digests to signing requests Uses LINQ to flatten the repo/image/platform hierarchy. Updates BuildCommand to use the generator instead of inline request creation.
This standalone command has been superseded by the integrated signing services that sign images immediately after build/push in BuildCommand.
- Add SignType property to SigningConfiguration (defaults to 'test') - Update EsrpSigningService to use SignType from config - Set SignType: real in publish-config-prod.yml - Set SignType: test in publish-config-nonprod.yml
- Add Enabled property to SigningConfiguration
- Update BuildCommand to check Signing.Enabled before signing
- Update publish-config templates to use $(enableSigning) variable
- Update init-imagebuilder to read enableSigning and signType from variables
To enable signing in a pipeline, set:
variables:
enableSigning: true
signType: real # or 'test'
Inject IBulkImageSigningService and call SignImagesAsync after push. Signing is skipped in dry-run mode or when SigningConfiguration is null.
Some image-info entries have null digests when their platforms weren't built in the current pipeline run. ApplyOverrideToDigest assumes non-null input, causing a NullReferenceException in the signing command. The signing request generator already filters out empty digests downstream, but the override runs first.
Extract signing logic from BuildCommand into a new standalone SignImagesCommand. This creates SignImagesCommand.cs and SignImagesOptions.cs for separate signing. Removes signing-related fields and methods from BuildCommand. Registers SignImagesCommand in ImageBuilder.
Add RegistryOverride option to SignImagesOptions and apply it to image artifact details before generating signing requests.
DDSignFiles.dll requires VSENGESRPSSL environment variable on non-Windows platforms. Add fail-fast check in EsrpSigningService to catch missing environment variables before DDSignFiles retries auth endlessly.
After ApplyRegistryOverride, the digest is already a fully qualified reference (registry/prefix/repo@sha256:...). The signing request generator was incorrectly prepending repo@ again, producing a malformed reference.
Switch from runtime-deps to runtime base image and remove --self-contained=true from dotnet publish. This provides the dotnet CLI in the container, which is needed to invoke DDSignFiles.dll for ESRP signing.
DDSignFiles expects SignFileRecordList with Certs/SrcPath/DstPath fields. We were generating the wrong schema (MicroBuild SignBatches format), causing a NullReferenceException in GetSignRecordsFromInputFile.
6d2daa9 to
e260945
Compare
Update test mocks and assertions to match the current behavior where SigningRequestGenerator passes bare digests (e.g. 'sha256:abc123') instead of full image references to the descriptor service.
There was a problem hiding this comment.
Pull request overview
This PR implements container image signing functionality for ImageBuilder using ESRP (Enterprise Signing and Release Pipeline) and ORAS (OCI Registry as Storage). The implementation replaces the previous GenerateSigningPayloadsCommand with a new end-to-end SignImagesCommand that handles payload generation, ESRP signing, certificate chain extraction, and signature artifact publishing.
Changes:
- Added comprehensive signing infrastructure with services for ESRP integration, payload signing, and ORAS-based signature publishing
- Integrated ORAS .NET library for OCI descriptor resolution and signature artifact management
- Added configuration models for signing and build artifacts
- Removed old
GenerateSigningPayloadsCommandin favor of newSignImagesCommand - Modified Dockerfile to use framework-dependent deployment instead of self-contained
Reviewed changes
Copilot reviewed 38 out of 38 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
| SigningServiceExtensions.cs | DI registration extension methods for signing services |
| SigningRequestGenerator.cs | Generates signing requests by fetching OCI descriptors from registries |
| PayloadSigningService.cs | Writes payloads to disk, invokes ESRP signing, and calculates certificate chains |
| EsrpSigningService.cs | Wraps MicroBuild DDSignFiles.dll for ESRP file signing |
| CertificateChainCalculator.cs | Extracts and calculates x5chain thumbprints from COSE signature envelopes |
| BulkImageSigningService.cs | Orchestrates bulk signing and signature publishing workflow |
| OrasDotNetService.cs | ORAS .NET library integration for descriptor resolution and signature pushing |
| OrasCredentialProviderAdapter.cs | Adapts ImageBuilder credentials to ORAS authentication interface |
| IOrasSignatureService.cs, IOrasDescriptorService.cs | Interfaces for ORAS operations |
| SignImagesCommand.cs | Command implementation that orchestrates end-to-end signing process |
| SignImagesOptions.cs | CLI options for sign-images command |
| SigningConfiguration.cs, BuildConfiguration.cs | Configuration models for signing and build artifacts |
| PublishConfiguration.cs | Added Signing property |
| ConfigurationExtensions.cs | Added BuildConfiguration DI registration |
| ImageBuilder.cs | Updated DI registrations, removed old command, added new command and ORAS services |
| IEnvironmentService.cs, EnvironmentService.cs | Added GetEnvironmentVariable method |
| ImageInfoHelper.cs | Added null/empty checks before applying registry overrides |
| BuildCommand.cs | Removed unused PublishConfiguration parameter |
| Microsoft.DotNet.ImageBuilder.csproj | Added OrasProject.Oras and System.Formats.Cbor packages |
| Dockerfile.linux | Changed to framework-dependent deployment and runtime base image |
| SigningRequestGeneratorTests.cs | Tests for request generator |
| CertificateChainCalculatorTests.cs | Tests for certificate chain calculation |
| OrasDotNetServiceTests.cs | Tests for ORAS service (partial coverage) |
| OrasCredentialProviderAdapterTests.cs | Tests for credential adapter |
| GenerateSigningPayloadsCommandTests.cs | Removed old command tests |
| BuildCommandTests.cs | Updated test helper to remove unused parameter |
| ImageSigningRequest.cs, ImageSigningResult.cs, PayloadSigningResult.cs | Data transfer objects for signing operations |
| ISigningRequestGenerator.cs, IPayloadSigningService.cs, IEsrpSigningService.cs, IBulkImageSigningService.cs | Service interfaces |
| public async Task<Descriptor> GetDescriptorAsync(string reference, CancellationToken cancellationToken = default) | ||
| { |
There was a problem hiding this comment.
The method does not validate the reference parameter. According to the C# coding guidelines, guard clauses should be added. Consider adding ArgumentException.ThrowIfNullOrWhiteSpace(reference) at the beginning of the method.
| public int ImageSigningKeyCode { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// Certificate ID used by DDSignFiles.dll for signing referrer artifacts (SBOMs, etc.). |
There was a problem hiding this comment.
The ReferrerSigningKeyCode property is defined but never used in the current implementation. According to the comment, it's intended for "signing referrer artifacts (SBOMs, etc.)", but there's no code that uses this property. Consider removing it if it's not needed yet, or add a TODO comment if it's planned for future use.
| /// Certificate ID used by DDSignFiles.dll for signing referrer artifacts (SBOMs, etc.). | |
| /// Certificate ID used by DDSignFiles.dll for signing referrer artifacts (SBOMs, etc.). | |
| /// TODO: Implement usage of this property when adding support for signing referrer artifacts. |
| public async Task<string> PushSignatureAsync( | ||
| Descriptor subjectDescriptor, | ||
| PayloadSigningResult result, | ||
| CancellationToken cancellationToken = default) | ||
| { |
There was a problem hiding this comment.
The method does not validate its parameters. According to the C# coding guidelines, guard clauses should be added at the beginning. Consider adding ArgumentNullException.ThrowIfNull(subjectDescriptor) and ArgumentNullException.ThrowIfNull(result) before the method logic.
| var fileBytes = File.ReadAllBytes(signedPayloadPath); | ||
| var reader = new CborReader(fileBytes); | ||
|
|
There was a problem hiding this comment.
The method does not validate its parameters. According to the C# coding guidelines, guard clauses should be added. Consider adding parameter validation for signedPayloadPath using ArgumentException.ThrowIfNullOrWhiteSpace(signedPayloadPath) at the beginning of the method.
| var fileBytes = File.ReadAllBytes(signedPayloadPath); | |
| var reader = new CborReader(fileBytes); | |
| ArgumentException.ThrowIfNullOrWhiteSpace(signedPayloadPath); | |
| var fileBytes = File.ReadAllBytes(signedPayloadPath); | |
| var reader = new CborReader(fileBytes); |
| public class SignImagesCommand( | ||
| ILoggerService loggerService, | ||
| IBulkImageSigningService signingService, | ||
| ISigningRequestGenerator signingRequestGenerator, | ||
| IOptions<PublishConfiguration> publishConfigOptions) | ||
| : Command<SignImagesOptions, SignImagesOptionsBuilder> | ||
| { | ||
| private readonly PublishConfiguration _publishConfiguration = publishConfigOptions.Value; | ||
|
|
||
| protected override string Description => | ||
| "Signs container images listed in the image info file using ESRP"; | ||
|
|
||
| public override async Task ExecuteAsync() | ||
| { | ||
| loggerService.WriteHeading("SIGNING CONTAINER IMAGES"); | ||
|
|
||
| var signingConfig = _publishConfiguration.Signing; | ||
| if (signingConfig is null || !signingConfig.Enabled) | ||
| { | ||
| loggerService.WriteMessage("Signing is not enabled. Skipping image signing."); | ||
| return; | ||
| } | ||
|
|
||
| if (!File.Exists(Options.ImageInfoPath)) | ||
| { | ||
| loggerService.WriteMessage(PipelineHelper.FormatWarningCommand( | ||
| "Image info file not found. Skipping image signing.")); | ||
| return; | ||
| } | ||
|
|
||
| if (Options.IsDryRun) | ||
| { | ||
| loggerService.WriteMessage("Dry run enabled. Skipping actual signing."); | ||
| return; | ||
| } | ||
|
|
||
| var imageInfoContents = await File.ReadAllTextAsync(Options.ImageInfoPath); | ||
| var imageArtifactDetails = ImageArtifactDetails.FromJson(imageInfoContents); | ||
|
|
||
| loggerService.WriteMessage($"Registry override: Registry='{Options.RegistryOverride.Registry}', RepoPrefix='{Options.RegistryOverride.RepoPrefix}'"); | ||
|
|
||
| LogDigests(imageArtifactDetails); | ||
|
|
||
| // Apply registry override to get fully-qualified image references | ||
| imageArtifactDetails = imageArtifactDetails.ApplyRegistryOverride(Options.RegistryOverride); | ||
|
|
||
| LogDigests(imageArtifactDetails); | ||
|
|
||
| var platformRequests = await signingRequestGenerator.GeneratePlatformSigningRequestsAsync(imageArtifactDetails); | ||
| var manifestListRequests = await signingRequestGenerator.GenerateManifestListSigningRequestsAsync(imageArtifactDetails); | ||
| var allRequests = platformRequests.Concat(manifestListRequests).ToList(); | ||
|
|
||
| if (allRequests.Count == 0) | ||
| { | ||
| loggerService.WriteMessage("No images to sign."); | ||
| return; | ||
| } | ||
|
|
||
| var keyCode = signingConfig.ImageSigningKeyCode; | ||
| loggerService.WriteMessage($"Signing {allRequests.Count} image(s) ({platformRequests.Count} platforms, {manifestListRequests.Count} manifest lists) with key code {keyCode}..."); | ||
|
|
||
| var results = await signingService.SignImagesAsync(allRequests, keyCode); | ||
|
|
||
| loggerService.WriteMessage($"Successfully signed {results.Count} image(s)."); | ||
| foreach (var result in results) | ||
| { | ||
| loggerService.WriteMessage($" {result.ImageName}: signature digest {result.SignatureDigest}"); | ||
| } | ||
| } | ||
|
|
||
| private void LogDigests(ImageArtifactDetails imageArtifactDetails) | ||
| { | ||
| foreach (var repo in imageArtifactDetails.Repos) | ||
| { | ||
| foreach (var image in repo.Images) | ||
| { | ||
| foreach (var platform in image.Platforms) | ||
| { | ||
| loggerService.WriteMessage($" repo={repo.Repo} platform.Digest={platform.Digest}"); | ||
| } | ||
|
|
||
| if (image.Manifest is not null) | ||
| { | ||
| loggerService.WriteMessage($" repo={repo.Repo} manifest.Digest={image.Manifest.Digest}"); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
The SignImagesCommand class lacks automated tests. The old GenerateSigningPayloadsCommand had comprehensive test coverage that should be replicated for the new command. Consider adding tests that cover various scenarios: empty image info, missing files, registry override application, dry-run mode, and signing enabled/disabled states.
| builder.Services.AddSingleton<Oras.OrasDotNetService>(); | ||
| builder.Services.AddSingleton<Oras.IOrasDescriptorService>(sp => sp.GetRequiredService<Oras.OrasDotNetService>()); | ||
| builder.Services.AddSingleton<Oras.IOrasSignatureService>(sp => sp.GetRequiredService<Oras.OrasDotNetService>()); |
There was a problem hiding this comment.
The OrasDotNetService is registered as both a concrete type and as two interfaces using lambda expressions. While this works, there's a more idiomatic pattern using TryAddSingleton<IOrasDescriptorService, OrasDotNetService>() and builder.Services.TryAddSingleton<IOrasSignatureService>(sp => sp.GetRequiredService<IOrasDescriptorService>()). This ensures both interfaces resolve to the same singleton instance and is clearer about the intent. However, the current approach also works correctly.
| [Fact] | ||
| public async Task PushSignatureAsync_ThrowsForNullResult() | ||
| { | ||
| var service = CreateService(); | ||
| var subjectDescriptor = Descriptor.Create([], "application/vnd.oci.image.manifest.v1+json"); | ||
|
|
||
| #pragma warning disable CS8625 | ||
| var exception = await Should.ThrowAsync<NullReferenceException>(async () => | ||
| await service.PushSignatureAsync(subjectDescriptor, null)); | ||
| #pragma warning restore CS8625 | ||
|
|
||
| exception.ShouldNotBeNull(); | ||
| } |
There was a problem hiding this comment.
The test verifies that a NullReferenceException is thrown when null is passed. However, according to the C# coding guidelines, parameters should be explicitly validated and throw ArgumentNullException instead of allowing NullReferenceException. The PushSignatureAsync method should include parameter validation such as ArgumentNullException.ThrowIfNull(result) at the beginning of the method.
| # copy everything else and publish | ||
| COPY . ./ | ||
| RUN dotnet publish -r linux-$TARGETARCH ./ImageBuilder/Microsoft.DotNet.ImageBuilder.csproj --self-contained=true --no-restore -o out | ||
| RUN dotnet publish -r linux-$TARGETARCH ./ImageBuilder/Microsoft.DotNet.ImageBuilder.csproj --no-restore -o out |
There was a problem hiding this comment.
The --self-contained=true flag has been removed from the publish command. This changes the application from self-contained to framework-dependent deployment. This requires the .NET runtime to be present in the target container, which explains the change on line 34 from runtime-deps to runtime. While this reduces image size, ensure this change is intentional and document the reason in the PR description or commit message if not already done. Also verify that this doesn't break any deployment scenarios that might rely on self-contained deployment.
| public class BulkImageSigningService( | ||
| IPayloadSigningService payloadSigningService, | ||
| IOrasDescriptorService descriptorService, | ||
| IOrasSignatureService signatureService, | ||
| ILoggerService logger) : IBulkImageSigningService | ||
| { | ||
| private readonly IPayloadSigningService _payloadSigningService = payloadSigningService; | ||
| private readonly IOrasDescriptorService _descriptorService = descriptorService; | ||
| private readonly IOrasSignatureService _signatureService = signatureService; | ||
| private readonly ILoggerService _logger = logger; | ||
|
|
||
| /// <inheritdoc/> | ||
| public async Task<IReadOnlyList<ImageSigningResult>> SignImagesAsync( | ||
| IEnumerable<ImageSigningRequest> requests, | ||
| int signingKeyCode, | ||
| CancellationToken cancellationToken = default) | ||
| { | ||
| var requestList = requests.ToList(); | ||
| if (requestList.Count == 0) | ||
| { | ||
| return []; | ||
| } | ||
|
|
||
| _logger.WriteMessage($"Signing {requestList.Count} images..."); | ||
|
|
||
| // Step 1: Sign all payloads via ESRP | ||
| var signedPayloads = await _payloadSigningService.SignPayloadsAsync( | ||
| requestList, signingKeyCode, cancellationToken); | ||
|
|
||
| // Step 2: Push signatures to registry | ||
| var results = new List<ImageSigningResult>(); | ||
| foreach (var signedPayload in signedPayloads) | ||
| { | ||
| // Get the subject descriptor (the image being signed) | ||
| var subjectDescriptor = await _descriptorService.GetDescriptorAsync( | ||
| signedPayload.ImageName, cancellationToken); | ||
|
|
||
| // Push the signature as a referrer artifact | ||
| var signatureDigest = await _signatureService.PushSignatureAsync( | ||
| subjectDescriptor, signedPayload, cancellationToken); | ||
|
|
||
| results.Add(new ImageSigningResult(signedPayload.ImageName, signatureDigest)); | ||
| } | ||
|
|
||
| _logger.WriteMessage($"Successfully signed {results.Count} images."); | ||
|
|
||
| return results; | ||
| } | ||
| } |
There was a problem hiding this comment.
The PayloadSigningService, EsrpSigningService, and BulkImageSigningService classes lack automated tests. Given the complexity and criticality of signing operations, comprehensive unit tests should be added. Consider testing: empty input handling, file I/O operations, ESRP invocation, certificate chain calculation integration, and error scenarios.
| var manifestReferences = imageArtifactDetails.Repos | ||
| .SelectMany(repo => repo.Images | ||
| .Where(image => image.Manifest is not null && !string.IsNullOrEmpty(image.Manifest.Digest)) | ||
| .Select(image => new | ||
| { | ||
| Repo = repo.Repo, | ||
| Digest = image.Manifest!.Digest | ||
| })) | ||
| .ToList(); |
There was a problem hiding this comment.
Similar to the platform signing requests, the Repo field is extracted but never used. Only the Digest is used as the reference on line 82. Consider removing the unused Repo field or clarifying the design with a comment.
Related:
What's included in this PR:
Summary of types added
Signing
ImageSigningRequestPayloadSigningResultImageSigningResultBulkImageSigningService : IBulkImageSigningService- orchestrates signing and pushing signaturesEsrpSigningService : IEsrpSigningService- invokes DDSignFiles.dll via MicroBuildPayloadSigningService : IPayloadSigningService- writes payloads, signs via ESRP, calculates cert chainsSigningRequestGenerator : ISigningRequestGenerator- creates requests from ImageArtifactDetailsCertificateChainCalculator- static class to extract x5chain from COSE envelopesSigningServiceExtensions- DI registration extension methodsOras
OrasCredentialProviderAdapter- adapts ImageBuilder credentials to ORAS authOrasDotNetService : IOrasDescriptorService, IOrasSignatureService- ORAS .NET library implementationConfiguration
BuildConfiguration- for build/pipeline artifact settingsSigningConfiguration- for ESRP signing settingsTests
OrasCredentialProviderAdapterTestsSigningRequestGeneratorTestsWhat's not included in this PR:
TODO (before leaving draft mode):