Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -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";
}

View File

@@ -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; }
}

View File

@@ -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)] + "...";
}
}

View File

@@ -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>