sprints work

This commit is contained in:
StellaOps Bot
2025-12-25 12:19:12 +02:00
parent 223843f1d1
commit 2a06f780cf
224 changed files with 41796 additions and 1515 deletions

View File

@@ -1,19 +1,50 @@
namespace StellaOps.Audit.ReplayToken;
/// <summary>
/// Result of token verification including expiration check.
/// </summary>
public enum ReplayTokenVerificationResult
{
/// <summary>Token is valid and not expired.</summary>
Valid = 0,
/// <summary>Token hash does not match the inputs (tampered or different inputs).</summary>
Invalid = 1,
/// <summary>Token has expired.</summary>
Expired = 2
}
/// <summary>
/// Generates deterministic replay tokens for audit and reproducibility.
/// </summary>
public interface IReplayTokenGenerator
{
/// <summary>
/// Generates a replay token from the given inputs.
/// Generates a replay token from the given inputs without expiration (v1.0).
/// </summary>
/// <param name="request">The inputs to hash.</param>
/// <returns>A deterministic replay token.</returns>
ReplayToken Generate(ReplayTokenRequest request);
/// <summary>
/// Verifies that inputs match a previously generated token.
/// Generates a replay token from the given inputs with expiration (v2.0).
/// </summary>
/// <param name="request">The inputs to hash.</param>
/// <param name="expiration">How long the token is valid. If null, uses ReplayToken.DefaultExpiration.</param>
/// <returns>A deterministic replay token with expiration.</returns>
ReplayToken GenerateWithExpiration(ReplayTokenRequest request, TimeSpan? expiration = null);
/// <summary>
/// Verifies that inputs match a previously generated token (does not check expiration).
/// </summary>
bool Verify(ReplayToken token, ReplayTokenRequest request);
/// <summary>
/// Verifies that inputs match a previously generated token and checks expiration.
/// </summary>
/// <param name="token">The token to verify.</param>
/// <param name="request">The inputs to verify against.</param>
/// <returns>The verification result including expiration check.</returns>
ReplayTokenVerificationResult VerifyWithExpiration(ReplayToken token, ReplayTokenRequest request);
}

View File

