Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

@@ -0,0 +1,200 @@
using System.Security.Cryptography;
using System.Text;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
internal sealed class DsseSignatureVerifier : IDsseSignatureVerifier
{
public DsseSignatureVerificationResult Verify(
string payloadType,
string payloadBase64,
IReadOnlyList<DsseSignatureInput> signatures,
TrustPolicyContext policy)
{
if (signatures.Count == 0)
{
return new DsseSignatureVerificationResult
{
IsValid = false,
Error = "dsse-signatures-missing"
};
}
if (policy.Keys.Count == 0)
{
return new DsseSignatureVerificationResult
{
IsValid = false,
Error = "trust-policy-keys-missing"
};
}
byte[] payloadBytes;
try
{
payloadBytes = Convert.FromBase64String(payloadBase64);
}
catch
{
return new DsseSignatureVerificationResult
{
IsValid = false,
Error = "dsse-payload-invalid"
};
}
var pae = BuildPae(payloadType, payloadBytes);
string? lastError = null;
foreach (var signature in signatures)
{
var key = FindKey(signature.KeyId, policy.Keys);
if (key is null)
{
continue;
}
if (!TryDecodeSignature(signature.SignatureBase64, out var signatureBytes))
{
lastError = "dsse-signature-invalid";
continue;
}
if (TryVerifySignature(key, pae, signatureBytes, out var error))
{
return new DsseSignatureVerificationResult
{
IsValid = true,
KeyId = signature.KeyId
};
}
lastError = error;
}
return new DsseSignatureVerificationResult
{
IsValid = false,
Error = lastError ?? "dsse-signature-untrusted"
};
}
private static TrustPolicyKeyMaterial? FindKey(string keyId, IReadOnlyList<TrustPolicyKeyMaterial> keys)
{
foreach (var key in keys)
{
if (string.Equals(key.KeyId, keyId, StringComparison.OrdinalIgnoreCase) ||
string.Equals(key.Fingerprint, keyId, StringComparison.OrdinalIgnoreCase))
{
return key;
}
}
return null;
}
private static bool TryDecodeSignature(string signatureBase64, out byte[] signature)
{
try
{
signature = Convert.FromBase64String(signatureBase64);
return true;
}
catch
{
signature = Array.Empty<byte>();
return false;
}
}
private static bool TryVerifySignature(
TrustPolicyKeyMaterial key,
byte[] pae,
byte[] signature,
out string error)
{
error = "dsse-signature-invalid";
var algorithm = key.Algorithm.ToLowerInvariant();
if (algorithm.Contains("ed25519", StringComparison.Ordinal))
{
error = "dsse-algorithm-unsupported";
return false;
}
if (algorithm.Contains("es", StringComparison.Ordinal) || algorithm.Contains("ecdsa", StringComparison.Ordinal))
{
return TryVerifyEcdsa(key.PublicKey, pae, signature, out error);
}
if (algorithm.Contains("rsa", StringComparison.Ordinal) || algorithm.Contains("pss", StringComparison.Ordinal))
{
return TryVerifyRsa(key.PublicKey, pae, signature, out error);
}
if (TryVerifyRsa(key.PublicKey, pae, signature, out error))
{
return true;
}
return TryVerifyEcdsa(key.PublicKey, pae, signature, out error);
}
private static bool TryVerifyRsa(byte[] publicKey, byte[] pae, byte[] signature, out string error)
{
error = "dsse-signature-invalid";
try
{
using var rsa = RSA.Create();
rsa.ImportSubjectPublicKeyInfo(publicKey, out _);
return rsa.VerifyData(pae, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
}
catch
{
error = "dsse-signature-verification-failed";
return false;
}
}
private static bool TryVerifyEcdsa(byte[] publicKey, byte[] pae, byte[] signature, out string error)
{
error = "dsse-signature-invalid";
try
{
using var ecdsa = ECDsa.Create();
ecdsa.ImportSubjectPublicKeyInfo(publicKey, out _);
return ecdsa.VerifyData(pae, signature, HashAlgorithmName.SHA256);
}
catch
{
error = "dsse-signature-verification-failed";
return false;
}
}
private static byte[] BuildPae(string payloadType, byte[] payload)
{
var header = Encoding.UTF8.GetBytes("DSSEv1");
var pt = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
var lenPt = Encoding.UTF8.GetBytes(pt.Length.ToString());
var lenPayload = Encoding.UTF8.GetBytes(payload.Length.ToString());
var space = new[] { (byte)' ' };
return Concat(header, space, lenPt, space, pt, space, lenPayload, space, payload);
}
private static byte[] Concat(params byte[][] parts)
{
var length = parts.Sum(part => part.Length);
var buffer = new byte[length];
var offset = 0;
foreach (var part in parts)
{
Buffer.BlockCopy(part, 0, buffer, offset, part.Length);
offset += part.Length;
}
return buffer;
}
}

View File

@@ -0,0 +1,21 @@
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
internal interface IDsseSignatureVerifier
{
DsseSignatureVerificationResult Verify(string payloadType, string payloadBase64, IReadOnlyList<DsseSignatureInput> signatures, TrustPolicyContext policy);
}
internal sealed record DsseSignatureVerificationResult
{
public required bool IsValid { get; init; }
public string? KeyId { get; init; }
public string? Error { get; init; }
}
internal sealed record DsseSignatureInput
{
public required string KeyId { get; init; }
public required string SignatureBase64 { get; init; }
}

View File

@@ -0,0 +1,8 @@
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
public interface IImageAttestationVerifier
{
Task<ImageVerificationResult> VerifyAsync(ImageVerificationRequest request, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,23 @@
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
public interface IOciRegistryClient
{
Task<string> ResolveDigestAsync(OciImageReference reference, CancellationToken cancellationToken = default);
Task<OciReferrersResponse> ListReferrersAsync(
OciImageReference reference,
string digest,
CancellationToken cancellationToken = default);
Task<OciManifest> GetManifestAsync(
OciImageReference reference,
string digest,
CancellationToken cancellationToken = default);
Task<byte[]> GetBlobAsync(
OciImageReference reference,
string digest,
CancellationToken cancellationToken = default);
}

View File

@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
@@ -44,6 +44,13 @@ internal interface ISbomClient
SbomExportRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Uploads an SBOM for BYOS ingestion.
/// </summary>
Task<SbomUploadResponse?> UploadAsync(
SbomUploadRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets the parity matrix showing CLI command coverage.
/// </summary>

View File

@@ -0,0 +1,8 @@
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
public interface ITrustPolicyLoader
{
Task<TrustPolicyContext> LoadAsync(string path, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,453 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
public sealed class ImageAttestationVerifier : IImageAttestationVerifier
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly IOciRegistryClient _registryClient;
private readonly ITrustPolicyLoader _trustPolicyLoader;
private readonly IDsseSignatureVerifier _dsseVerifier;
private readonly ILogger<ImageAttestationVerifier> _logger;
public ImageAttestationVerifier(
IOciRegistryClient registryClient,
ITrustPolicyLoader trustPolicyLoader,
IDsseSignatureVerifier dsseVerifier,
ILogger<ImageAttestationVerifier> logger)
{
_registryClient = registryClient ?? throw new ArgumentNullException(nameof(registryClient));
_trustPolicyLoader = trustPolicyLoader ?? throw new ArgumentNullException(nameof(trustPolicyLoader));
_dsseVerifier = dsseVerifier ?? throw new ArgumentNullException(nameof(dsseVerifier));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ImageVerificationResult> VerifyAsync(
ImageVerificationRequest request,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(request.Reference))
{
throw new ArgumentException("Image reference is required.", nameof(request));
}
var reference = OciImageReferenceParser.Parse(request.Reference);
var digest = await _registryClient.ResolveDigestAsync(reference, cancellationToken).ConfigureAwait(false);
var policy = request.TrustPolicyPath is not null
? await _trustPolicyLoader.LoadAsync(request.TrustPolicyPath, cancellationToken).ConfigureAwait(false)
: CreateDefaultTrustPolicy();
var result = new ImageVerificationResult
{
ImageReference = request.Reference,
ImageDigest = digest,
Registry = reference.Registry,
Repository = reference.Repository,
VerifiedAt = DateTimeOffset.UtcNow
};
OciReferrersResponse referrers;
try
{
referrers = await _registryClient.ListReferrersAsync(reference, digest, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to list OCI referrers for {Reference}", request.Reference);
result.Errors.Add($"Failed to list referrers: {ex.Message}");
result.IsValid = false;
return result;
}
var orderedReferrers = (referrers.Referrers ?? new List<OciReferrerDescriptor>())
.OrderBy(r => r.Digest, StringComparer.Ordinal)
.ToList();
var referrersByType = orderedReferrers
.GroupBy(ResolveAttestationType)
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
foreach (var requiredType in request.RequiredTypes)
{
var verification = await VerifyAttestationTypeAsync(
reference,
requiredType,
referrersByType,
policy,
cancellationToken).ConfigureAwait(false);
result.Attestations.Add(verification);
}
result.MissingTypes = result.Attestations
.Where(attestation => attestation.Status == AttestationStatus.Missing)
.Select(attestation => attestation.Type)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(type => type, StringComparer.OrdinalIgnoreCase)
.ToList();
var hasInvalid = result.Attestations.Any(attestation => attestation.Status is AttestationStatus.Invalid or AttestationStatus.Expired or AttestationStatus.UntrustedSigner);
if (request.Strict)
{
result.IsValid = !hasInvalid && result.Attestations.All(attestation => attestation.Status == AttestationStatus.Verified);
}
else
{
result.IsValid = !hasInvalid;
}
return result;
}
private static TrustPolicyContext CreateDefaultTrustPolicy()
{
return new TrustPolicyContext
{
Policy = new TrustPolicy(),
Keys = Array.Empty<TrustPolicyKeyMaterial>(),
RequireRekor = false,
MaxAge = null
};
}
private async Task<AttestationVerification> VerifyAttestationTypeAsync(
OciImageReference reference,
string type,
Dictionary<string, List<OciReferrerDescriptor>> referrersByType,
TrustPolicyContext policy,
CancellationToken cancellationToken)
{
if (!referrersByType.TryGetValue(type, out var referrers) || referrers.Count == 0)
{
return new AttestationVerification
{
Type = type,
IsValid = false,
Status = AttestationStatus.Missing,
Message = $"No {type} attestation found"
};
}
var candidate = referrers
.OrderByDescending(GetCreatedAt)
.ThenBy(r => r.Digest, StringComparer.Ordinal)
.First();
try
{
var manifest = await _registryClient.GetManifestAsync(reference, candidate.Digest, cancellationToken).ConfigureAwait(false);
var layer = SelectDsseLayer(manifest);
if (layer is null)
{
return new AttestationVerification
{
Type = type,
IsValid = false,
Status = AttestationStatus.Invalid,
Digest = candidate.Digest,
Message = "DSSE layer not found"
};
}
var blob = await _registryClient.GetBlobAsync(reference, layer.Digest, cancellationToken).ConfigureAwait(false);
var payload = await DecodeLayerAsync(layer, blob, cancellationToken).ConfigureAwait(false);
var envelope = ParseEnvelope(payload);
var signatures = envelope.Signatures
.Where(signature => !string.IsNullOrWhiteSpace(signature.KeyId) && !string.IsNullOrWhiteSpace(signature.Signature))
.Select(signature => new DsseSignatureInput
{
KeyId = signature.KeyId!,
SignatureBase64 = signature.Signature!
})
.ToList();
if (signatures.Count == 0)
{
return new AttestationVerification
{
Type = type,
IsValid = false,
Status = AttestationStatus.Invalid,
Digest = candidate.Digest,
Message = "DSSE signatures missing"
};
}
var verification = _dsseVerifier.Verify(envelope.PayloadType, envelope.Payload, signatures, policy);
if (!verification.IsValid)
{
return new AttestationVerification
{
Type = type,
IsValid = false,
Status = MapFailureToStatus(verification.Error),
Digest = candidate.Digest,
SignerIdentity = verification.KeyId,
Message = verification.Error ?? "Signature verification failed",
VerifiedAt = DateTimeOffset.UtcNow
};
}
var signerKeyId = verification.KeyId ?? signatures[0].KeyId;
if (!IsSignerAllowed(policy, type, signerKeyId))
{
return new AttestationVerification
{
Type = type,
IsValid = false,
Status = AttestationStatus.UntrustedSigner,
Digest = candidate.Digest,
SignerIdentity = signerKeyId,
Message = "Signer not allowed by trust policy",
VerifiedAt = DateTimeOffset.UtcNow
};
}
if (policy.RequireRekor && !HasRekorReceipt(candidate))
{
return new AttestationVerification
{
Type = type,
IsValid = false,
Status = AttestationStatus.Invalid,
Digest = candidate.Digest,
SignerIdentity = signerKeyId,
Message = "Rekor receipt missing",
VerifiedAt = DateTimeOffset.UtcNow
};
}
if (policy.MaxAge.HasValue)
{
var created = GetCreatedAt(candidate);
if (created.HasValue && DateTimeOffset.UtcNow - created.Value > policy.MaxAge.Value)
{
return new AttestationVerification
{
Type = type,
IsValid = false,
Status = AttestationStatus.Expired,
Digest = candidate.Digest,
SignerIdentity = signerKeyId,
Message = "Attestation exceeded max age",
VerifiedAt = DateTimeOffset.UtcNow
};
}
}
return new AttestationVerification
{
Type = type,
IsValid = true,
Status = AttestationStatus.Verified,
Digest = candidate.Digest,
SignerIdentity = signerKeyId,
Message = "Signature valid",
VerifiedAt = DateTimeOffset.UtcNow
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to verify attestation {Type}", type);
return new AttestationVerification
{
Type = type,
IsValid = false,
Status = AttestationStatus.Invalid,
Digest = candidate.Digest,
Message = ex.Message
};
}
}
private static AttestationStatus MapFailureToStatus(string? error) => error switch
{
"trust-policy-keys-missing" => AttestationStatus.UntrustedSigner,
"dsse-signature-untrusted" => AttestationStatus.UntrustedSigner,
"dsse-signature-untrusted-or-invalid" => AttestationStatus.UntrustedSigner,
_ => AttestationStatus.Invalid
};
private static bool IsSignerAllowed(TrustPolicyContext policy, string type, string signerKeyId)
{
if (!policy.Policy.Attestations.TryGetValue(type, out var attestation) ||
attestation.Signers.Count == 0)
{
return true;
}
return attestation.Signers.Any(signer => MatchPattern(signer.Identity, signerKeyId));
}
private static bool MatchPattern(string? pattern, string value)
{
if (string.IsNullOrWhiteSpace(pattern))
{
return false;
}
if (pattern == "*")
{
return true;
}
if (!pattern.Contains('*', StringComparison.Ordinal))
{
return string.Equals(pattern, value, StringComparison.OrdinalIgnoreCase);
}
var parts = pattern.Split('*');
var index = 0;
foreach (var part in parts)
{
if (string.IsNullOrEmpty(part))
{
continue;
}
var next = value.IndexOf(part, index, StringComparison.OrdinalIgnoreCase);
if (next < 0)
{
return false;
}
index = next + part.Length;
}
return true;
}
private static DateTimeOffset? GetCreatedAt(OciReferrerDescriptor referrer)
{
if (referrer.Annotations is null)
{
return null;
}
if (referrer.Annotations.TryGetValue("created", out var created) ||
referrer.Annotations.TryGetValue("org.opencontainers.image.created", out created))
{
if (DateTimeOffset.TryParse(created, out var parsed))
{
return parsed;
}
}
return null;
}
private static bool HasRekorReceipt(OciReferrerDescriptor referrer)
{
if (referrer.Annotations is null)
{
return false;
}
return referrer.Annotations.Keys.Any(key =>
key.Contains("rekor", StringComparison.OrdinalIgnoreCase) ||
key.Contains("transparency", StringComparison.OrdinalIgnoreCase));
}
private static string ResolveAttestationType(OciReferrerDescriptor referrer)
{
var candidate = referrer.ArtifactType ?? referrer.MediaType ?? string.Empty;
if (referrer.Annotations is not null)
{
if (referrer.Annotations.TryGetValue("predicateType", out var predicateType) ||
referrer.Annotations.TryGetValue("predicate-type", out predicateType))
{
candidate = $"{candidate} {predicateType}";
}
}
if (candidate.Contains("spdx", StringComparison.OrdinalIgnoreCase) ||
candidate.Contains("cyclonedx", StringComparison.OrdinalIgnoreCase) ||
candidate.Contains("sbom", StringComparison.OrdinalIgnoreCase))
{
return "sbom";
}
if (candidate.Contains("openvex", StringComparison.OrdinalIgnoreCase) ||
candidate.Contains("csaf", StringComparison.OrdinalIgnoreCase) ||
candidate.Contains("vex", StringComparison.OrdinalIgnoreCase))
{
return "vex";
}
if (candidate.Contains("decision", StringComparison.OrdinalIgnoreCase) ||
candidate.Contains("verdict", StringComparison.OrdinalIgnoreCase))
{
return "decision";
}
if (candidate.Contains("approval", StringComparison.OrdinalIgnoreCase))
{
return "approval";
}
return "unknown";
}
private static OciDescriptor? SelectDsseLayer(OciManifest manifest)
{
if (manifest.Layers.Count == 0)
{
return null;
}
var dsse = manifest.Layers.FirstOrDefault(layer =>
layer.MediaType is not null &&
(layer.MediaType.Contains("dsse", StringComparison.OrdinalIgnoreCase) ||
layer.MediaType.Contains("in-toto", StringComparison.OrdinalIgnoreCase) ||
layer.MediaType.Contains("intoto", StringComparison.OrdinalIgnoreCase)));
return dsse ?? manifest.Layers[0];
}
private static async Task<byte[]> DecodeLayerAsync(OciDescriptor layer, byte[] content, CancellationToken ct)
{
if (layer.MediaType is null || !layer.MediaType.Contains("gzip", StringComparison.OrdinalIgnoreCase))
{
return content;
}
await using var input = new MemoryStream(content);
await using var gzip = new GZipStream(input, CompressionMode.Decompress);
await using var output = new MemoryStream();
await gzip.CopyToAsync(output, ct).ConfigureAwait(false);
return output.ToArray();
}
private static DsseEnvelopeWire ParseEnvelope(byte[] payload)
{
var json = Encoding.UTF8.GetString(payload);
var envelope = JsonSerializer.Deserialize<DsseEnvelopeWire>(json, JsonOptions);
if (envelope is null || string.IsNullOrWhiteSpace(envelope.PayloadType) || string.IsNullOrWhiteSpace(envelope.Payload))
{
throw new InvalidDataException("Invalid DSSE envelope.");
}
envelope.Signatures ??= new List<DsseSignatureWire>();
return envelope;
}
private sealed record DsseEnvelopeWire
{
public string PayloadType { get; init; } = string.Empty;
public string Payload { get; init; } = string.Empty;
public List<DsseSignatureWire> Signatures { get; set; } = new();
}
private sealed record DsseSignatureWire
{
public string? KeyId { get; init; }
public string? Signature { get; init; }
}
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models;
public sealed record ImageVerificationRequest
{
public required string Reference { get; init; }
public required IReadOnlyList<string> RequiredTypes { get; init; }
public string? TrustPolicyPath { get; init; }
public bool Strict { get; init; }
}
public sealed record ImageVerificationResult
{
public required string ImageReference { get; init; }
public required string ImageDigest { get; init; }
public string? Registry { get; init; }
public string? Repository { get; init; }
public required DateTimeOffset VerifiedAt { get; init; }
public bool IsValid { get; set; }
public List<AttestationVerification> Attestations { get; } = new();
public List<string> MissingTypes { get; set; } = new();
public List<string> Errors { get; } = new();
}
public sealed record AttestationVerification
{
public required string Type { get; init; }
public required bool IsValid { get; init; }
public required AttestationStatus Status { get; init; }
public string? Digest { get; init; }
public string? SignerIdentity { get; init; }
public string? Message { get; init; }
public DateTimeOffset? VerifiedAt { get; init; }
}
public enum AttestationStatus
{
Verified,
Invalid,
Missing,
Expired,
UntrustedSigner
}

View File

@@ -0,0 +1,70 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
public sealed record OciImageReference
{
public required string Registry { get; init; }
public required string Repository { get; init; }
public string? Tag { get; init; }
public string? Digest { get; init; }
public required string Original { get; init; }
}
public sealed record OciReferrersResponse
{
[JsonPropertyName("referrers")]
public List<OciReferrerDescriptor> Referrers { get; init; } = new();
}
public sealed record OciReferrerDescriptor
{
[JsonPropertyName("mediaType")]
public string? MediaType { get; init; }
[JsonPropertyName("artifactType")]
public string? ArtifactType { get; init; }
[JsonPropertyName("digest")]
public string Digest { get; init; } = string.Empty;
[JsonPropertyName("size")]
public long Size { get; init; }
[JsonPropertyName("annotations")]
public Dictionary<string, string>? Annotations { get; init; }
}
public sealed record OciManifest
{
[JsonPropertyName("mediaType")]
public string? MediaType { get; init; }
[JsonPropertyName("artifactType")]
public string? ArtifactType { get; init; }
[JsonPropertyName("config")]
public OciDescriptor? Config { get; init; }
[JsonPropertyName("layers")]
public List<OciDescriptor> Layers { get; init; } = new();
[JsonPropertyName("annotations")]
public Dictionary<string, string>? Annotations { get; init; }
}
public sealed record OciDescriptor
{
[JsonPropertyName("mediaType")]
public string? MediaType { get; init; }
[JsonPropertyName("digest")]
public string Digest { get; init; } = string.Empty;
[JsonPropertyName("size")]
public long Size { get; init; }
[JsonPropertyName("annotations")]
public Dictionary<string, string>? Annotations { get; init; }
}

View File

@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
@@ -66,6 +68,102 @@ internal sealed class SbomListResponse
public string? NextCursor { get; init; }
}
/// <summary>
/// SBOM upload request payload.
/// </summary>
internal sealed class SbomUploadRequest
{
[JsonPropertyName("artifactRef")]
public string ArtifactRef { get; init; } = string.Empty;
[JsonPropertyName("sbom")]
public JsonElement? Sbom { get; init; }
[JsonPropertyName("sbomBase64")]
public string? SbomBase64 { get; init; }
[JsonPropertyName("format")]
public string? Format { get; init; }
[JsonPropertyName("source")]
public SbomUploadSource? Source { get; init; }
}
/// <summary>
/// SBOM upload source metadata.
/// </summary>
internal sealed class SbomUploadSource
{
[JsonPropertyName("tool")]
public string? Tool { get; init; }
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("ciContext")]
public SbomUploadCiContext? CiContext { get; init; }
}
/// <summary>
/// CI context metadata for SBOM uploads.
/// </summary>
internal sealed class SbomUploadCiContext
{
[JsonPropertyName("buildId")]
public string? BuildId { get; init; }
[JsonPropertyName("repository")]
public string? Repository { get; init; }
}
/// <summary>
/// SBOM upload response payload.
/// </summary>
internal sealed class SbomUploadResponse
{
[JsonPropertyName("sbomId")]
public string SbomId { get; init; } = string.Empty;
[JsonPropertyName("artifactRef")]
public string ArtifactRef { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public string Digest { get; init; } = string.Empty;
[JsonPropertyName("format")]
public string Format { get; init; } = string.Empty;
[JsonPropertyName("formatVersion")]
public string FormatVersion { get; init; } = string.Empty;
[JsonPropertyName("validationResult")]
public SbomUploadValidationSummary ValidationResult { get; init; } = new();
[JsonPropertyName("analysisJobId")]
public string AnalysisJobId { get; init; } = string.Empty;
}
/// <summary>
/// SBOM upload validation summary.
/// </summary>
internal sealed class SbomUploadValidationSummary
{
[JsonPropertyName("valid")]
public bool Valid { get; init; }
[JsonPropertyName("qualityScore")]
public double QualityScore { get; init; }
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = [];
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = [];
[JsonPropertyName("componentCount")]
public int ComponentCount { get; init; }
}
/// <summary>
/// Summary view of an SBOM.
/// </summary>
@@ -552,6 +650,111 @@ internal sealed class SbomExportResult
public IReadOnlyList<string>? Errors { get; init; }
}
/// <summary>
/// SBOM upload request payload.
/// </summary>
internal sealed class SbomUploadRequest
{
[JsonPropertyName("artifactRef")]
public string ArtifactRef { get; init; } = string.Empty;
[JsonPropertyName("artifactDigest")]
public string? ArtifactDigest { get; init; }
[JsonPropertyName("sbom")]
public JsonElement? Sbom { get; init; }
[JsonPropertyName("sbomBase64")]
public string? SbomBase64 { get; init; }
[JsonPropertyName("format")]
public string? Format { get; init; }
[JsonPropertyName("source")]
public SbomUploadSource? Source { get; init; }
}
/// <summary>
/// SBOM upload provenance metadata.
/// </summary>
internal sealed class SbomUploadSource
{
[JsonPropertyName("tool")]
public string? Tool { get; init; }
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("ciContext")]
public SbomUploadCiContext? CiContext { get; init; }
}
/// <summary>
/// CI context for SBOM upload provenance.
/// </summary>
internal sealed class SbomUploadCiContext
{
[JsonPropertyName("buildId")]
public string? BuildId { get; init; }
[JsonPropertyName("repository")]
public string? Repository { get; init; }
}
/// <summary>
/// SBOM upload response payload.
/// </summary>
internal sealed class SbomUploadResponse
{
[JsonPropertyName("sbomId")]
public string SbomId { get; init; } = string.Empty;
[JsonPropertyName("artifactRef")]
public string ArtifactRef { get; init; } = string.Empty;
[JsonPropertyName("artifactDigest")]
public string? ArtifactDigest { get; init; }
[JsonPropertyName("digest")]
public string Digest { get; init; } = string.Empty;
[JsonPropertyName("format")]
public string Format { get; init; } = string.Empty;
[JsonPropertyName("formatVersion")]
public string FormatVersion { get; init; } = string.Empty;
[JsonPropertyName("validationResult")]
public SbomUploadValidationSummary? ValidationResult { get; init; }
[JsonPropertyName("analysisJobId")]
public string AnalysisJobId { get; init; } = string.Empty;
[JsonPropertyName("uploadedAtUtc")]
public DateTimeOffset UploadedAtUtc { get; init; }
}
/// <summary>
/// SBOM upload validation summary.
/// </summary>
internal sealed class SbomUploadValidationSummary
{
[JsonPropertyName("valid")]
public bool Valid { get; init; }
[JsonPropertyName("qualityScore")]
public double QualityScore { get; init; }
[JsonPropertyName("warnings")]
public IReadOnlyList<string> Warnings { get; init; } = [];
[JsonPropertyName("errors")]
public IReadOnlyList<string> Errors { get; init; } = [];
[JsonPropertyName("componentCount")]
public int ComponentCount { get; init; }
}
// CLI-PARITY-41-001: Parity matrix models
/// <summary>

View File

@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models;
public sealed record TrustPolicyContext
{
public TrustPolicy Policy { get; init; } = new();
public IReadOnlyList<TrustPolicyKeyMaterial> Keys { get; init; } = Array.Empty<TrustPolicyKeyMaterial>();
public bool RequireRekor { get; init; }
public TimeSpan? MaxAge { get; init; }
}
public sealed record TrustPolicyKeyMaterial
{
public required string KeyId { get; init; }
public required string Fingerprint { get; init; }
public required string Algorithm { get; init; }
public required byte[] PublicKey { get; init; }
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models;
public sealed class TrustPolicy
{
public string Version { get; set; } = "1";
public Dictionary<string, TrustPolicyAttestation> Attestations { get; set; } = new();
public TrustPolicyDefaults Defaults { get; set; } = new();
public List<TrustPolicyKey> Keys { get; set; } = new();
}
public sealed class TrustPolicyAttestation
{
public bool Required { get; set; }
public List<TrustPolicySigner> Signers { get; set; } = new();
}
public sealed class TrustPolicySigner
{
public string? Identity { get; set; }
public string? Issuer { get; set; }
}
public sealed class TrustPolicyDefaults
{
public bool RequireRekor { get; set; }
public string? MaxAge { get; set; }
}
public sealed class TrustPolicyKey
{
public string? Id { get; set; }
public string? Path { get; set; }
public string? Algorithm { get; set; }
}

View File

@@ -0,0 +1,141 @@
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
internal static class OciImageReferenceParser
{
public static OciImageReference Parse(string reference)
{
if (string.IsNullOrWhiteSpace(reference))
{
throw new ArgumentException("Image reference is required.", nameof(reference));
}
reference = reference.Trim();
if (reference.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
reference.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return ParseUri(reference);
}
var registry = string.Empty;
var remainder = reference;
var parts = reference.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 1 && LooksLikeRegistry(parts[0]))
{
registry = parts[0];
remainder = string.Join('/', parts.Skip(1));
}
else
{
registry = "docker.io";
}
var repository = remainder;
string? tag = null;
string? digest = null;
var atIndex = remainder.LastIndexOf('@');
if (atIndex >= 0)
{
repository = remainder[..atIndex];
digest = remainder[(atIndex + 1)..];
}
else
{
var lastColon = remainder.LastIndexOf(':');
var lastSlash = remainder.LastIndexOf('/');
if (lastColon > lastSlash)
{
repository = remainder[..lastColon];
tag = remainder[(lastColon + 1)..];
}
}
if (string.IsNullOrWhiteSpace(repository))
{
throw new ArgumentException("Image repository is required.", nameof(reference));
}
if (string.Equals(registry, "docker.io", StringComparison.OrdinalIgnoreCase) &&
!repository.Contains('/', StringComparison.Ordinal))
{
repository = $"library/{repository}";
}
if (string.IsNullOrWhiteSpace(tag) && string.IsNullOrWhiteSpace(digest))
{
tag = "latest";
}
return new OciImageReference
{
Registry = registry,
Repository = repository,
Tag = tag,
Digest = digest,
Original = reference
};
}
private static OciImageReference ParseUri(string reference)
{
if (!Uri.TryCreate(reference, UriKind.Absolute, out var uri))
{
throw new ArgumentException("Invalid image reference URI.", nameof(reference));
}
var registry = uri.Authority;
var remainder = uri.AbsolutePath.Trim('/');
string? tag = null;
string? digest = null;
var atIndex = remainder.LastIndexOf('@');
if (atIndex >= 0)
{
digest = remainder[(atIndex + 1)..];
remainder = remainder[..atIndex];
}
else
{
var lastColon = remainder.LastIndexOf(':');
if (lastColon > remainder.LastIndexOf('/'))
{
tag = remainder[(lastColon + 1)..];
remainder = remainder[..lastColon];
}
}
if (string.Equals(registry, "docker.io", StringComparison.OrdinalIgnoreCase) &&
!remainder.Contains('/', StringComparison.Ordinal))
{
remainder = $"library/{remainder}";
}
if (string.IsNullOrWhiteSpace(tag) && string.IsNullOrWhiteSpace(digest))
{
tag = "latest";
}
return new OciImageReference
{
Registry = registry,
Repository = remainder,
Tag = tag,
Digest = digest,
Original = reference
};
}
private static bool LooksLikeRegistry(string value)
{
if (string.Equals(value, "localhost", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return value.Contains('.', StringComparison.Ordinal) || value.Contains(':', StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,320 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
public sealed class OciRegistryClient : IOciRegistryClient
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private static readonly string[] ManifestAccept =
{
"application/vnd.oci.artifact.manifest.v1+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json"
};
private readonly HttpClient _httpClient;
private readonly ILogger<OciRegistryClient> _logger;
private readonly Dictionary<string, string> _tokenCache = new(StringComparer.OrdinalIgnoreCase);
public OciRegistryClient(HttpClient httpClient, ILogger<OciRegistryClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<string> ResolveDigestAsync(OciImageReference reference, CancellationToken cancellationToken = default)
{
if (!string.IsNullOrWhiteSpace(reference.Digest))
{
return reference.Digest!;
}
if (string.IsNullOrWhiteSpace(reference.Tag))
{
throw new InvalidOperationException("Image reference does not include a tag or digest.");
}
var path = $"/v2/{reference.Repository}/manifests/{reference.Tag}";
using var request = new HttpRequestMessage(HttpMethod.Head, BuildUri(reference, path));
AddAcceptHeaders(request, ManifestAccept);
using var response = await SendWithAuthAsync(reference, request, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
if (response.Headers.TryGetValues("Docker-Content-Digest", out var digestHeaders))
{
var digest = digestHeaders.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(digest))
{
return digest;
}
}
}
using var getRequest = new HttpRequestMessage(HttpMethod.Get, BuildUri(reference, path));
AddAcceptHeaders(getRequest, ManifestAccept);
using var getResponse = await SendWithAuthAsync(reference, getRequest, cancellationToken).ConfigureAwait(false);
if (!getResponse.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Failed to resolve digest: {getResponse.StatusCode}");
}
if (getResponse.Headers.TryGetValues("Docker-Content-Digest", out var getDigestHeaders))
{
var digest = getDigestHeaders.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(digest))
{
return digest;
}
}
throw new InvalidOperationException("Registry response did not include Docker-Content-Digest.");
}
public async Task<OciReferrersResponse> ListReferrersAsync(
OciImageReference reference,
string digest,
CancellationToken cancellationToken = default)
{
var path = $"/v2/{reference.Repository}/referrers/{digest}";
using var request = new HttpRequestMessage(HttpMethod.Get, BuildUri(reference, path));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.index.v1+json"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var response = await SendWithAuthAsync(reference, request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Failed to list referrers: {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<OciReferrersResponse>(json, JsonOptions)
?? new OciReferrersResponse();
}
public async Task<OciManifest> GetManifestAsync(
OciImageReference reference,
string digest,
CancellationToken cancellationToken = default)
{
var path = $"/v2/{reference.Repository}/manifests/{digest}";
using var request = new HttpRequestMessage(HttpMethod.Get, BuildUri(reference, path));
AddAcceptHeaders(request, ManifestAccept);
using var response = await SendWithAuthAsync(reference, request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Failed to fetch manifest: {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<OciManifest>(json, JsonOptions)
?? new OciManifest();
}
public async Task<byte[]> GetBlobAsync(
OciImageReference reference,
string digest,
CancellationToken cancellationToken = default)
{
var path = $"/v2/{reference.Repository}/blobs/{digest}";
using var request = new HttpRequestMessage(HttpMethod.Get, BuildUri(reference, path));
using var response = await SendWithAuthAsync(reference, request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Failed to fetch blob: {response.StatusCode}");
}
return await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<HttpResponseMessage> SendWithAuthAsync(
OciImageReference reference,
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode != HttpStatusCode.Unauthorized)
{
return response;
}
var challenge = response.Headers.WwwAuthenticate.FirstOrDefault(header =>
header.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase));
if (challenge is null)
{
return response;
}
var token = await GetTokenAsync(reference, challenge, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(token))
{
return response;
}
response.Dispose();
var retry = CloneRequest(request);
retry.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await _httpClient.SendAsync(retry, cancellationToken).ConfigureAwait(false);
}
private async Task<string?> GetTokenAsync(
OciImageReference reference,
AuthenticationHeaderValue challenge,
CancellationToken cancellationToken)
{
var parameters = ParseChallengeParameters(challenge.Parameter);
if (!parameters.TryGetValue("realm", out var realm))
{
return null;
}
var service = parameters.GetValueOrDefault("service");
var scope = parameters.GetValueOrDefault("scope") ?? $"repository:{reference.Repository}:pull";
var cacheKey = $"{realm}|{service}|{scope}";
if (_tokenCache.TryGetValue(cacheKey, out var cached))
{
return cached;
}
var tokenUri = BuildTokenUri(realm, service, scope);
using var request = new HttpRequestMessage(HttpMethod.Get, tokenUri);
var authHeader = BuildBasicAuthHeader();
if (authHeader is not null)
{
request.Headers.Authorization = authHeader;
}
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("OCI token request failed: {StatusCode}", response.StatusCode);
return null;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("token", out var tokenElement) &&
!document.RootElement.TryGetProperty("access_token", out tokenElement))
{
return null;
}
var token = tokenElement.GetString();
if (!string.IsNullOrWhiteSpace(token))
{
_tokenCache[cacheKey] = token;
}
return token;
}
private static AuthenticationHeaderValue? BuildBasicAuthHeader()
{
var username = Environment.GetEnvironmentVariable("STELLAOPS_REGISTRY_USERNAME");
var password = Environment.GetEnvironmentVariable("STELLAOPS_REGISTRY_PASSWORD");
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return null;
}
var token = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{username}:{password}"));
return new AuthenticationHeaderValue("Basic", token);
}
private static Dictionary<string, string> ParseChallengeParameters(string? parameter)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(parameter))
{
return result;
}
var parts = parameter.Split(',', StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts)
{
var tokens = part.Split('=', 2, StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length != 2)
{
continue;
}
var key = tokens[0].Trim();
var value = tokens[1].Trim().Trim('"');
if (!string.IsNullOrWhiteSpace(key))
{
result[key] = value;
}
}
return result;
}
private static Uri BuildTokenUri(string realm, string? service, string? scope)
{
var builder = new UriBuilder(realm);
var query = new List<string>();
if (!string.IsNullOrWhiteSpace(service))
{
query.Add($"service={Uri.EscapeDataString(service)}");
}
if (!string.IsNullOrWhiteSpace(scope))
{
query.Add($"scope={Uri.EscapeDataString(scope)}");
}
builder.Query = string.Join("&", query);
return builder.Uri;
}
private Uri BuildUri(OciImageReference reference, string path)
{
var scheme = reference.Original.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
? "http"
: "https";
var builder = new UriBuilder(scheme, reference.Registry)
{
Path = path
};
return builder.Uri;
}
private static void AddAcceptHeaders(HttpRequestMessage request, IEnumerable<string> accepts)
{
foreach (var accept in accepts)
{
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept));
}
}
private static HttpRequestMessage CloneRequest(HttpRequestMessage request)
{
var clone = new HttpRequestMessage(request.Method, request.RequestUri);
foreach (var header in request.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
if (request.Content is not null)
{
clone.Content = request.Content;
}
return clone;
}
}

View File

@@ -3,6 +3,8 @@ using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -333,6 +335,105 @@ internal sealed class SbomClient : ISbomClient
}
}
public async Task<SbomUploadResponse?> UploadAsync(
SbomUploadRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var uri = "/api/v1/sbom/upload";
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, uri);
await AuthorizeRequestAsync(httpRequest, "sbom.write", cancellationToken).ConfigureAwait(false);
var payload = JsonSerializer.Serialize(request, SerializerOptions);
httpRequest.Content = new StringContent(payload, Encoding.UTF8, "application/json");
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to upload SBOM (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(body) ? "<empty>" : body);
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await JsonSerializer
.DeserializeAsync<SbomUploadResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while uploading SBOM");
return null;
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while uploading SBOM");
return null;
}
}
public async Task<SbomUploadResponse?> UploadAsync(
SbomUploadRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sbom/upload")
{
Content = JsonContent.Create(request, options: SerializerOptions)
};
await AuthorizeRequestAsync(httpRequest, "sbom.write", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to upload SBOM (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
var validation = TryParseValidation(payload, request);
if (validation is not null)
{
return validation;
}
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await JsonSerializer
.DeserializeAsync<SbomUploadResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while uploading SBOM");
return null;
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while uploading SBOM");
return null;
}
}
public async Task<ParityMatrixResponse> GetParityMatrixAsync(
string? tenant,
CancellationToken cancellationToken)
@@ -481,4 +582,67 @@ internal sealed class SbomClient : ISbomClient
return null;
}
}
private static SbomUploadResponse? TryParseValidation(string payload, SbomUploadRequest request)
{
if (string.IsNullOrWhiteSpace(payload))
{
return null;
}
try
{
using var document = JsonDocument.Parse(payload);
if (!document.RootElement.TryGetProperty("extensions", out var extensions) || extensions.ValueKind != JsonValueKind.Object)
{
return null;
}
var errors = ReadStringList(extensions, "errors");
var warnings = ReadStringList(extensions, "warnings");
if (errors.Count == 0 && warnings.Count == 0)
{
return null;
}
return new SbomUploadResponse
{
ArtifactRef = request.ArtifactRef,
ValidationResult = new SbomUploadValidationSummary
{
Valid = false,
Errors = errors,
Warnings = warnings
}
};
}
catch (JsonException)
{
return null;
}
}
private static IReadOnlyList<string> ReadStringList(JsonElement parent, string name)
{
if (!parent.TryGetProperty(name, out var element) || element.ValueKind != JsonValueKind.Array)
{
return Array.Empty<string>();
}
var list = new List<string>();
foreach (var entry in element.EnumerateArray())
{
if (entry.ValueKind == JsonValueKind.String)
{
var value = entry.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
list.Add(value);
}
}
}
return list;
}
}

