save progress
This commit is contained in:
204
src/__Libraries/StellaOps.Replay.Core/Models/ReplayProof.cs
Normal file
204
src/__Libraries/StellaOps.Replay.Core/Models/ReplayProof.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
// <copyright file="ReplayProof.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Replay.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Compact proof artifact for audit trails and ticket attachments.
|
||||
/// Captures the essential evidence that a replay was performed and matched expectations.
|
||||
/// </summary>
|
||||
public sealed record ReplayProof
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version for forward compatibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 of the replay bundle used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("bundleHash")]
|
||||
public required string BundleHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version used in the replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyVersion")]
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Merkle root of all verdict outputs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verdictRoot")]
|
||||
public required string VerdictRoot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the replayed verdict matches the expected verdict.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verdictMatches")]
|
||||
public required bool VerdictMatches { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Replay execution duration in milliseconds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("durationMs")]
|
||||
public required long DurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when replay was performed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("replayedAt")]
|
||||
public required DateTimeOffset ReplayedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the replay engine used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("engineVersion")]
|
||||
public required string EngineVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original artifact digest (image or SBOM) that was evaluated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifactDigest")]
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature verified status (true/false/null if not present).
|
||||
/// </summary>
|
||||
[JsonPropertyName("signatureVerified")]
|
||||
public bool? SignatureVerified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used for signature verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signatureKeyId")]
|
||||
public string? SignatureKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata (e.g., organization, project, tenant).
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON serializer options for canonical serialization (sorted keys, no indentation).
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions CanonicalOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
// Note: We manually ensure sorted keys in ToCanonicalJson()
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Converts the proof to a compact string format: "replay-proof:<sha256>".
|
||||
/// The hash is computed over the canonical JSON representation.
|
||||
/// </summary>
|
||||
/// <returns>Compact proof string suitable for ticket attachments.</returns>
|
||||
public string ToCompactString()
|
||||
{
|
||||
var canonicalJson = ToCanonicalJson();
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(canonicalJson));
|
||||
var hashHex = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
return $"replay-proof:{hashHex}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the proof to canonical JSON (RFC 8785 style: sorted keys, minimal whitespace).
|
||||
/// </summary>
|
||||
/// <returns>Canonical JSON string.</returns>
|
||||
public string ToCanonicalJson()
|
||||
{
|
||||
// Build ordered dictionary for canonical serialization
|
||||
var ordered = new SortedDictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["artifactDigest"] = ArtifactDigest,
|
||||
["bundleHash"] = BundleHash,
|
||||
["durationMs"] = DurationMs,
|
||||
["engineVersion"] = EngineVersion,
|
||||
["metadata"] = Metadata is not null && Metadata.Count > 0
|
||||
? new SortedDictionary<string, string>(Metadata, StringComparer.Ordinal)
|
||||
: null,
|
||||
["policyVersion"] = PolicyVersion,
|
||||
["replayedAt"] = ReplayedAt.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ", System.Globalization.CultureInfo.InvariantCulture),
|
||||
["schemaVersion"] = SchemaVersion,
|
||||
["signatureKeyId"] = SignatureKeyId,
|
||||
["signatureVerified"] = SignatureVerified,
|
||||
["verdictMatches"] = VerdictMatches,
|
||||
["verdictRoot"] = VerdictRoot,
|
||||
};
|
||||
|
||||
// Remove null values for canonical form
|
||||
var filtered = ordered.Where(kvp => kvp.Value is not null)
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||||
|
||||
return JsonSerializer.Serialize(filtered, CanonicalOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a compact proof string and validates its hash.
|
||||
/// </summary>
|
||||
/// <param name="compactString">The compact proof string (replay-proof:<hash>).</param>
|
||||
/// <param name="originalJson">The original canonical JSON to verify against.</param>
|
||||
/// <returns>True if the hash matches, false otherwise.</returns>
|
||||
public static bool ValidateCompactString(string compactString, string originalJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(compactString) || string.IsNullOrWhiteSpace(originalJson))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const string prefix = "replay-proof:";
|
||||
if (!compactString.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedHash = compactString[prefix.Length..];
|
||||
var actualHashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(originalJson));
|
||||
var actualHash = Convert.ToHexString(actualHashBytes).ToLowerInvariant();
|
||||
|
||||
return string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ReplayProof from execution results.
|
||||
/// </summary>
|
||||
public static ReplayProof FromExecutionResult(
|
||||
string bundleHash,
|
||||
string policyVersion,
|
||||
string verdictRoot,
|
||||
bool verdictMatches,
|
||||
long durationMs,
|
||||
DateTimeOffset replayedAt,
|
||||
string engineVersion,
|
||||
string? artifactDigest = null,
|
||||
bool? signatureVerified = null,
|
||||
string? signatureKeyId = null,
|
||||
ImmutableDictionary<string, string>? metadata = null)
|
||||
{
|
||||
return new ReplayProof
|
||||
{
|
||||
BundleHash = bundleHash ?? throw new ArgumentNullException(nameof(bundleHash)),
|
||||
PolicyVersion = policyVersion ?? throw new ArgumentNullException(nameof(policyVersion)),
|
||||
VerdictRoot = verdictRoot ?? throw new ArgumentNullException(nameof(verdictRoot)),
|
||||
VerdictMatches = verdictMatches,
|
||||
DurationMs = durationMs,
|
||||
ReplayedAt = replayedAt,
|
||||
EngineVersion = engineVersion ?? throw new ArgumentNullException(nameof(engineVersion)),
|
||||
ArtifactDigest = artifactDigest,
|
||||
SignatureVerified = signatureVerified,
|
||||
SignatureKeyId = signatureKeyId,
|
||||
Metadata = metadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user