@@ -1,7 +1,7 @@
namespace StellaOps.Audit.ReplayToken;
/// <summary>
/// A deterministic, content-addressable replay token.
/// A deterministic, content-addressable replay token with optional expiration.
/// </summary>
public sealed class ReplayToken : IEquatable<ReplayToken>
{
@@ -9,6 +9,16 @@ public sealed class ReplayToken : IEquatable<ReplayToken>
public const string DefaultAlgorithm = "SHA-256";
public const string DefaultVersion = "1.0";
/// <summary>
/// Version 2.0 includes expiration support.
/// </summary>
public const string VersionWithExpiration = "2.0";
/// <summary>
/// Default expiration duration (1 hour).
/// </summary>
public static readonly TimeSpan DefaultExpiration = TimeSpan.FromHours(1);
/// <summary>
/// The token value (SHA-256 hash in hex).
/// </summary>
@@ -30,11 +40,30 @@ public sealed class ReplayToken : IEquatable<ReplayToken>
public DateTimeOffset GeneratedAt { get; }
/// <summary>
/// Canonical representation for storage.
/// Timestamp when token expires. Null means no expiration (v1.0 behavior).
/// </summary>
public string Canonical => $"{Scheme}:v{Version}:{Algorithm}:{Value}";
public DateTimeOffset? ExpiresAt { get; }
/// <summary>
/// Canonical representation for storage.
/// For v2.0+, includes expiration timestamp.
/// </summary>
public string Canonical => ExpiresAt.HasValue
? $"{Scheme}:v{Version}:{Algorithm}:{Value}:{ExpiresAt.Value.ToUnixTimeSeconds()}"
: $"{Scheme}:v{Version}:{Algorithm}:{Value}";
/// <summary>
/// Creates a replay token without expiration (v1.0 compatibility).
/// </summary>
public ReplayToken(string value, DateTimeOffset generatedAt, string? algorithm = null, string? version = null)
: this(value, generatedAt, null, algorithm, version)
{
}
/// <summary>
/// Creates a replay token with optional expiration.
/// </summary>
public ReplayToken(string value, DateTimeOffset generatedAt, DateTimeOffset? expiresAt, string? algorithm = null, string? version = null)
{
if (string.IsNullOrWhiteSpace(value))
{
@@ -43,12 +72,56 @@ public sealed class ReplayToken : IEquatable<ReplayToken>
Value = value.Trim();
GeneratedAt = generatedAt;
ExpiresAt = expiresAt;
Algorithm = string.IsNullOrWhiteSpace(algorithm) ? DefaultAlgorithm : algorithm.Trim();
Version = string.IsNullOrWhiteSpace(version) ? DefaultVersion : version.Trim();
// Default to v2.0 if expiration is set, otherwise use provided or default
if (expiresAt.HasValue && string.IsNullOrWhiteSpace(version))
{
Version = VersionWithExpiration;
}
else
{
Version = string.IsNullOrWhiteSpace(version) ? DefaultVersion : version.Trim();
}
}
/// <summary>
/// Checks if the token has expired.
/// </summary>
/// <param name="currentTime">The current time to check against. If null, uses DateTimeOffset.UtcNow.</param>
/// <returns>True if the token has expired; false if not expired or has no expiration.</returns>
public bool IsExpired(DateTimeOffset? currentTime = null)
{
if (!ExpiresAt.HasValue)
{
return false; // v1.0 tokens without expiration never expire
}
var now = currentTime ?? DateTimeOffset.UtcNow;
return now >= ExpiresAt.Value;
}
/// <summary>
/// Gets the remaining time until expiration.
/// </summary>
/// <param name="currentTime">The current time to check against. If null, uses DateTimeOffset.UtcNow.</param>
/// <returns>The remaining time, or null if no expiration or already expired.</returns>
public TimeSpan? GetTimeToExpiration(DateTimeOffset? currentTime = null)
{
if (!ExpiresAt.HasValue)
{
return null;
}
var now = currentTime ?? DateTimeOffset.UtcNow;
var remaining = ExpiresAt.Value - now;
return remaining > TimeSpan.Zero ? remaining : null;
}
/// <summary>
/// Parse a canonical token string.
/// Supports both v1.0 format (4 parts) and v2.0 format with expiration (5 parts).
/// </summary>
public static ReplayToken Parse(string canonical)
{
@@ -58,7 +131,10 @@ public sealed class ReplayToken : IEquatable<ReplayToken>
}
var parts = canonical.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 4 || !string.Equals(parts[0], Scheme, StringComparison.Ordinal))
// v1.0 format: replay:v1.0:SHA-256:hash (4 parts)
// v2.0 format: replay:v2.0:SHA-256:hash:expiry_unix_seconds (5 parts)
if (parts.Length < 4 || parts.Length > 5 || !string.Equals(parts[0], Scheme, StringComparison.Ordinal))
{
throw new FormatException($"Invalid replay token format: {canonical}");
}
@@ -73,7 +149,35 @@ public sealed class ReplayToken : IEquatable<ReplayToken>
var algorithm = parts[2];
var value = parts[3];
return new ReplayToken(value, DateTimeOffset.UnixEpoch, algorithm, version);
DateTimeOffset? expiresAt = null;
if (parts.Length == 5)
{
if (!long.TryParse(parts[4], out var unixSeconds))
{
throw new FormatException($"Invalid expiration timestamp in replay token: {canonical}");
}
expiresAt = DateTimeOffset.FromUnixTimeSeconds(unixSeconds);
}
return new ReplayToken(value, DateTimeOffset.UnixEpoch, expiresAt, algorithm, version);
}
/// <summary>
/// Try to parse a canonical token string.
/// </summary>
/// <returns>True if parsing succeeded; false otherwise.</returns>
public static bool TryParse(string canonical, out ReplayToken? token)
{
try
{
token = Parse(canonical);
return true;
}
catch
{
token = null;
return false;
}
}
public override string ToString() => Canonical;

View File

@@ -35,6 +35,19 @@ public sealed class Sha256ReplayTokenGenerator : IReplayTokenGenerator
return new ReplayToken(hashHex, _timeProvider.GetUtcNow());
}
public ReplayToken GenerateWithExpiration(ReplayTokenRequest request, TimeSpan? expiration = null)
{
ArgumentNullException.ThrowIfNull(request);
var canonical = Canonicalize(request);
var hashHex = ComputeHash(canonical);
var now = _timeProvider.GetUtcNow();
var expiresAt = now + (expiration ?? ReplayToken.DefaultExpiration);
return new ReplayToken(hashHex, now, expiresAt, ReplayToken.DefaultAlgorithm, ReplayToken.VersionWithExpiration);
}
public bool Verify(ReplayToken token, ReplayTokenRequest request)
{
ArgumentNullException.ThrowIfNull(token);
@@ -44,6 +57,27 @@ public sealed class Sha256ReplayTokenGenerator : IReplayTokenGenerator
return string.Equals(token.Value, computed.Value, StringComparison.OrdinalIgnoreCase);
}
public ReplayTokenVerificationResult VerifyWithExpiration(ReplayToken token, ReplayTokenRequest request)
{
ArgumentNullException.ThrowIfNull(token);
ArgumentNullException.ThrowIfNull(request);
// Check hash first
var computed = Generate(request);
if (!string.Equals(token.Value, computed.Value, 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);