View File

@@ -0,0 +1,218 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
public sealed class TrustPolicyLoader : ITrustPolicyLoader
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly ILogger<TrustPolicyLoader> _logger;
public TrustPolicyLoader(ILogger<TrustPolicyLoader> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<TrustPolicyContext> LoadAsync(string path, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Trust policy path must be provided.", nameof(path));
}
var fullPath = Path.GetFullPath(path);
if (!File.Exists(fullPath))
{
throw new FileNotFoundException("Trust policy file not found.", fullPath);
}
var policy = await LoadPolicyDocumentAsync(fullPath, cancellationToken).ConfigureAwait(false);
var normalized = NormalizePolicy(policy);
var keyMaterials = await LoadKeysAsync(fullPath, normalized.Keys, cancellationToken).ConfigureAwait(false);
var maxAge = ParseDuration(normalized.Defaults?.MaxAge);
return new TrustPolicyContext
{
Policy = normalized,
Keys = keyMaterials,
RequireRekor = normalized.Defaults?.RequireRekor ?? false,
MaxAge = maxAge
};
}
private static async Task<TrustPolicy> LoadPolicyDocumentAsync(string path, CancellationToken cancellationToken)
{
var extension = Path.GetExtension(path).ToLowerInvariant();
if (extension is ".yaml" or ".yml")
{
var builder = new ConfigurationBuilder()
.AddYamlFile(path, optional: false, reloadOnChange: false);
var config = builder.Build();
var policy = new TrustPolicy();
config.Bind(policy);
return policy;
}
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<TrustPolicy>(json, JsonOptions) ?? new TrustPolicy();
}
private TrustPolicy NormalizePolicy(TrustPolicy policy)
{
policy.Attestations ??= new Dictionary<string, TrustPolicyAttestation>();
policy.Keys ??= new List<TrustPolicyKey>();
policy.Defaults ??= new TrustPolicyDefaults();
var normalizedAttestations = new Dictionary<string, TrustPolicyAttestation>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in policy.Attestations)
{
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
value ??= new TrustPolicyAttestation();
value.Signers ??= new List<TrustPolicySigner>();
normalizedAttestations[key.Trim()] = value;
}
policy.Attestations = normalizedAttestations;
return policy;
}
private async Task<IReadOnlyList<TrustPolicyKeyMaterial>> LoadKeysAsync(
string policyPath,
IReadOnlyList<TrustPolicyKey> keys,
CancellationToken cancellationToken)
{
if (keys.Count == 0)
{
return Array.Empty<TrustPolicyKeyMaterial>();
}
var keyMaterials = new List<TrustPolicyKeyMaterial>(keys.Count);
var baseDir = Path.GetDirectoryName(policyPath) ?? Environment.CurrentDirectory;
foreach (var key in keys)
{
if (string.IsNullOrWhiteSpace(key.Path))
{
continue;
}
var resolvedPath = Path.IsPathRooted(key.Path)
? key.Path
: Path.Combine(baseDir, key.Path);
var fullPath = Path.GetFullPath(resolvedPath);
if (!File.Exists(fullPath))
{
throw new FileNotFoundException($"Trust policy key file not found: {fullPath}", fullPath);
}
var publicKey = await LoadPublicKeyDerBytesAsync(fullPath, cancellationToken).ConfigureAwait(false);
var fingerprint = ComputeFingerprint(publicKey);
var keyId = string.IsNullOrWhiteSpace(key.Id) ? fingerprint : key.Id.Trim();
var algorithm = NormalizeAlgorithm(key.Algorithm);
keyMaterials.Add(new TrustPolicyKeyMaterial
{
KeyId = keyId,
Fingerprint = fingerprint,
Algorithm = algorithm,
PublicKey = publicKey
});
}
if (keyMaterials.Count == 0)
{
_logger.LogWarning("Trust policy did not load any keys.");
}
return keyMaterials;
}
private static string NormalizeAlgorithm(string? algorithm)
{
if (string.IsNullOrWhiteSpace(algorithm))
{
return "rsa-pss-sha256";
}
return algorithm.Trim().ToLowerInvariant();
}
private static string ComputeFingerprint(byte[] publicKey)
{
var hash = SHA256.HashData(publicKey);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static async Task<byte[]> LoadPublicKeyDerBytesAsync(string path, CancellationToken ct)
{
var bytes = await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false);
var text = Encoding.UTF8.GetString(bytes);
const string Begin = "-----BEGIN PUBLIC KEY-----";
const string End = "-----END PUBLIC KEY-----";
var begin = text.IndexOf(Begin, StringComparison.Ordinal);
var end = text.IndexOf(End, StringComparison.Ordinal);
if (begin >= 0 && end > begin)
{
var base64 = text
.Substring(begin + Begin.Length, end - (begin + Begin.Length))
.Replace("\r", string.Empty, StringComparison.Ordinal)
.Replace("\n", string.Empty, StringComparison.Ordinal)
.Trim();
return Convert.FromBase64String(base64);
}
var trimmed = text.Trim();
try
{
return Convert.FromBase64String(trimmed);
}
catch
{
throw new InvalidDataException("Unsupported public key format (expected PEM or raw base64 SPKI).");
}
}
private static TimeSpan? ParseDuration(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
value = value.Trim();
if (TimeSpan.TryParse(value, CultureInfo.InvariantCulture, out var parsed))
{
return parsed;
}
var suffix = value[^1];
if (!double.TryParse(value[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var amount))
{
return null;
}
return suffix switch
{
's' or 'S' => TimeSpan.FromSeconds(amount),
'm' or 'M' => TimeSpan.FromMinutes(amount),
'h' or 'H' => TimeSpan.FromHours(amount),
'd' or 'D' => TimeSpan.FromDays(amount),
_ => null
};
}
}