190 lines
7.4 KiB
C#
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);
|
|
|