using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using StellaOps.Cryptography; namespace StellaOps.Audit.ReplayToken; /// /// Generates replay tokens using SHA-256 hashing with deterministic canonicalization. /// public sealed class Sha256ReplayTokenGenerator : IReplayTokenGenerator { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; private readonly ICryptoHash _cryptoHash; private readonly TimeProvider _timeProvider; public Sha256ReplayTokenGenerator(ICryptoHash cryptoHash, TimeProvider timeProvider) { _cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); } public ReplayToken Generate(ReplayTokenRequest request) { ArgumentNullException.ThrowIfNull(request); var hashHex = ComputeTokenValue(request, ReplayToken.DefaultVersion); return new ReplayToken(hashHex, _timeProvider.GetUtcNow()); } public ReplayToken GenerateWithExpiration(ReplayTokenRequest request, TimeSpan? expiration = null) { ArgumentNullException.ThrowIfNull(request); var effectiveExpiration = expiration ?? ReplayToken.DefaultExpiration; if (effectiveExpiration <= TimeSpan.Zero) { throw new ArgumentOutOfRangeException(nameof(expiration), "Expiration must be positive."); } var hashHex = ComputeTokenValue(request, ReplayToken.VersionWithExpiration); var now = _timeProvider.GetUtcNow(); var expiresAt = now + effectiveExpiration; return new ReplayToken(hashHex, now, expiresAt, ReplayToken.DefaultAlgorithm, ReplayToken.VersionWithExpiration); } public bool Verify(ReplayToken token, ReplayTokenRequest request) { ArgumentNullException.ThrowIfNull(token); ArgumentNullException.ThrowIfNull(request); var computed = ComputeTokenValue(request, token.Version); return string.Equals(token.Value, computed, StringComparison.OrdinalIgnoreCase); } public ReplayTokenVerificationResult VerifyWithExpiration(ReplayToken token, ReplayTokenRequest request) { ArgumentNullException.ThrowIfNull(token); ArgumentNullException.ThrowIfNull(request); // Check hash first var computed = ComputeTokenValue(request, token.Version); if (!string.Equals(token.Value, computed, StringComparison.OrdinalIgnoreCase)) { return ReplayTokenVerificationResult.Invalid; } // Check expiration if (token.IsExpired(_timeProvider.GetUtcNow())) { return ReplayTokenVerificationResult.Expired; } return ReplayTokenVerificationResult.Valid; } private string ComputeHash(string input) { var bytes = Encoding.UTF8.GetBytes(input); return _cryptoHash.ComputeHashHex(bytes, HashAlgorithms.Sha256); } private string ComputeTokenValue(ReplayTokenRequest request, string version) { var canonical = Canonicalize(request, version); return ComputeHash(canonical); } private static string? NormalizeValue(string? value) { if (string.IsNullOrWhiteSpace(value)) { return null; } return value.Trim(); } private static List NormalizeSortedList(IReadOnlyList? values) { if (values is null || values.Count == 0) { return new List(); } var normalized = values .Where(static x => !string.IsNullOrWhiteSpace(x)) .Select(static x => x.Trim()) .OrderBy(static x => x, StringComparer.Ordinal) .ToList(); return normalized; } private static Dictionary NormalizeSortedDictionary(IReadOnlyDictionary? values) { if (values is null || values.Count == 0) { return new Dictionary(); } var normalized = new List>(values.Count); var seen = new HashSet(StringComparer.Ordinal); foreach (var kvp in values) { if (string.IsNullOrWhiteSpace(kvp.Key)) { continue; } var key = kvp.Key.Trim(); if (!seen.Add(key)) { throw new ArgumentException($"AdditionalContext contains duplicate key after normalization: '{key}'.", nameof(values)); } normalized.Add(new KeyValuePair(key, kvp.Value?.Trim() ?? string.Empty)); } var ordered = normalized .OrderBy(static kvp => kvp.Key, StringComparer.Ordinal) .ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value, StringComparer.Ordinal); return ordered; } /// /// Produces deterministic canonical representation of inputs. /// private static string Canonicalize(ReplayTokenRequest request, string version) { var canonical = new CanonicalReplayInput { Version = version, FeedManifests = NormalizeSortedList(request.FeedManifests), RulesVersion = NormalizeValue(request.RulesVersion), RulesHash = NormalizeValue(request.RulesHash), LatticePolicyVersion = NormalizeValue(request.LatticePolicyVersion), LatticePolicyHash = NormalizeValue(request.LatticePolicyHash), InputHashes = NormalizeSortedList(request.InputHashes), ScoringConfigVersion = NormalizeValue(request.ScoringConfigVersion), EvidenceHashes = NormalizeSortedList(request.EvidenceHashes), AdditionalContext = NormalizeSortedDictionary(request.AdditionalContext) }; return JsonSerializer.Serialize(canonical, JsonOptions); } private sealed class CanonicalReplayInput { public required string Version { get; init; } public required List FeedManifests { get; init; } public string? RulesVersion { get; init; } public string? RulesHash { get; init; } public string? LatticePolicyVersion { get; init; } public string? LatticePolicyHash { get; init; } public required List InputHashes { get; init; } public string? ScoringConfigVersion { get; init; } public required List EvidenceHashes { get; init; } public required Dictionary AdditionalContext { get; init; } } }