Skip to content

Add signing implementation to ImageBuilder#1957

Draft
lbussell wants to merge 22 commits intodotnet:mainfrom
lbussell:signing-src
Draft

Add signing implementation to ImageBuilder#1957
lbussell wants to merge 22 commits intodotnet:mainfrom
lbussell:signing-src

Conversation

@lbussell
Copy link
Member

@lbussell lbussell commented Jan 30, 2026

Related:

What's included in this PR:

Summary of types added

Signing

  • Data types:
    • ImageSigningRequest
    • PayloadSigningResult
    • ImageSigningResult
  • Services:
    • BulkImageSigningService : IBulkImageSigningService - orchestrates signing and pushing signatures
    • EsrpSigningService : IEsrpSigningService - invokes DDSignFiles.dll via MicroBuild
    • PayloadSigningService : IPayloadSigningService - writes payloads, signs via ESRP, calculates cert chains
    • SigningRequestGenerator : ISigningRequestGenerator - creates requests from ImageArtifactDetails
  • CertificateChainCalculator - static class to extract x5chain from COSE envelopes
  • SigningServiceExtensions - DI registration extension methods

Oras

  • OrasCredentialProviderAdapter - adapts ImageBuilder credentials to ORAS auth
  • OrasDotNetService : IOrasDescriptorService, IOrasSignatureService - ORAS .NET library implementation

Configuration

  • BuildConfiguration - for build/pipeline artifact settings
  • SigningConfiguration - for ESRP signing settings

Tests

  • OrasCredentialProviderAdapterTests
  • SigningRequestGeneratorTests

What's not included in this PR:

  • Integration into Build or Post-Build commands or pipeline steps
  • Pipeline yaml
  • Comprehensive tests

TODO (before leaving draft mode):

  • Successful internal test run end-to-end in pipeline
  • Cleanup
  • Add more tests

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.
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.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 GenerateSigningPayloadsCommand in favor of new SignImagesCommand
  • 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

Comment on lines +51 to +52
public async Task<Descriptor> GetDescriptorAsync(string reference, CancellationToken cancellationToken = default)
{
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
public int ImageSigningKeyCode { get; set; }

/// <summary>
/// Certificate ID used by DDSignFiles.dll for signing referrer artifacts (SBOMs, etc.).
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/// 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.

Copilot uses AI. Check for mistakes.
Comment on lines +61 to +65
public async Task<string> PushSignatureAsync(
Descriptor subjectDescriptor,
PayloadSigningResult result,
CancellationToken cancellationToken = default)
{
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +38
var fileBytes = File.ReadAllBytes(signedPayloadPath);
var reader = new CborReader(fileBytes);

Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
var fileBytes = File.ReadAllBytes(signedPayloadPath);
var reader = new CborReader(fileBytes);
ArgumentException.ThrowIfNullOrWhiteSpace(signedPayloadPath);
var fileBytes = File.ReadAllBytes(signedPayloadPath);
var reader = new CborReader(fileBytes);

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +107
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}");
}
}
}
}
}
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +55
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>());
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +55
[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();
}
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
# 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
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +64
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;
}
}
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +66 to +74
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();
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant