Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/ProofSpineVerifier.cs
2026-02-01 21:37:40 +02:00

190 lines
7.4 KiB
C#

using StellaOps.Cryptography;
using StellaOps.Replay.Core;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.ProofSpine;
public sealed class ProofSpineVerifier
{
private static readonly JsonSerializerOptions SignedPayloadOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() }
};
private readonly IDsseSigningService _signingService;
private readonly ICryptoHash _cryptoHash;
public ProofSpineVerifier(IDsseSigningService signingService, ICryptoHash cryptoHash)
{
_signingService = signingService ?? throw new ArgumentNullException(nameof(signingService));
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
}
public async Task<ProofSpineVerificationResult> VerifyAsync(ProofSpine spine, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(spine);
cancellationToken.ThrowIfCancellationRequested();
var spineErrors = new List<string>();
var segments = spine.Segments ?? Array.Empty<ProofSegment>();
var segmentResults = new ProofSegmentVerificationResult[segments.Count];
string? prevHash = null;
for (var i = 0; i < segments.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var segment = segments[i];
var errors = new List<string>();
if (segment.Index != i)
{
errors.Add($"segment_index_mismatch:{segment.Index}->{i}");
}
if (i == 0)
{
if (segment.PrevSegmentHash is not null)
{
errors.Add("prev_hash_expected_null");
}
}
else if (!string.Equals(segment.PrevSegmentHash, prevHash, StringComparison.Ordinal))
{
errors.Add("prev_hash_mismatch");
}
var expectedSegmentId = ComputeSegmentId(segment.SegmentType, i, segment.InputHash, segment.ResultHash, prevHash);
if (!string.Equals(segment.SegmentId, expectedSegmentId, StringComparison.Ordinal))
{
errors.Add("segment_id_mismatch");
}
var dsseOutcome = await _signingService.VerifyAsync(segment.Envelope, cancellationToken).ConfigureAwait(false);
var status = dsseOutcome switch
{
{ IsValid: true, IsTrusted: true } => ProofSegmentStatus.Verified,
{ IsValid: true, IsTrusted: false } => ProofSegmentStatus.Untrusted,
{ IsValid: false, FailureReason: "dsse_key_not_trusted" } => ProofSegmentStatus.Untrusted,
_ => ProofSegmentStatus.Invalid
};
if (!dsseOutcome.IsValid && !string.IsNullOrWhiteSpace(dsseOutcome.FailureReason))
{
errors.Add(dsseOutcome.FailureReason);
}
if (!TryReadSignedPayload(segment.Envelope, out var signed, out var payloadError))
{
errors.Add(payloadError ?? "signed_payload_invalid");
status = ProofSegmentStatus.Invalid;
}
else
{
if (!string.Equals(signed.SegmentType, segment.SegmentType.ToString(), StringComparison.OrdinalIgnoreCase))
{
errors.Add("signed_segment_type_mismatch");
status = ProofSegmentStatus.Invalid;
}
if (signed.Index != i)
{
errors.Add("signed_index_mismatch");
status = ProofSegmentStatus.Invalid;
}
if (!string.Equals(signed.InputHash, segment.InputHash, StringComparison.Ordinal)
|| !string.Equals(signed.ResultHash, segment.ResultHash, StringComparison.Ordinal)
|| !string.Equals(signed.PrevSegmentHash, segment.PrevSegmentHash, StringComparison.Ordinal))
{
errors.Add("signed_fields_mismatch");
status = ProofSegmentStatus.Invalid;
}
}
segmentResults[i] = new ProofSegmentVerificationResult(segment.SegmentId, status, errors.ToArray());
prevHash = segment.ResultHash;
}
var expectedRootHash = ComputeRootHash(segments.Select(s => s.ResultHash));
if (!string.Equals(spine.RootHash, expectedRootHash, StringComparison.Ordinal))
{
spineErrors.Add("root_hash_mismatch");
}
var expectedSpineId = ComputeSpineId(spine.ArtifactId, spine.VulnerabilityId, spine.PolicyProfileId, expectedRootHash);
if (!string.Equals(spine.SpineId, expectedSpineId, StringComparison.Ordinal))
{
spineErrors.Add("spine_id_mismatch");
}
var ok = spineErrors.Count == 0 && segmentResults.All(r => r.Status is ProofSegmentStatus.Verified or ProofSegmentStatus.Untrusted);
return new ProofSpineVerificationResult(ok, spineErrors.ToArray(), segmentResults);
}
private static bool TryReadSignedPayload(DsseEnvelope envelope, out SignedProofSegmentPayload payload, out string? error)
{
error = null;
payload = default!;
try
{
var bytes = Convert.FromBase64String(envelope.Payload);
payload = JsonSerializer.Deserialize<SignedProofSegmentPayload>(bytes, SignedPayloadOptions)
?? throw new InvalidOperationException("signed_payload_null");
return true;
}
catch (FormatException)
{
error = "dsse_payload_not_base64";
return false;
}
catch (Exception)
{
error = "signed_payload_json_invalid";
return false;
}
}
private string ComputeRootHash(IEnumerable<string> segmentResultHashes)
{
var concat = string.Join(":", segmentResultHashes);
return _cryptoHash.ComputePrefixedHashForPurpose(Encoding.UTF8.GetBytes(concat), HashPurpose.Content);
}
private string ComputeSpineId(string artifactId, string vulnId, string profileId, string rootHash)
{
var data = $"{artifactId}:{vulnId}:{profileId}:{rootHash}";
var hex = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(data), HashPurpose.Content);
return hex[..32];
}
private string ComputeSegmentId(ProofSegmentType type, int index, string inputHash, string resultHash, string? prevHash)
{
var data = $"{type}:{index}:{inputHash}:{resultHash}:{prevHash ?? "null"}";
var hex = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(data), HashPurpose.Content);
return hex[..32];
}
private sealed record SignedProofSegmentPayload(
[property: JsonPropertyName("segmentType")] string SegmentType,
[property: JsonPropertyName("index")] int Index,
[property: JsonPropertyName("inputHash")] string InputHash,
[property: JsonPropertyName("resultHash")] string ResultHash,
[property: JsonPropertyName("prevSegmentHash")] string? PrevSegmentHash);
}
public sealed record ProofSpineVerificationResult(
bool IsValid,
IReadOnlyList<string> Errors,
IReadOnlyList<ProofSegmentVerificationResult> Segments);
public sealed record ProofSegmentVerificationResult(
string SegmentId,
ProofSegmentStatus Status,
IReadOnlyList<string> Errors);