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 VerifyAsync(ProofSpine spine, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(spine); cancellationToken.ThrowIfCancellationRequested(); var spineErrors = new List(); var segments = spine.Segments ?? Array.Empty(); 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(); 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(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 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 Errors, IReadOnlyList Segments); public sealed record ProofSegmentVerificationResult( string SegmentId, ProofSegmentStatus Status, IReadOnlyList Errors);