192 lines
6.6 KiB
C#
192 lines
6.6 KiB
C#
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using StellaOps.Cryptography;
|
|
|
|
namespace StellaOps.Audit.ReplayToken;
|
|
|
|
/// <summary>
|
|
/// Generates replay tokens using SHA-256 hashing with deterministic canonicalization.
|
|
/// </summary>
|
|
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<string> NormalizeSortedList(IReadOnlyList<string>? values)
|
|
{
|
|
if (values is null || values.Count == 0)
|
|
{
|
|
return new List<string>();
|
|
}
|
|
|
|
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<string, string> NormalizeSortedDictionary(IReadOnlyDictionary<string, string>? values)
|
|
{
|
|
if (values is null || values.Count == 0)
|
|
{
|
|
return new Dictionary<string, string>();
|
|
}
|
|
|
|
var normalized = new List<KeyValuePair<string, string>>(values.Count);
|
|
var seen = new HashSet<string>(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<string, string>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Produces deterministic canonical representation of inputs.
|
|
/// </summary>
|
|
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<string> 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<string> InputHashes { get; init; }
|
|
public string? ScoringConfigVersion { get; init; }
|
|
public required List<string> EvidenceHashes { get; init; }
|
|
public required Dictionary<string, string> AdditionalContext { get; init; }
|
|
}
|
|
}
|