Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOciAttestationAttacher.cs
|
||||
// Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T1)
|
||||
// Task: Create OciAttestationAttacher service interface
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Attestor.Oci.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for attaching and retrieving DSSE attestations from OCI registries.
|
||||
/// Implements OCI Distribution Spec 1.1 referrers API for cosign compatibility.
|
||||
/// </summary>
|
||||
public interface IOciAttestationAttacher
|
||||
{
|
||||
/// <summary>
|
||||
/// Attaches a DSSE attestation to an OCI artifact.
|
||||
/// </summary>
|
||||
/// <param name="imageRef">Reference to the OCI artifact.</param>
|
||||
/// <param name="attestation">DSSE envelope containing the attestation.</param>
|
||||
/// <param name="options">Attachment options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Result of the attachment operation.</returns>
|
||||
Task<AttachmentResult> AttachAsync(
|
||||
OciReference imageRef,
|
||||
DsseEnvelope attestation,
|
||||
AttachmentOptions? options = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all attestations attached to an OCI artifact.
|
||||
/// </summary>
|
||||
/// <param name="imageRef">Reference to the OCI artifact.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of attached attestations.</returns>
|
||||
Task<IReadOnlyList<AttachedAttestation>> ListAsync(
|
||||
OciReference imageRef,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a specific attestation by predicate type.
|
||||
/// </summary>
|
||||
/// <param name="imageRef">Reference to the OCI artifact.</param>
|
||||
/// <param name="predicateType">Predicate type URI to filter by.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The DSSE envelope if found, null otherwise.</returns>
|
||||
Task<DsseEnvelope?> FetchAsync(
|
||||
OciReference imageRef,
|
||||
string predicateType,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an attestation from an OCI artifact.
|
||||
/// </summary>
|
||||
/// <param name="imageRef">Reference to the OCI artifact.</param>
|
||||
/// <param name="attestationDigest">Digest of the attestation to remove.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if removed, false if not found.</returns>
|
||||
Task<bool> RemoveAsync(
|
||||
OciReference imageRef,
|
||||
string attestationDigest,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an OCI artifact.
|
||||
/// </summary>
|
||||
public sealed record OciReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry hostname (e.g., "registry.example.com").
|
||||
/// </summary>
|
||||
public required string Registry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Repository name (e.g., "myorg/myapp").
|
||||
/// </summary>
|
||||
public required string Repository { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressable digest (e.g., "sha256:abc123...").
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tag (e.g., "v1.0.0").
|
||||
/// </summary>
|
||||
public string? Tag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full reference string.
|
||||
/// </summary>
|
||||
public string FullReference => Tag is not null
|
||||
? $"{Registry}/{Repository}:{Tag}"
|
||||
: $"{Registry}/{Repository}@{Digest}";
|
||||
|
||||
/// <summary>
|
||||
/// Parses an OCI reference string.
|
||||
/// </summary>
|
||||
public static OciReference Parse(string reference)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(reference);
|
||||
|
||||
// Handle digest references: registry/repo@sha256:...
|
||||
var digestIndex = reference.IndexOf('@');
|
||||
if (digestIndex > 0)
|
||||
{
|
||||
var beforeDigest = reference[..digestIndex];
|
||||
var digest = reference[(digestIndex + 1)..];
|
||||
var (registry, repo) = ParseRegistryAndRepo(beforeDigest);
|
||||
return new OciReference
|
||||
{
|
||||
Registry = registry,
|
||||
Repository = repo,
|
||||
Digest = digest
|
||||
};
|
||||
}
|
||||
|
||||
// Handle tag references: registry/repo:tag
|
||||
var tagIndex = reference.LastIndexOf(':');
|
||||
if (tagIndex > 0)
|
||||
{
|
||||
var beforeTag = reference[..tagIndex];
|
||||
var tag = reference[(tagIndex + 1)..];
|
||||
|
||||
// Check if this is actually a port number
|
||||
if (!beforeTag.Contains('/') || tag.Contains('/'))
|
||||
{
|
||||
throw new ArgumentException($"Invalid OCI reference: {reference}", nameof(reference));
|
||||
}
|
||||
|
||||
var (registry, repo) = ParseRegistryAndRepo(beforeTag);
|
||||
return new OciReference
|
||||
{
|
||||
Registry = registry,
|
||||
Repository = repo,
|
||||
Digest = string.Empty, // Will be resolved
|
||||
Tag = tag
|
||||
};
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Invalid OCI reference: {reference}", nameof(reference));
|
||||
}
|
||||
|
||||
private static (string Registry, string Repo) ParseRegistryAndRepo(string reference)
|
||||
{
|
||||
var firstSlash = reference.IndexOf('/');
|
||||
if (firstSlash < 0)
|
||||
{
|
||||
throw new ArgumentException($"Invalid OCI reference: {reference}");
|
||||
}
|
||||
|
||||
var registry = reference[..firstSlash];
|
||||
var repo = reference[(firstSlash + 1)..];
|
||||
|
||||
return (registry, repo);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for attestation attachment.
|
||||
/// </summary>
|
||||
public sealed record AttachmentOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Media type for the attestation. Default: DSSE envelope.
|
||||
/// </summary>
|
||||
public string MediaType { get; init; } = MediaTypes.DsseEnvelope;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to replace existing attestations with the same predicate type.
|
||||
/// </summary>
|
||||
public bool ReplaceExisting { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Additional OCI annotations to attach.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to record in Sigstore Rekor transparency log.
|
||||
/// </summary>
|
||||
public bool RecordInRekor { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an attestation attachment operation.
|
||||
/// </summary>
|
||||
public sealed record AttachmentResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressable digest of the attestation blob.
|
||||
/// </summary>
|
||||
public required string AttestationDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full OCI reference to the attached attestation manifest.
|
||||
/// </summary>
|
||||
public required string AttestationRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when attachment completed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset AttachedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log entry ID if recorded in transparency log.
|
||||
/// </summary>
|
||||
public string? RekorLogId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an attestation attached to an OCI artifact.
|
||||
/// </summary>
|
||||
public sealed record AttachedAttestation
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressable digest of the attestation.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Predicate type URI (e.g., "https://in-toto.io/attestation/vulns/v0.1").
|
||||
/// </summary>
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when attestation was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OCI annotations on the attestation manifest.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the attestation blob in bytes.
|
||||
/// </summary>
|
||||
public long Size { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standard media types for attestation artifacts.
|
||||
/// </summary>
|
||||
public static class MediaTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// DSSE envelope media type.
|
||||
/// </summary>
|
||||
public const string DsseEnvelope = "application/vnd.dsse.envelope.v1+json";
|
||||
|
||||
/// <summary>
|
||||
/// In-toto attestation bundle media type.
|
||||
/// </summary>
|
||||
public const string InTotoBundle = "application/vnd.in-toto+json";
|
||||
|
||||
/// <summary>
|
||||
/// Sigstore bundle media type.
|
||||
/// </summary>
|
||||
public const string SigstoreBundle = "application/vnd.dev.sigstore.bundle.v0.3+json";
|
||||
|
||||
/// <summary>
|
||||
/// OCI image manifest media type.
|
||||
/// </summary>
|
||||
public const string OciManifest = "application/vnd.oci.image.manifest.v1+json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standard annotation keys for attestation metadata.
|
||||
/// </summary>
|
||||
public static class AnnotationKeys
|
||||
{
|
||||
/// <summary>
|
||||
/// OCI standard: creation timestamp.
|
||||
/// </summary>
|
||||
public const string Created = "org.opencontainers.image.created";
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps: predicate type.
|
||||
/// </summary>
|
||||
public const string PredicateType = "dev.stellaops/predicate-type";
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps: signer identity.
|
||||
/// </summary>
|
||||
public const string SignerIdentity = "dev.stellaops/signer-identity";
|
||||
|
||||
/// <summary>
|
||||
/// Cosign compatibility: signature placeholder.
|
||||
/// </summary>
|
||||
public const string CosignSignature = "dev.sigstore.cosign/signature";
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index.
|
||||
/// </summary>
|
||||
public const string RekorLogIndex = "dev.sigstore.rekor/logIndex";
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOciRegistryClient.cs
|
||||
// Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T2)
|
||||
// Task: Define OCI registry client interface for ORAS operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Oci.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for OCI registry operations using OCI Distribution Spec 1.1.
|
||||
/// </summary>
|
||||
public interface IOciRegistryClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes a blob to the registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="content">Blob content.</param>
|
||||
/// <param name="digest">Expected content digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task PushBlobAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
ReadOnlyMemory<byte> content,
|
||||
string digest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a blob from the registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="digest">Blob digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Blob content.</returns>
|
||||
Task<ReadOnlyMemory<byte>> FetchBlobAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string digest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a manifest to the registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="manifest">OCI manifest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Manifest digest.</returns>
|
||||
Task<string> PushManifestAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
OciManifest manifest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a manifest from the registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="reference">Digest or tag.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>OCI manifest.</returns>
|
||||
Task<OciManifest> FetchManifestAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string reference,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists referrers to an artifact using OCI Distribution Spec 1.1 referrers API.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="digest">Subject artifact digest.</param>
|
||||
/// <param name="artifactType">Optional artifact type filter.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of referrer descriptors.</returns>
|
||||
Task<IReadOnlyList<OciDescriptor>> ListReferrersAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string digest,
|
||||
string? artifactType = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a manifest from the registry.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="digest">Manifest digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if deleted, false if not found.</returns>
|
||||
Task<bool> DeleteManifestAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string digest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a tag to a digest.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="tag">Tag to resolve.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Content digest.</returns>
|
||||
Task<string> ResolveTagAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string tag,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCI manifest structure per OCI Image Spec.
|
||||
/// </summary>
|
||||
public sealed record OciManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version (always 2).
|
||||
/// </summary>
|
||||
public int SchemaVersion { get; init; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Media type of this manifest.
|
||||
/// </summary>
|
||||
public string MediaType { get; init; } = MediaTypes.OciManifest;
|
||||
|
||||
/// <summary>
|
||||
/// Optional artifact type for OCI 1.1 artifacts.
|
||||
/// </summary>
|
||||
public string? ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Config descriptor.
|
||||
/// </summary>
|
||||
public required OciDescriptor Config { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Layer descriptors.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<OciDescriptor> Layers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject descriptor for OCI 1.1 referrers.
|
||||
/// </summary>
|
||||
public OciDescriptor? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotations on this manifest.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCI content descriptor.
|
||||
/// </summary>
|
||||
public sealed record OciDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Media type of the referenced content.
|
||||
/// </summary>
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressable digest.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional annotations.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional artifact type (for OCI 1.1).
|
||||
/// </summary>
|
||||
public string? ArtifactType { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OrasAttestationAttacher.cs
|
||||
// Sprint: SPRINT_20251228_002_BE_oci_attestation_attach (T2)
|
||||
// Task: Implement OCI registry attachment via ORAS
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Attestor.Oci.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="IOciAttestationAttacher"/> using OCI Distribution Spec 1.1.
|
||||
/// Stores attestations as OCI artifacts with subject references for cosign compatibility.
|
||||
/// </summary>
|
||||
public sealed class OrasAttestationAttacher : IOciAttestationAttacher
|
||||
{
|
||||
private readonly IOciRegistryClient _registryClient;
|
||||
private readonly ILogger<OrasAttestationAttacher> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public OrasAttestationAttacher(
|
||||
IOciRegistryClient registryClient,
|
||||
ILogger<OrasAttestationAttacher> logger)
|
||||
{
|
||||
_registryClient = registryClient ?? throw new ArgumentNullException(nameof(registryClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AttachmentResult> AttachAsync(
|
||||
OciReference imageRef,
|
||||
DsseEnvelope attestation,
|
||||
AttachmentOptions? options = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(imageRef);
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
|
||||
options ??= new AttachmentOptions();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Attaching attestation to {Registry}/{Repository}@{Digest}",
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
TruncateDigest(imageRef.Digest));
|
||||
|
||||
// 1. Serialize DSSE envelope to canonical JSON
|
||||
var attestationBytes = SerializeCanonical(attestation);
|
||||
var attestationDigest = ComputeDigest(attestationBytes);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Attestation serialized: {Size} bytes, digest {Digest}",
|
||||
attestationBytes.Length,
|
||||
TruncateDigest(attestationDigest));
|
||||
|
||||
// 2. Check for existing attestation if ReplaceExisting=false
|
||||
if (!options.ReplaceExisting)
|
||||
{
|
||||
var existing = await FindExistingAttestationAsync(
|
||||
imageRef,
|
||||
attestation.PayloadType,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attestation with predicate type {PredicateType} already exists at {Digest}",
|
||||
attestation.PayloadType,
|
||||
TruncateDigest(existing.Digest));
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Attestation with predicate type '{attestation.PayloadType}' already exists. " +
|
||||
"Use ReplaceExisting=true to overwrite.");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Push attestation blob
|
||||
await _registryClient.PushBlobAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
attestationBytes,
|
||||
attestationDigest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Pushed attestation blob {Digest}", TruncateDigest(attestationDigest));
|
||||
|
||||
// 4. Create empty config blob (required by OCI spec)
|
||||
var emptyConfig = "{}"u8.ToArray();
|
||||
var emptyConfigDigest = ComputeDigest(emptyConfig);
|
||||
|
||||
await _registryClient.PushBlobAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
emptyConfig,
|
||||
emptyConfigDigest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
// 5. Build manifest with subject reference
|
||||
var annotations = BuildAnnotations(attestation, options);
|
||||
var manifest = new OciManifest
|
||||
{
|
||||
SchemaVersion = 2,
|
||||
MediaType = MediaTypes.OciManifest,
|
||||
ArtifactType = options.MediaType,
|
||||
Subject = new OciDescriptor
|
||||
{
|
||||
MediaType = MediaTypes.OciManifest,
|
||||
Digest = imageRef.Digest,
|
||||
Size = 0 // Referrer doesn't need subject size
|
||||
},
|
||||
Config = new OciDescriptor
|
||||
{
|
||||
MediaType = "application/vnd.oci.empty.v1+json",
|
||||
Digest = emptyConfigDigest,
|
||||
Size = emptyConfig.Length
|
||||
},
|
||||
Layers =
|
||||
[
|
||||
new OciDescriptor
|
||||
{
|
||||
MediaType = options.MediaType,
|
||||
Digest = attestationDigest,
|
||||
Size = attestationBytes.Length,
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
[AnnotationKeys.PredicateType] = attestation.PayloadType
|
||||
}
|
||||
}
|
||||
],
|
||||
Annotations = annotations
|
||||
};
|
||||
|
||||
// 6. Push manifest
|
||||
var manifestDigest = await _registryClient.PushManifestAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
manifest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Attached attestation {PredicateType} to {Registry}/{Repository}@{ImageDigest} as {ManifestDigest}",
|
||||
attestation.PayloadType,
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
TruncateDigest(imageRef.Digest),
|
||||
TruncateDigest(manifestDigest));
|
||||
|
||||
return new AttachmentResult
|
||||
{
|
||||
AttestationDigest = attestationDigest,
|
||||
AttestationRef = $"{imageRef.Registry}/{imageRef.Repository}@{manifestDigest}",
|
||||
AttachedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<AttachedAttestation>> ListAsync(
|
||||
OciReference imageRef,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(imageRef);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Listing attestations for {Registry}/{Repository}@{Digest}",
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
TruncateDigest(imageRef.Digest));
|
||||
|
||||
var referrers = await _registryClient.ListReferrersAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
imageRef.Digest,
|
||||
artifactType: null, // Get all types
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var attestations = new List<AttachedAttestation>();
|
||||
|
||||
foreach (var referrer in referrers)
|
||||
{
|
||||
// Filter to DSSE envelope types
|
||||
if (referrer.MediaType != MediaTypes.DsseEnvelope &&
|
||||
referrer.ArtifactType != MediaTypes.DsseEnvelope)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var predicateType = referrer.Annotations?.GetValueOrDefault(AnnotationKeys.PredicateType)
|
||||
?? "unknown";
|
||||
|
||||
var createdAtStr = referrer.Annotations?.GetValueOrDefault(AnnotationKeys.Created);
|
||||
var createdAt = DateTimeOffset.TryParse(createdAtStr, out var dt)
|
||||
? dt
|
||||
: DateTimeOffset.MinValue;
|
||||
|
||||
attestations.Add(new AttachedAttestation
|
||||
{
|
||||
Digest = referrer.Digest,
|
||||
PredicateType = predicateType,
|
||||
CreatedAt = createdAt,
|
||||
Annotations = referrer.Annotations,
|
||||
Size = referrer.Size
|
||||
});
|
||||
}
|
||||
|
||||
// Deterministic ordering: by predicate type, then by creation time (newest first)
|
||||
return attestations
|
||||
.OrderBy(a => a.PredicateType, StringComparer.Ordinal)
|
||||
.ThenByDescending(a => a.CreatedAt)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<DsseEnvelope?> FetchAsync(
|
||||
OciReference imageRef,
|
||||
string predicateType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(imageRef);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(predicateType);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Fetching attestation {PredicateType} from {Registry}/{Repository}@{Digest}",
|
||||
predicateType,
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
TruncateDigest(imageRef.Digest));
|
||||
|
||||
var attestations = await ListAsync(imageRef, ct).ConfigureAwait(false);
|
||||
var target = attestations.FirstOrDefault(a => a.PredicateType == predicateType);
|
||||
|
||||
if (target is null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No attestation with predicate type {PredicateType} found",
|
||||
predicateType);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch the attestation manifest to get the layer digest
|
||||
var manifest = await _registryClient.FetchManifestAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
target.Digest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (manifest.Layers.Count == 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attestation manifest {Digest} has no layers",
|
||||
TruncateDigest(target.Digest));
|
||||
return null;
|
||||
}
|
||||
|
||||
var layerDigest = manifest.Layers[0].Digest;
|
||||
|
||||
// Fetch the attestation blob
|
||||
var blobBytes = await _registryClient.FetchBlobAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
layerDigest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
return DeserializeEnvelope(blobBytes);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> RemoveAsync(
|
||||
OciReference imageRef,
|
||||
string attestationDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(imageRef);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(attestationDigest);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Removing attestation {AttestationDigest} from {Registry}/{Repository}@{Digest}",
|
||||
TruncateDigest(attestationDigest),
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
TruncateDigest(imageRef.Digest));
|
||||
|
||||
return await _registryClient.DeleteManifestAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
attestationDigest,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<AttachedAttestation?> FindExistingAttestationAsync(
|
||||
OciReference imageRef,
|
||||
string predicateType,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var attestations = await ListAsync(imageRef, ct).ConfigureAwait(false);
|
||||
return attestations.FirstOrDefault(a => a.PredicateType == predicateType);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildAnnotations(
|
||||
DsseEnvelope envelope,
|
||||
AttachmentOptions options)
|
||||
{
|
||||
var annotations = new Dictionary<string, string>
|
||||
{
|
||||
[AnnotationKeys.Created] = DateTimeOffset.UtcNow.ToString("O"),
|
||||
[AnnotationKeys.PredicateType] = envelope.PayloadType,
|
||||
[AnnotationKeys.CosignSignature] = "" // Cosign compatibility placeholder
|
||||
};
|
||||
|
||||
// Add signer identity if available
|
||||
var firstSignature = envelope.Signatures.FirstOrDefault();
|
||||
if (firstSignature?.KeyId is not null)
|
||||
{
|
||||
annotations[AnnotationKeys.SignerIdentity] = firstSignature.KeyId;
|
||||
}
|
||||
|
||||
// Merge user-provided annotations
|
||||
if (options.Annotations is not null)
|
||||
{
|
||||
foreach (var (key, value) in options.Annotations)
|
||||
{
|
||||
annotations[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
|
||||
private static byte[] SerializeCanonical(DsseEnvelope envelope)
|
||||
{
|
||||
// Use the serializer from StellaOps.Attestor.Envelope
|
||||
var options = new DsseEnvelopeSerializationOptions
|
||||
{
|
||||
EmitCompactJson = true,
|
||||
EmitExpandedJson = false
|
||||
};
|
||||
|
||||
var result = DsseEnvelopeSerializer.Serialize(envelope, options);
|
||||
|
||||
return result.CompactJson ?? throw new InvalidOperationException(
|
||||
"Failed to serialize DSSE envelope to compact JSON");
|
||||
}
|
||||
|
||||
private static DsseEnvelope DeserializeEnvelope(ReadOnlyMemory<byte> bytes)
|
||||
{
|
||||
// Parse the compact DSSE envelope format
|
||||
var json = JsonDocument.Parse(bytes);
|
||||
var root = json.RootElement;
|
||||
|
||||
var payloadType = root.GetProperty("payloadType").GetString()
|
||||
?? throw new InvalidOperationException("Missing payloadType");
|
||||
|
||||
var payloadBase64 = root.GetProperty("payload").GetString()
|
||||
?? throw new InvalidOperationException("Missing payload");
|
||||
|
||||
var payload = Convert.FromBase64String(payloadBase64);
|
||||
|
||||
var signatures = new List<DsseSignature>();
|
||||
if (root.TryGetProperty("signatures", out var sigsElement))
|
||||
{
|
||||
foreach (var sigElement in sigsElement.EnumerateArray())
|
||||
{
|
||||
var keyId = sigElement.TryGetProperty("keyid", out var keyIdProp)
|
||||
? keyIdProp.GetString()
|
||||
: null;
|
||||
|
||||
var sig = sigElement.GetProperty("sig").GetString()
|
||||
?? throw new InvalidOperationException("Missing sig");
|
||||
|
||||
signatures.Add(new DsseSignature(signature: sig, keyId: keyId));
|
||||
}
|
||||
}
|
||||
|
||||
return new DsseEnvelope(payloadType, payload, signatures);
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static string TruncateDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex < 0 || digest.Length < colonIndex + 13)
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
|
||||
return digest[..(colonIndex + 13)] + "...";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Attestor.Oci</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user