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,234 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using StellaOps.DeltaVerdict.Models;
namespace StellaOps.DeltaVerdict.Engine;
public interface IDeltaComputationEngine
{
DeltaVerdict.Models.DeltaVerdict ComputeDelta(Verdict baseVerdict, Verdict headVerdict);
}
public sealed class DeltaComputationEngine : IDeltaComputationEngine
{
private readonly TimeProvider _timeProvider;
public DeltaComputationEngine(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public DeltaVerdict.Models.DeltaVerdict ComputeDelta(Verdict baseVerdict, Verdict headVerdict)
{
ArgumentNullException.ThrowIfNull(baseVerdict);
ArgumentNullException.ThrowIfNull(headVerdict);
var baseComponents = baseVerdict.Components
.ToDictionary(c => c.Purl, c => c, StringComparer.Ordinal);
var headComponents = headVerdict.Components
.ToDictionary(c => c.Purl, c => c, StringComparer.Ordinal);
var addedComponents = ComputeAddedComponents(baseComponents, headComponents);
var removedComponents = ComputeRemovedComponents(baseComponents, headComponents);
var changedComponents = ComputeChangedComponents(baseComponents, headComponents);
var baseVulns = baseVerdict.Vulnerabilities
.ToDictionary(v => v.Id, v => v, StringComparer.Ordinal);
var headVulns = headVerdict.Vulnerabilities
.ToDictionary(v => v.Id, v => v, StringComparer.Ordinal);
var addedVulns = ComputeAddedVulnerabilities(baseVulns, headVulns);
var removedVulns = ComputeRemovedVulnerabilities(baseVulns, headVulns);
var changedStatuses = ComputeStatusChanges(baseVulns, headVulns);
var riskDelta = ComputeRiskScoreDelta(baseVerdict.RiskScore, headVerdict.RiskScore);
var totalChanges = addedComponents.Length + removedComponents.Length + changedComponents.Length
+ addedVulns.Length + removedVulns.Length + changedStatuses.Length;
var summary = new DeltaSummary(
ComponentsAdded: addedComponents.Length,
ComponentsRemoved: removedComponents.Length,
ComponentsChanged: changedComponents.Length,
VulnerabilitiesAdded: addedVulns.Length,
VulnerabilitiesRemoved: removedVulns.Length,
VulnerabilityStatusChanges: changedStatuses.Length,
TotalChanges: totalChanges,
Magnitude: ClassifyMagnitude(totalChanges));
return new DeltaVerdict.Models.DeltaVerdict
{
DeltaId = ComputeDeltaId(baseVerdict, headVerdict),
SchemaVersion = "1.0.0",
BaseVerdict = CreateVerdictReference(baseVerdict),
HeadVerdict = CreateVerdictReference(headVerdict),
AddedComponents = addedComponents,
RemovedComponents = removedComponents,
ChangedComponents = changedComponents,
AddedVulnerabilities = addedVulns,
RemovedVulnerabilities = removedVulns,
ChangedVulnerabilityStatuses = changedStatuses,
RiskScoreDelta = riskDelta,
Summary = summary,
ComputedAt = _timeProvider.GetUtcNow()
};
}
private static ImmutableArray<ComponentDelta> ComputeAddedComponents(
IReadOnlyDictionary<string, Component> baseComponents,
IReadOnlyDictionary<string, Component> headComponents)
{
return headComponents
.Where(kv => !baseComponents.ContainsKey(kv.Key))
.OrderBy(kv => kv.Key, StringComparer.Ordinal)
.Select(kv => new ComponentDelta(
kv.Value.Purl,
kv.Value.Name,
kv.Value.Version,
kv.Value.Type,
kv.Value.Vulnerabilities))
.ToImmutableArray();
}
private static ImmutableArray<ComponentDelta> ComputeRemovedComponents(
IReadOnlyDictionary<string, Component> baseComponents,
IReadOnlyDictionary<string, Component> headComponents)
{
return baseComponents
.Where(kv => !headComponents.ContainsKey(kv.Key))
.OrderBy(kv => kv.Key, StringComparer.Ordinal)
.Select(kv => new ComponentDelta(
kv.Value.Purl,
kv.Value.Name,
kv.Value.Version,
kv.Value.Type,
kv.Value.Vulnerabilities))
.ToImmutableArray();
}
private static ImmutableArray<ComponentVersionDelta> ComputeChangedComponents(
IReadOnlyDictionary<string, Component> baseComponents,
IReadOnlyDictionary<string, Component> headComponents)
{
return baseComponents
.Where(kv => headComponents.TryGetValue(kv.Key, out var head)
&& !string.Equals(kv.Value.Version, head.Version, StringComparison.Ordinal))
.OrderBy(kv => kv.Key, StringComparer.Ordinal)
.Select(kv =>
{
var baseComponent = kv.Value;
var headComponent = headComponents[kv.Key];
var fixedVulns = baseComponent.Vulnerabilities
.Except(headComponent.Vulnerabilities, StringComparer.Ordinal)
.OrderBy(v => v, StringComparer.Ordinal)
.ToImmutableArray();
var introducedVulns = headComponent.Vulnerabilities
.Except(baseComponent.Vulnerabilities, StringComparer.Ordinal)
.OrderBy(v => v, StringComparer.Ordinal)
.ToImmutableArray();
return new ComponentVersionDelta(
baseComponent.Purl,
baseComponent.Name,
baseComponent.Version,
headComponent.Version,
fixedVulns,
introducedVulns);
})
.ToImmutableArray();
}
private static ImmutableArray<VulnerabilityDelta> ComputeAddedVulnerabilities(
IReadOnlyDictionary<string, Vulnerability> baseVulns,
IReadOnlyDictionary<string, Vulnerability> headVulns)
{
return headVulns
.Where(kv => !baseVulns.ContainsKey(kv.Key))
.OrderBy(kv => kv.Key, StringComparer.Ordinal)
.Select(kv => new VulnerabilityDelta(
kv.Value.Id,
kv.Value.Severity,
kv.Value.CvssScore,
kv.Value.ComponentPurl,
kv.Value.ReachabilityStatus))
.ToImmutableArray();
}
private static ImmutableArray<VulnerabilityDelta> ComputeRemovedVulnerabilities(
IReadOnlyDictionary<string, Vulnerability> baseVulns,
IReadOnlyDictionary<string, Vulnerability> headVulns)
{
return baseVulns
.Where(kv => !headVulns.ContainsKey(kv.Key))
.OrderBy(kv => kv.Key, StringComparer.Ordinal)
.Select(kv => new VulnerabilityDelta(
kv.Value.Id,
kv.Value.Severity,
kv.Value.CvssScore,
kv.Value.ComponentPurl,
kv.Value.ReachabilityStatus))
.ToImmutableArray();
}
private static ImmutableArray<VulnerabilityStatusDelta> ComputeStatusChanges(
IReadOnlyDictionary<string, Vulnerability> baseVulns,
IReadOnlyDictionary<string, Vulnerability> headVulns)
{
var deltas = new List<VulnerabilityStatusDelta>();
foreach (var (id, baseVuln) in baseVulns.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
if (!headVulns.TryGetValue(id, out var headVuln))
{
continue;
}
var oldStatus = baseVuln.Status ?? baseVuln.ReachabilityStatus ?? "unknown";
var newStatus = headVuln.Status ?? headVuln.ReachabilityStatus ?? "unknown";
if (!string.Equals(oldStatus, newStatus, StringComparison.OrdinalIgnoreCase))
{
deltas.Add(new VulnerabilityStatusDelta(id, oldStatus, newStatus, null));
}
}
return deltas.ToImmutableArray();
}
private static RiskScoreDelta ComputeRiskScoreDelta(decimal oldScore, decimal newScore)
{
var change = newScore - oldScore;
var percentChange = oldScore > 0 ? (change / oldScore) * 100 : (newScore > 0 ? 100 : 0);
var trend = change switch
{
< 0 => RiskTrend.Improved,
> 0 => RiskTrend.Degraded,
_ => RiskTrend.Stable
};
return new RiskScoreDelta(oldScore, newScore, change, percentChange, trend);
}
private static DeltaMagnitude ClassifyMagnitude(int totalChanges) => totalChanges switch
{
0 => DeltaMagnitude.None,
<= 5 => DeltaMagnitude.Minimal,
<= 20 => DeltaMagnitude.Small,
<= 50 => DeltaMagnitude.Medium,
<= 100 => DeltaMagnitude.Large,
_ => DeltaMagnitude.Major
};
private static VerdictReference CreateVerdictReference(Verdict verdict)
=> new(verdict.VerdictId, verdict.Digest, verdict.ArtifactRef, verdict.ScannedAt);
private static string ComputeDeltaId(Verdict baseVerdict, Verdict headVerdict)
{
var baseKey = baseVerdict.Digest ?? baseVerdict.VerdictId;
var headKey = headVerdict.Digest ?? headVerdict.VerdictId;
var input = $"{baseKey}:{headKey}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,158 @@
using System.Collections.Immutable;
namespace StellaOps.DeltaVerdict.Models;
/// <summary>
/// Represents the difference between two scan verdicts.
/// Used for diff-aware release gates and risk budget computation.
/// </summary>
public sealed record DeltaVerdict
{
/// <summary>
/// Unique identifier for this delta.
/// </summary>
public required string DeltaId { get; init; }
/// <summary>
/// Schema version for forward compatibility.
/// </summary>
public required string SchemaVersion { get; init; } = "1.0.0";
/// <summary>
/// Reference to the base (before) verdict.
/// </summary>
public required VerdictReference BaseVerdict { get; init; }
/// <summary>
/// Reference to the head (after) verdict.
/// </summary>
public required VerdictReference HeadVerdict { get; init; }
/// <summary>
/// Components added in head.
/// </summary>
public ImmutableArray<ComponentDelta> AddedComponents { get; init; } = [];
/// <summary>
/// Components removed in head.
/// </summary>
public ImmutableArray<ComponentDelta> RemovedComponents { get; init; } = [];
/// <summary>
/// Components with version changes.
/// </summary>
public ImmutableArray<ComponentVersionDelta> ChangedComponents { get; init; } = [];
/// <summary>
/// New vulnerabilities introduced in head.
/// </summary>
public ImmutableArray<VulnerabilityDelta> AddedVulnerabilities { get; init; } = [];
/// <summary>
/// Vulnerabilities fixed in head.
/// </summary>
public ImmutableArray<VulnerabilityDelta> RemovedVulnerabilities { get; init; } = [];
/// <summary>
/// Vulnerabilities with status changes (e.g., VEX update).
/// </summary>
public ImmutableArray<VulnerabilityStatusDelta> ChangedVulnerabilityStatuses { get; init; } = [];
/// <summary>
/// Risk score changes.
/// </summary>
public required RiskScoreDelta RiskScoreDelta { get; init; }
/// <summary>
/// Summary statistics for the delta.
/// </summary>
public required DeltaSummary Summary { get; init; }
/// <summary>
/// Whether this is an "empty delta" (no changes).
/// </summary>
public bool IsEmpty => Summary.TotalChanges == 0;
/// <summary>
/// UTC timestamp when delta was computed.
/// </summary>
public required DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// SHA-256 digest of this delta (excluding this field and signature).
/// </summary>
public string? DeltaDigest { get; init; }
/// <summary>
/// DSSE signature if signed (JSON envelope).
/// </summary>
public string? Signature { get; init; }
}
public sealed record VerdictReference(
string VerdictId,
string? Digest,
string? ArtifactRef,
DateTimeOffset ScannedAt);
public sealed record ComponentDelta(
string Purl,
string Name,
string Version,
string Type,
ImmutableArray<string> AssociatedVulnerabilities);
public sealed record ComponentVersionDelta(
string Purl,
string Name,
string OldVersion,
string NewVersion,
ImmutableArray<string> VulnerabilitiesFixed,
ImmutableArray<string> VulnerabilitiesIntroduced);
public sealed record VulnerabilityDelta(
string VulnerabilityId,
string Severity,
decimal? CvssScore,
string? ComponentPurl,
string? ReachabilityStatus);
public sealed record VulnerabilityStatusDelta(
string VulnerabilityId,
string OldStatus,
string NewStatus,
string? Reason);
public sealed record RiskScoreDelta(
decimal OldScore,
decimal NewScore,
decimal Change,
decimal PercentChange,
RiskTrend Trend);
public enum RiskTrend
{
Improved,
Degraded,
Stable
}
public sealed record DeltaSummary(
int ComponentsAdded,
int ComponentsRemoved,
int ComponentsChanged,
int VulnerabilitiesAdded,
int VulnerabilitiesRemoved,
int VulnerabilityStatusChanges,
int TotalChanges,
DeltaMagnitude Magnitude);
public enum DeltaMagnitude
{
None,
Minimal,
Small,
Medium,
Large,
Major
}

View File

@@ -0,0 +1,29 @@
using System.Collections.Immutable;
namespace StellaOps.DeltaVerdict.Models;
public sealed record Verdict
{
public required string VerdictId { get; init; }
public string? Digest { get; init; }
public string? ArtifactRef { get; init; }
public required DateTimeOffset ScannedAt { get; init; }
public decimal RiskScore { get; init; }
public ImmutableArray<Component> Components { get; init; } = [];
public ImmutableArray<Vulnerability> Vulnerabilities { get; init; } = [];
}
public sealed record Component(
string Purl,
string Name,
string Version,
string Type,
ImmutableArray<string> Vulnerabilities);
public sealed record Vulnerability(
string Id,
string Severity,
decimal? CvssScore,
string? ComponentPurl,
string? ReachabilityStatus,
string? Status);

View File

@@ -0,0 +1,44 @@
using StellaOps.DeltaVerdict.Models;
using StellaOps.DeltaVerdict.Serialization;
namespace StellaOps.DeltaVerdict.Oci;
public sealed class DeltaOciAttacher : IDeltaOciAttacher
{
public OciAttachment CreateAttachment(DeltaVerdict.Models.DeltaVerdict delta, string artifactRef)
{
ArgumentNullException.ThrowIfNull(delta);
if (string.IsNullOrWhiteSpace(artifactRef))
{
throw new ArgumentException("Artifact reference is required.", nameof(artifactRef));
}
var payload = DeltaVerdictSerializer.Serialize(delta);
var annotations = new Dictionary<string, string>(StringComparer.Ordinal)
{
["org.opencontainers.image.title"] = "stellaops.delta-verdict",
["org.opencontainers.image.description"] = "Delta verdict for diff-aware release gates",
["stellaops.delta.base.digest"] = delta.BaseVerdict.Digest ?? string.Empty,
["stellaops.delta.head.digest"] = delta.HeadVerdict.Digest ?? string.Empty,
["stellaops.delta.base.id"] = delta.BaseVerdict.VerdictId,
["stellaops.delta.head.id"] = delta.HeadVerdict.VerdictId
};
return new OciAttachment(
ArtifactReference: artifactRef,
MediaType: "application/vnd.stellaops.delta-verdict+json",
Payload: payload,
Annotations: annotations);
}
}
public interface IDeltaOciAttacher
{
OciAttachment CreateAttachment(DeltaVerdict.Models.DeltaVerdict delta, string artifactRef);
}
public sealed record OciAttachment(
string ArtifactReference,
string MediaType,
string Payload,
IReadOnlyDictionary<string, string> Annotations);

View File

@@ -0,0 +1,89 @@
using System.Collections.Immutable;
using StellaOps.DeltaVerdict.Models;
namespace StellaOps.DeltaVerdict.Policy;
/// <summary>
/// Evaluates delta verdicts against risk budgets for release gates.
/// </summary>
public sealed class RiskBudgetEvaluator : IRiskBudgetEvaluator
{
public RiskBudgetResult Evaluate(DeltaVerdict.Models.DeltaVerdict delta, RiskBudget budget)
{
ArgumentNullException.ThrowIfNull(delta);
ArgumentNullException.ThrowIfNull(budget);
var violations = new List<RiskBudgetViolation>();
var criticalAdded = delta.AddedVulnerabilities
.Count(v => string.Equals(v.Severity, "critical", StringComparison.OrdinalIgnoreCase));
if (criticalAdded > budget.MaxNewCriticalVulnerabilities)
{
violations.Add(new RiskBudgetViolation(
"CriticalVulnerabilities",
$"Added {criticalAdded} critical vulnerabilities (budget: {budget.MaxNewCriticalVulnerabilities})"));
}
var highAdded = delta.AddedVulnerabilities
.Count(v => string.Equals(v.Severity, "high", StringComparison.OrdinalIgnoreCase));
if (highAdded > budget.MaxNewHighVulnerabilities)
{
violations.Add(new RiskBudgetViolation(
"HighVulnerabilities",
$"Added {highAdded} high vulnerabilities (budget: {budget.MaxNewHighVulnerabilities})"));
}
if (delta.RiskScoreDelta.Change > budget.MaxRiskScoreIncrease)
{
violations.Add(new RiskBudgetViolation(
"RiskScoreIncrease",
$"Risk score increased by {delta.RiskScoreDelta.Change} (budget: {budget.MaxRiskScoreIncrease})"));
}
if ((int)delta.Summary.Magnitude > (int)budget.MaxMagnitude)
{
violations.Add(new RiskBudgetViolation(
"DeltaMagnitude",
$"Delta magnitude {delta.Summary.Magnitude} exceeds budget {budget.MaxMagnitude}"));
}
foreach (var vuln in delta.AddedVulnerabilities)
{
if (budget.BlockedVulnerabilities.Contains(vuln.VulnerabilityId))
{
violations.Add(new RiskBudgetViolation(
"BlockedVulnerability",
$"Added blocked vulnerability {vuln.VulnerabilityId}"));
}
}
return new RiskBudgetResult(
IsWithinBudget: violations.Count == 0,
Violations: violations,
Delta: delta,
Budget: budget);
}
}
public interface IRiskBudgetEvaluator
{
RiskBudgetResult Evaluate(DeltaVerdict.Models.DeltaVerdict delta, RiskBudget budget);
}
public sealed record RiskBudget
{
public int MaxNewCriticalVulnerabilities { get; init; } = 0;
public int MaxNewHighVulnerabilities { get; init; } = 3;
public decimal MaxRiskScoreIncrease { get; init; } = 10;
public DeltaMagnitude MaxMagnitude { get; init; } = DeltaMagnitude.Medium;
public ImmutableHashSet<string> BlockedVulnerabilities { get; init; }
= ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
}
public sealed record RiskBudgetResult(
bool IsWithinBudget,
IReadOnlyList<RiskBudgetViolation> Violations,
DeltaVerdict.Models.DeltaVerdict Delta,
RiskBudget Budget);
public sealed record RiskBudgetViolation(string Category, string Message);

View File

@@ -0,0 +1,44 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Canonical.Json;
using StellaOps.DeltaVerdict.Models;
namespace StellaOps.DeltaVerdict.Serialization;
public static class DeltaVerdictSerializer
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
public static string Serialize(DeltaVerdict.Models.DeltaVerdict delta)
{
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(delta, JsonOptions);
var canonicalBytes = CanonJson.CanonicalizeParsedJson(jsonBytes);
return Encoding.UTF8.GetString(canonicalBytes);
}
public static DeltaVerdict.Models.DeltaVerdict Deserialize(string json)
{
return JsonSerializer.Deserialize<DeltaVerdict.Models.DeltaVerdict>(json, JsonOptions)
?? throw new InvalidOperationException("Failed to deserialize delta verdict");
}
public static string ComputeDigest(DeltaVerdict.Models.DeltaVerdict delta)
{
var unsigned = delta with { DeltaDigest = null, Signature = null };
var json = Serialize(unsigned);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static DeltaVerdict.Models.DeltaVerdict WithDigest(DeltaVerdict.Models.DeltaVerdict delta)
=> delta with { DeltaDigest = ComputeDigest(delta) };
}

View File

@@ -0,0 +1,44 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Canonical.Json;
using StellaOps.DeltaVerdict.Models;
namespace StellaOps.DeltaVerdict.Serialization;
public static class VerdictSerializer
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
public static string Serialize(Verdict verdict)
{
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(verdict, JsonOptions);
var canonicalBytes = CanonJson.CanonicalizeParsedJson(jsonBytes);
return Encoding.UTF8.GetString(canonicalBytes);
}
public static Verdict Deserialize(string json)
{
return JsonSerializer.Deserialize<Verdict>(json, JsonOptions)
?? throw new InvalidOperationException("Failed to deserialize verdict");
}
public static string ComputeDigest(Verdict verdict)
{
var withoutDigest = verdict with { Digest = null };
var json = Serialize(withoutDigest);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static Verdict WithDigest(Verdict verdict)
=> verdict with { Digest = ComputeDigest(verdict) };
}

View File

@@ -0,0 +1,195 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.DeltaVerdict.Models;
using StellaOps.DeltaVerdict.Serialization;
namespace StellaOps.DeltaVerdict.Signing;
public interface IDeltaSigningService
{
Task<DeltaVerdict.Models.DeltaVerdict> SignAsync(
DeltaVerdict.Models.DeltaVerdict delta,
SigningOptions options,
CancellationToken ct = default);
Task<VerificationResult> VerifyAsync(
DeltaVerdict.Models.DeltaVerdict delta,
VerificationOptions options,
CancellationToken ct = default);
}
public sealed class DeltaSigningService : IDeltaSigningService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public Task<DeltaVerdict.Models.DeltaVerdict> SignAsync(
DeltaVerdict.Models.DeltaVerdict delta,
SigningOptions options,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(delta);
ArgumentNullException.ThrowIfNull(options);
ct.ThrowIfCancellationRequested();
var withDigest = DeltaVerdictSerializer.WithDigest(delta);
var payloadJson = DeltaVerdictSerializer.Serialize(withDigest with { Signature = null });
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
var envelope = BuildEnvelope(payloadBytes, options);
var envelopeJson = JsonSerializer.Serialize(envelope, JsonOptions);
return Task.FromResult(withDigest with { Signature = envelopeJson });
}
public Task<VerificationResult> VerifyAsync(
DeltaVerdict.Models.DeltaVerdict delta,
VerificationOptions options,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(delta);
ArgumentNullException.ThrowIfNull(options);
ct.ThrowIfCancellationRequested();
if (string.IsNullOrEmpty(delta.Signature))
{
return Task.FromResult(VerificationResult.Fail("Delta is not signed"));
}
DsseEnvelope? envelope;
try
{
envelope = JsonSerializer.Deserialize<DsseEnvelope>(delta.Signature, JsonOptions);
}
catch (JsonException ex)
{
return Task.FromResult(VerificationResult.Fail($"Invalid signature envelope: {ex.Message}"));
}
if (envelope is null)
{
return Task.FromResult(VerificationResult.Fail("Signature envelope is empty"));
}
var payloadBytes = Convert.FromBase64String(envelope.Payload);
var pae = BuildPae(envelope.PayloadType, payloadBytes);
var expectedSig = ComputeSignature(pae, options);
var matched = envelope.Signatures.Any(sig =>
string.Equals(sig.KeyId, options.KeyId, StringComparison.Ordinal)
&& string.Equals(sig.Sig, expectedSig, StringComparison.Ordinal));
if (!matched)
{
return Task.FromResult(VerificationResult.Fail("Signature verification failed"));
}
if (!string.IsNullOrEmpty(delta.DeltaDigest))
{
var computed = DeltaVerdictSerializer.ComputeDigest(delta);
if (!string.Equals(computed, delta.DeltaDigest, StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(VerificationResult.Fail("Delta digest mismatch"));
}
}
return Task.FromResult(VerificationResult.Success());
}
private static DsseEnvelope BuildEnvelope(byte[] payload, SigningOptions options)
{
var pae = BuildPae(options.PayloadType, payload);
var signature = ComputeSignature(pae, options);
return new DsseEnvelope(
options.PayloadType,
Convert.ToBase64String(payload),
[new DsseSignature(options.KeyId, signature)]);
}
private static string ComputeSignature(byte[] pae, SigningOptions options)
{
return options.Algorithm switch
{
SigningAlgorithm.HmacSha256 => ComputeHmac(pae, options.SecretBase64),
SigningAlgorithm.Sha256 => Convert.ToBase64String(SHA256.HashData(pae)),
_ => throw new InvalidOperationException($"Unsupported signing algorithm: {options.Algorithm}")
};
}
private static string ComputeHmac(byte[] data, string? secretBase64)
{
if (string.IsNullOrWhiteSpace(secretBase64))
{
throw new InvalidOperationException("HMAC signing requires a base64 secret.");
}
var secret = Convert.FromBase64String(secretBase64);
using var hmac = new HMACSHA256(secret);
var sig = hmac.ComputeHash(data);
return Convert.ToBase64String(sig);
}
private static byte[] BuildPae(string payloadType, byte[] payload)
{
var prefix = "DSSEv1";
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var prefixBytes = Encoding.UTF8.GetBytes(prefix);
var lengthType = Encoding.UTF8.GetBytes(typeBytes.Length.ToString());
var lengthPayload = Encoding.UTF8.GetBytes(payload.Length.ToString());
using var stream = new MemoryStream();
stream.Write(prefixBytes);
stream.WriteByte((byte)' ');
stream.Write(lengthType);
stream.WriteByte((byte)' ');
stream.Write(typeBytes);
stream.WriteByte((byte)' ');
stream.Write(lengthPayload);
stream.WriteByte((byte)' ');
stream.Write(payload);
return stream.ToArray();
}
}
public sealed record SigningOptions
{
public required string KeyId { get; init; }
public SigningAlgorithm Algorithm { get; init; } = SigningAlgorithm.HmacSha256;
public string? SecretBase64 { get; init; }
public string PayloadType { get; init; } = "application/vnd.stellaops.delta-verdict+json";
}
public sealed record VerificationOptions
{
public required string KeyId { get; init; }
public SigningAlgorithm Algorithm { get; init; } = SigningAlgorithm.HmacSha256;
public string? SecretBase64 { get; init; }
}
public enum SigningAlgorithm
{
HmacSha256,
Sha256
}
public sealed record VerificationResult
{
public required bool IsValid { get; init; }
public string? Error { get; init; }
public static VerificationResult Success() => new() { IsValid = true };
public static VerificationResult Fail(string error) => new() { IsValid = false, Error = error };
}
public sealed record DsseEnvelope(
string PayloadType,
string Payload,
IReadOnlyList<DsseSignature> Signatures);
public sealed record DsseSignature(string KeyId, string Sig);

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
</ItemGroup>
</Project>