partly or unimplemented features - now implemented
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
/// <summary>
|
||||
/// CI/CD timestamping request.
|
||||
/// </summary>
|
||||
public sealed record CiCdTimestampingRequest
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required string PipelineId { get; init; }
|
||||
public required string Environment { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public IReadOnlyList<CiCdArtifactInput> Artifacts { get; init; } = Array.Empty<CiCdArtifactInput>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact descriptor for timestamping.
|
||||
/// </summary>
|
||||
public sealed record CiCdArtifactInput
|
||||
{
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required string ArtifactType { get; init; }
|
||||
public string? HashAlgorithm { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact-level status.
|
||||
/// </summary>
|
||||
public enum CiCdTimestampingArtifactStatus
|
||||
{
|
||||
Timestamped,
|
||||
Failed,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persisted receipt metadata associated with an artifact timestamp token.
|
||||
/// </summary>
|
||||
public sealed record ArtifactTimestampReceipt
|
||||
{
|
||||
public required string ProviderName { get; init; }
|
||||
public required string TokenDigestSha256 { get; init; }
|
||||
public required string EncodedTokenBase64 { get; init; }
|
||||
public required DateTimeOffset TimestampedAtUtc { get; init; }
|
||||
public required DateTimeOffset RecordedAtUtc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result for one artifact.
|
||||
/// </summary>
|
||||
public sealed record CiCdTimestampingArtifactResult
|
||||
{
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required string ArtifactType { get; init; }
|
||||
public required CiCdTimestampingArtifactStatus Status { get; init; }
|
||||
public string? FailureReason { get; init; }
|
||||
public IReadOnlyList<ArtifactTimestampReceipt> Receipts { get; init; } = Array.Empty<ArtifactTimestampReceipt>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of CI/CD timestamping orchestration.
|
||||
/// </summary>
|
||||
public sealed record CiCdTimestampingResult
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required string PipelineId { get; init; }
|
||||
public required string Environment { get; init; }
|
||||
public required string PolicyName { get; init; }
|
||||
public IReadOnlyList<CiCdTimestampingArtifactResult> Artifacts { get; init; } = Array.Empty<CiCdTimestampingArtifactResult>();
|
||||
|
||||
public bool IsSuccess => Artifacts.All(static artifact => artifact.Status != CiCdTimestampingArtifactStatus.Failed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry record linking an artifact digest to an issued timestamp token.
|
||||
/// </summary>
|
||||
public sealed record ArtifactTimestampRecord
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required string PipelineId { get; init; }
|
||||
public required string Environment { get; init; }
|
||||
public required string ArtifactType { get; init; }
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required string HashAlgorithm { get; init; }
|
||||
public required string ProviderName { get; init; }
|
||||
public required string TokenDigestSha256 { get; init; }
|
||||
public required string EncodedTokenBase64 { get; init; }
|
||||
public required DateTimeOffset TimestampedAtUtc { get; init; }
|
||||
public required DateTimeOffset RecordedAtUtc { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic CI/CD timestamp orchestration over RFC-3161 providers.
|
||||
/// </summary>
|
||||
public sealed class CiCdTimestampingService : ICiCdTimestampingService
|
||||
{
|
||||
private readonly ITimeStampAuthorityClient _timeStampAuthorityClient;
|
||||
private readonly IArtifactTimestampRegistry _registry;
|
||||
private readonly PipelineTimestampingPolicyOptions _policyOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<CiCdTimestampingService> _logger;
|
||||
|
||||
public CiCdTimestampingService(
|
||||
ITimeStampAuthorityClient timeStampAuthorityClient,
|
||||
IArtifactTimestampRegistry registry,
|
||||
IOptions<PipelineTimestampingPolicyOptions> policyOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<CiCdTimestampingService> logger)
|
||||
{
|
||||
_timeStampAuthorityClient = timeStampAuthorityClient ?? throw new ArgumentNullException(nameof(timeStampAuthorityClient));
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_policyOptions = (policyOptions ?? throw new ArgumentNullException(nameof(policyOptions))).Value;
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CiCdTimestampingResult> TimestampArtifactsAsync(
|
||||
CiCdTimestampingRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var tenantId = NormalizeRequired(request.TenantId, nameof(request.TenantId));
|
||||
var pipelineId = NormalizeRequired(request.PipelineId, nameof(request.PipelineId));
|
||||
var environment = NormalizeRequired(request.Environment, nameof(request.Environment));
|
||||
|
||||
if (request.Artifacts.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("At least one artifact is required for CI/CD timestamping.");
|
||||
}
|
||||
|
||||
var (policyName, policy) = ResolvePolicy(_policyOptions, pipelineId, environment);
|
||||
|
||||
var orderedArtifacts = request.Artifacts
|
||||
.OrderBy(static artifact => artifact.ArtifactDigest.Trim(), StringComparer.Ordinal)
|
||||
.ThenBy(static artifact => artifact.ArtifactType.Trim(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (!policy.Enabled)
|
||||
{
|
||||
return new CiCdTimestampingResult
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PipelineId = pipelineId,
|
||||
Environment = environment,
|
||||
PolicyName = policyName,
|
||||
Artifacts = orderedArtifacts.Select(static artifact => new CiCdTimestampingArtifactResult
|
||||
{
|
||||
ArtifactDigest = artifact.ArtifactDigest.Trim(),
|
||||
ArtifactType = artifact.ArtifactType.Trim(),
|
||||
Status = CiCdTimestampingArtifactStatus.Skipped,
|
||||
FailureReason = "Pipeline timestamp policy is disabled for this scope.",
|
||||
}).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
var artifactResults = new List<CiCdTimestampingArtifactResult>(orderedArtifacts.Length);
|
||||
foreach (var artifact in orderedArtifacts)
|
||||
{
|
||||
artifactResults.Add(await TimestampArtifactAsync(
|
||||
tenantId,
|
||||
pipelineId,
|
||||
environment,
|
||||
request.CorrelationId,
|
||||
artifact,
|
||||
policy,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
}
|
||||
|
||||
return new CiCdTimestampingResult
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PipelineId = pipelineId,
|
||||
Environment = environment,
|
||||
PolicyName = policyName,
|
||||
Artifacts = artifactResults,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<CiCdTimestampingArtifactResult> TimestampArtifactAsync(
|
||||
string tenantId,
|
||||
string pipelineId,
|
||||
string environment,
|
||||
string? correlationId,
|
||||
CiCdArtifactInput artifact,
|
||||
PipelineTimestampPolicy policy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var artifactType = NormalizeRequired(artifact.ArtifactType, nameof(artifact.ArtifactType));
|
||||
|
||||
if (!TryParseMessageImprint(
|
||||
artifact,
|
||||
policy.HashAlgorithm,
|
||||
out var messageImprint,
|
||||
out var hashAlgorithm,
|
||||
out var normalizedDigest,
|
||||
out var parseFailure))
|
||||
{
|
||||
return new CiCdTimestampingArtifactResult
|
||||
{
|
||||
ArtifactDigest = NormalizeDigestForDisplay(artifact.ArtifactDigest),
|
||||
ArtifactType = artifactType,
|
||||
Status = CiCdTimestampingArtifactStatus.Failed,
|
||||
FailureReason = parseFailure,
|
||||
};
|
||||
}
|
||||
|
||||
var acceptedReceipts = new List<ArtifactTimestampReceipt>();
|
||||
var seenProviders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var failureReason = "No successful timestamp response was returned by configured TSA providers.";
|
||||
|
||||
for (var attempt = 0; attempt < policy.MaxAttemptsPerArtifact; attempt++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var request = new TimeStampRequest
|
||||
{
|
||||
HashAlgorithm = hashAlgorithm,
|
||||
MessageImprint = messageImprint,
|
||||
Nonce = policy.IncludeNonce
|
||||
? CreateDeterministicNonce(tenantId, pipelineId, environment, artifactType, normalizedDigest, attempt)
|
||||
: null,
|
||||
CertificateRequired = policy.CertificateRequired,
|
||||
PolicyOid = policy.PolicyOid,
|
||||
};
|
||||
|
||||
TimeStampResponse response;
|
||||
try
|
||||
{
|
||||
response = await _timeStampAuthorityClient
|
||||
.GetTimeStampAsync(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException or TaskCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"CI/CD timestamp attempt {Attempt} failed for artifact {ArtifactDigest} ({ArtifactType})",
|
||||
attempt + 1,
|
||||
normalizedDigest,
|
||||
artifactType);
|
||||
failureReason = ex.Message;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.IsSuccess || response.Token is null)
|
||||
{
|
||||
failureReason = response.StatusString ?? response.FailureInfo?.ToString() ?? failureReason;
|
||||
continue;
|
||||
}
|
||||
|
||||
var providerName = string.IsNullOrWhiteSpace(response.ProviderName)
|
||||
? "unknown"
|
||||
: response.ProviderName.Trim();
|
||||
|
||||
if (policy.RequireDistinctProviders && !seenProviders.Add(providerName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var verification = await _timeStampAuthorityClient
|
||||
.VerifyAsync(response.Token, messageImprint, TimeStampVerificationOptions.Offline, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!verification.IsValid)
|
||||
{
|
||||
failureReason = verification.Error?.Message ?? $"Verification status was {verification.Status}.";
|
||||
continue;
|
||||
}
|
||||
|
||||
var recordedAt = _timeProvider.GetUtcNow();
|
||||
var tokenDigest = ComputeHexDigest(response.Token.EncodedToken.Span);
|
||||
|
||||
var record = new ArtifactTimestampRecord
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PipelineId = pipelineId,
|
||||
Environment = environment,
|
||||
ArtifactType = artifactType,
|
||||
ArtifactDigest = normalizedDigest,
|
||||
HashAlgorithm = hashAlgorithm.Name ?? "SHA256",
|
||||
ProviderName = providerName,
|
||||
TokenDigestSha256 = tokenDigest,
|
||||
EncodedTokenBase64 = Convert.ToBase64String(response.Token.EncodedToken.Span),
|
||||
TimestampedAtUtc = response.Token.TstInfo.GenTime,
|
||||
RecordedAtUtc = recordedAt,
|
||||
CorrelationId = string.IsNullOrWhiteSpace(correlationId) ? null : correlationId.Trim(),
|
||||
};
|
||||
|
||||
await _registry.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
acceptedReceipts.Add(new ArtifactTimestampReceipt
|
||||
{
|
||||
ProviderName = record.ProviderName,
|
||||
TokenDigestSha256 = record.TokenDigestSha256,
|
||||
EncodedTokenBase64 = record.EncodedTokenBase64,
|
||||
TimestampedAtUtc = record.TimestampedAtUtc,
|
||||
RecordedAtUtc = record.RecordedAtUtc,
|
||||
});
|
||||
|
||||
if (acceptedReceipts.Count >= policy.RequiredSuccessCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (acceptedReceipts.Count < policy.RequiredSuccessCount)
|
||||
{
|
||||
return new CiCdTimestampingArtifactResult
|
||||
{
|
||||
ArtifactDigest = normalizedDigest,
|
||||
ArtifactType = artifactType,
|
||||
Status = CiCdTimestampingArtifactStatus.Failed,
|
||||
FailureReason = $"Required {policy.RequiredSuccessCount} successful timestamp(s) but got {acceptedReceipts.Count}. Last failure: {failureReason}",
|
||||
Receipts = acceptedReceipts
|
||||
.OrderBy(static receipt => receipt.ProviderName, StringComparer.Ordinal)
|
||||
.ThenBy(static receipt => receipt.TokenDigestSha256, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
return new CiCdTimestampingArtifactResult
|
||||
{
|
||||
ArtifactDigest = normalizedDigest,
|
||||
ArtifactType = artifactType,
|
||||
Status = CiCdTimestampingArtifactStatus.Timestamped,
|
||||
Receipts = acceptedReceipts
|
||||
.OrderBy(static receipt => receipt.ProviderName, StringComparer.Ordinal)
|
||||
.ThenBy(static receipt => receipt.TokenDigestSha256, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
private static (string PolicyName, PipelineTimestampPolicy Policy) ResolvePolicy(
|
||||
PipelineTimestampingPolicyOptions options,
|
||||
string pipelineId,
|
||||
string environment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var selected = options.DefaultPolicy ?? new PipelineTimestampPolicy();
|
||||
var policyName = "default";
|
||||
|
||||
if (TryGetPolicy(options.Pipelines, pipelineId, out var pipelinePolicy))
|
||||
{
|
||||
selected = pipelinePolicy;
|
||||
policyName = $"pipeline:{pipelineId}";
|
||||
}
|
||||
|
||||
if (TryGetPolicy(selected.Environments, environment, out var envPolicy))
|
||||
{
|
||||
selected = envPolicy;
|
||||
policyName = $"{policyName}/environment:{environment}";
|
||||
}
|
||||
|
||||
return (policyName, NormalizePolicy(selected));
|
||||
}
|
||||
|
||||
private static PipelineTimestampPolicy NormalizePolicy(PipelineTimestampPolicy policy)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
var requiredSuccessCount = Math.Clamp(policy.RequiredSuccessCount, 1, 8);
|
||||
var maxAttempts = Math.Clamp(policy.MaxAttemptsPerArtifact, requiredSuccessCount, 32);
|
||||
|
||||
return new PipelineTimestampPolicy
|
||||
{
|
||||
Enabled = policy.Enabled,
|
||||
RequiredSuccessCount = requiredSuccessCount,
|
||||
MaxAttemptsPerArtifact = maxAttempts,
|
||||
RequireDistinctProviders = policy.RequireDistinctProviders,
|
||||
IncludeNonce = policy.IncludeNonce,
|
||||
CertificateRequired = policy.CertificateRequired,
|
||||
HashAlgorithm = string.IsNullOrWhiteSpace(policy.HashAlgorithm) ? "SHA256" : policy.HashAlgorithm.Trim(),
|
||||
PolicyOid = string.IsNullOrWhiteSpace(policy.PolicyOid) ? null : policy.PolicyOid.Trim(),
|
||||
Environments = policy.Environments,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryParseMessageImprint(
|
||||
CiCdArtifactInput artifact,
|
||||
string fallbackHashAlgorithm,
|
||||
out byte[] messageImprint,
|
||||
out HashAlgorithmName hashAlgorithm,
|
||||
out string normalizedDigest,
|
||||
out string failureReason)
|
||||
{
|
||||
messageImprint = [];
|
||||
hashAlgorithm = HashAlgorithmName.SHA256;
|
||||
normalizedDigest = string.Empty;
|
||||
failureReason = string.Empty;
|
||||
|
||||
var digestInput = NormalizeDigestForDisplay(artifact.ArtifactDigest);
|
||||
if (digestInput.Length == 0)
|
||||
{
|
||||
failureReason = "Artifact digest is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var algorithmInput = string.IsNullOrWhiteSpace(artifact.HashAlgorithm)
|
||||
? fallbackHashAlgorithm
|
||||
: artifact.HashAlgorithm.Trim();
|
||||
|
||||
if (digestInput.Contains(':'))
|
||||
{
|
||||
var split = digestInput.Split(':', 2, StringSplitOptions.TrimEntries);
|
||||
if (split.Length == 2 && split[1].Length > 0)
|
||||
{
|
||||
algorithmInput = split[0];
|
||||
digestInput = split[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (digestInput.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
digestInput = digestInput[2..];
|
||||
}
|
||||
|
||||
if (digestInput.Length == 0 || (digestInput.Length % 2) != 0 || !IsHex(digestInput))
|
||||
{
|
||||
failureReason = "Artifact digest must be an even-length hexadecimal value.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryMapHashAlgorithm(algorithmInput, out hashAlgorithm))
|
||||
{
|
||||
failureReason = $"Unsupported hash algorithm '{algorithmInput}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
messageImprint = Convert.FromHexString(digestInput);
|
||||
normalizedDigest = $"{NormalizeAlgorithmName(hashAlgorithm)}:{digestInput.ToLowerInvariant()}";
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryMapHashAlgorithm(string? value, out HashAlgorithmName algorithm)
|
||||
{
|
||||
var normalized = value?.Trim().Replace("-", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
|
||||
algorithm = normalized switch
|
||||
{
|
||||
"SHA256" => HashAlgorithmName.SHA256,
|
||||
"SHA384" => HashAlgorithmName.SHA384,
|
||||
"SHA512" => HashAlgorithmName.SHA512,
|
||||
"SHA1" => HashAlgorithmName.SHA1,
|
||||
_ => default,
|
||||
};
|
||||
|
||||
return algorithm != default;
|
||||
}
|
||||
|
||||
private static string NormalizeAlgorithmName(HashAlgorithmName algorithm)
|
||||
{
|
||||
var name = algorithm.Name ?? "SHA256";
|
||||
return name.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string value, string paramName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value is required.", paramName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeDigestForDisplay(string value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool IsHex(string value)
|
||||
{
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (!char.IsAsciiHexDigit(c))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static byte[] CreateDeterministicNonce(
|
||||
string tenantId,
|
||||
string pipelineId,
|
||||
string environment,
|
||||
string artifactType,
|
||||
string artifactDigest,
|
||||
int attempt)
|
||||
{
|
||||
var material = string.Join("|",
|
||||
tenantId,
|
||||
pipelineId,
|
||||
environment,
|
||||
artifactType,
|
||||
artifactDigest,
|
||||
attempt.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(material));
|
||||
return digest.AsSpan(0, 8).ToArray();
|
||||
}
|
||||
|
||||
private static string ComputeHexDigest(ReadOnlySpan<byte> value)
|
||||
{
|
||||
return Convert.ToHexString(SHA256.HashData(value)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool TryGetPolicy(
|
||||
IReadOnlyDictionary<string, PipelineTimestampPolicy> policies,
|
||||
string key,
|
||||
out PipelineTimestampPolicy policy)
|
||||
{
|
||||
foreach (var entry in policies)
|
||||
{
|
||||
if (string.Equals(entry.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
policy = entry.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
policy = null!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
/// <summary>
|
||||
/// Registry that stores artifact to timestamp-token mappings.
|
||||
/// </summary>
|
||||
public interface IArtifactTimestampRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Inserts or replaces a timestamp record.
|
||||
/// </summary>
|
||||
Task UpsertAsync(
|
||||
ArtifactTimestampRecord record,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns timestamp records by artifact digest.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ArtifactTimestampRecord>> GetByArtifactDigestAsync(
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns timestamp records for a pipeline.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ArtifactTimestampRecord>> GetByPipelineAsync(
|
||||
string tenantId,
|
||||
string pipelineId,
|
||||
int maxResults = 200,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
/// <summary>
|
||||
/// Pipeline-facing service that obtains and records RFC-3161 timestamps for artifacts.
|
||||
/// </summary>
|
||||
public interface ICiCdTimestampingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Timestamps all artifacts in the request according to pipeline policy.
|
||||
/// </summary>
|
||||
Task<CiCdTimestampingResult> TimestampArtifactsAsync(
|
||||
CiCdTimestampingRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory artifact timestamp registry.
|
||||
/// </summary>
|
||||
public sealed class InMemoryArtifactTimestampRegistry : IArtifactTimestampRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ArtifactTimestampRecord> _records = new(StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpsertAsync(ArtifactTimestampRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var normalized = record with
|
||||
{
|
||||
TenantId = NormalizeValue(record.TenantId, nameof(record.TenantId)),
|
||||
PipelineId = NormalizeValue(record.PipelineId, nameof(record.PipelineId)),
|
||||
Environment = NormalizeValue(record.Environment, nameof(record.Environment)),
|
||||
ArtifactType = NormalizeValue(record.ArtifactType, nameof(record.ArtifactType)),
|
||||
ArtifactDigest = NormalizeDigest(record.ArtifactDigest),
|
||||
HashAlgorithm = NormalizeValue(record.HashAlgorithm, nameof(record.HashAlgorithm)),
|
||||
ProviderName = NormalizeValue(record.ProviderName, nameof(record.ProviderName)),
|
||||
TokenDigestSha256 = NormalizeDigest(record.TokenDigestSha256),
|
||||
EncodedTokenBase64 = NormalizeValue(record.EncodedTokenBase64, nameof(record.EncodedTokenBase64)),
|
||||
CorrelationId = NormalizeOptional(record.CorrelationId),
|
||||
};
|
||||
|
||||
_records[CreateKey(normalized)] = normalized;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ArtifactTimestampRecord>> GetByArtifactDigestAsync(
|
||||
string artifactDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var normalizedDigest = NormalizeDigest(artifactDigest);
|
||||
|
||||
var result = _records.Values
|
||||
.Where(record => string.Equals(record.ArtifactDigest, normalizedDigest, StringComparison.Ordinal))
|
||||
.OrderBy(record => record.RecordedAtUtc)
|
||||
.ThenBy(record => record.TenantId, StringComparer.Ordinal)
|
||||
.ThenBy(record => record.PipelineId, StringComparer.Ordinal)
|
||||
.ThenBy(record => record.ProviderName, StringComparer.Ordinal)
|
||||
.ThenBy(record => record.TokenDigestSha256, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ArtifactTimestampRecord>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ArtifactTimestampRecord>> GetByPipelineAsync(
|
||||
string tenantId,
|
||||
string pipelineId,
|
||||
int maxResults = 200,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var normalizedTenant = NormalizeValue(tenantId, nameof(tenantId));
|
||||
var normalizedPipeline = NormalizeValue(pipelineId, nameof(pipelineId));
|
||||
var cappedMaxResults = Math.Clamp(maxResults, 1, 10_000);
|
||||
|
||||
var result = _records.Values
|
||||
.Where(record =>
|
||||
string.Equals(record.TenantId, normalizedTenant, StringComparison.Ordinal) &&
|
||||
string.Equals(record.PipelineId, normalizedPipeline, StringComparison.Ordinal))
|
||||
.OrderBy(record => record.RecordedAtUtc)
|
||||
.ThenBy(record => record.ArtifactDigest, StringComparer.Ordinal)
|
||||
.ThenBy(record => record.ProviderName, StringComparer.Ordinal)
|
||||
.ThenBy(record => record.TokenDigestSha256, StringComparer.Ordinal)
|
||||
.Take(cappedMaxResults)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ArtifactTimestampRecord>>(result);
|
||||
}
|
||||
|
||||
private static string CreateKey(ArtifactTimestampRecord record)
|
||||
{
|
||||
return string.Join("|",
|
||||
record.TenantId,
|
||||
record.PipelineId,
|
||||
record.Environment,
|
||||
record.ArtifactType,
|
||||
record.ArtifactDigest,
|
||||
record.ProviderName,
|
||||
record.TokenDigestSha256);
|
||||
}
|
||||
|
||||
private static string NormalizeValue(string value, string paramName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value is required.", paramName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
throw new ArgumentException("Digest is required.", nameof(digest));
|
||||
}
|
||||
|
||||
return digest.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
/// <summary>
|
||||
/// Pipeline-scoped policy configuration for CI/CD timestamping orchestration.
|
||||
/// </summary>
|
||||
public sealed class PipelineTimestampingPolicyOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Authority:Timestamping:PipelinePolicies";
|
||||
|
||||
/// <summary>
|
||||
/// Default policy used when no pipeline-specific override exists.
|
||||
/// </summary>
|
||||
public PipelineTimestampPolicy DefaultPolicy { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Per-pipeline policy overrides.
|
||||
/// </summary>
|
||||
public Dictionary<string, PipelineTimestampPolicy> Pipelines { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Effective policy for timestamping artifacts in CI/CD flows.
|
||||
/// </summary>
|
||||
public sealed class PipelineTimestampPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether timestamping is enabled for this scope.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Number of successful timestamps required per artifact.
|
||||
/// </summary>
|
||||
public int RequiredSuccessCount { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum attempts per artifact.
|
||||
/// </summary>
|
||||
public int MaxAttemptsPerArtifact { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Whether each success must come from a distinct provider.
|
||||
/// </summary>
|
||||
public bool RequireDistinctProviders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether nonce should be included in requests.
|
||||
/// </summary>
|
||||
public bool IncludeNonce { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether TSA certificate should be requested in responses.
|
||||
/// </summary>
|
||||
public bool CertificateRequired { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Hash algorithm name used when artifact digest does not specify one.
|
||||
/// </summary>
|
||||
public string HashAlgorithm { get; set; } = "SHA256";
|
||||
|
||||
/// <summary>
|
||||
/// Optional TSA policy OID to request.
|
||||
/// </summary>
|
||||
public string? PolicyOid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment-specific overrides keyed by environment name.
|
||||
/// </summary>
|
||||
public Dictionary<string, PipelineTimestampPolicy> Environments { get; set; } = [];
|
||||
}
|
||||
@@ -6,3 +6,5 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Authority/__Libraries/StellaOps.Authority.Timestamping/StellaOps.Authority.Timestamping.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
| SPRINT_20260208_025-CORE | DONE | CI/CD timestamping orchestration and artifact timestamp registry implementation. |
|
||||
|
||||
@@ -25,22 +25,31 @@ public static partial class TimestampingServiceCollectionExtensions
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddTimestamping(
|
||||
this IServiceCollection services,
|
||||
Action<TsaClientOptions>? configure = null)
|
||||
Action<TsaClientOptions>? configure = null,
|
||||
Action<PipelineTimestampingPolicyOptions>? configurePipelinePolicies = null)
|
||||
{
|
||||
services.AddOptions<TsaClientOptions>();
|
||||
services.AddOptions<PipelineTimestampingPolicyOptions>();
|
||||
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
if (configurePipelinePolicies is not null)
|
||||
{
|
||||
services.Configure(configurePipelinePolicies);
|
||||
}
|
||||
|
||||
// Register HTTP client factory if not already registered
|
||||
services.AddHttpClient();
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Register core services
|
||||
services.TryAddSingleton<TimeStampTokenVerifier>();
|
||||
services.TryAddSingleton<ITsaProviderRegistry, TsaProviderRegistry>();
|
||||
services.TryAddSingleton<ITsaCacheStore, InMemoryTsaCacheStore>();
|
||||
services.TryAddSingleton<IArtifactTimestampRegistry, InMemoryArtifactTimestampRegistry>();
|
||||
services.TryAddSingleton<ICiCdTimestampingService, CiCdTimestampingService>();
|
||||
services.TryAddSingleton<ITimeStampAuthorityClient, HttpTsaClient>();
|
||||
|
||||
return services;
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Tests;
|
||||
|
||||
public sealed class CiCdTimestampingServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TimestampArtifactsAsync_StoresReceipts_WithDeterministicArtifactOrdering()
|
||||
{
|
||||
var registry = new InMemoryArtifactTimestampRegistry();
|
||||
var client = new FakeTimeStampAuthorityClient(
|
||||
[
|
||||
CreateSuccess("tsa-b", 0x10),
|
||||
CreateSuccess("tsa-a", 0x11),
|
||||
]);
|
||||
|
||||
var service = CreateService(
|
||||
client,
|
||||
registry,
|
||||
new PipelineTimestampingPolicyOptions
|
||||
{
|
||||
DefaultPolicy = new PipelineTimestampPolicy
|
||||
{
|
||||
Enabled = true,
|
||||
RequiredSuccessCount = 1,
|
||||
MaxAttemptsPerArtifact = 1,
|
||||
IncludeNonce = false,
|
||||
},
|
||||
});
|
||||
|
||||
var result = await service.TimestampArtifactsAsync(new CiCdTimestampingRequest
|
||||
{
|
||||
TenantId = "tenant-a",
|
||||
PipelineId = "pipeline-a",
|
||||
Environment = "stage",
|
||||
CorrelationId = "corr-1",
|
||||
Artifacts =
|
||||
[
|
||||
new CiCdArtifactInput { ArtifactDigest = "sha256:BBBB", ArtifactType = "sbom" },
|
||||
new CiCdArtifactInput { ArtifactDigest = "sha256:AAAA", ArtifactType = "attestation" },
|
||||
],
|
||||
});
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
"sha256:aaaa",
|
||||
"sha256:bbbb",
|
||||
}, result.Artifacts.Select(static a => a.ArtifactDigest).ToArray());
|
||||
|
||||
var persisted = await registry.GetByPipelineAsync("tenant-a", "pipeline-a");
|
||||
Assert.Equal(2, persisted.Count);
|
||||
Assert.Equal("sha256:aaaa", persisted[0].ArtifactDigest);
|
||||
Assert.Equal("sha256:bbbb", persisted[1].ArtifactDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TimestampArtifactsAsync_DualProviderPolicy_RequiresDistinctProviders()
|
||||
{
|
||||
var registry = new InMemoryArtifactTimestampRegistry();
|
||||
var client = new FakeTimeStampAuthorityClient(
|
||||
[
|
||||
CreateSuccess("tsa-a", 0x21),
|
||||
CreateSuccess("tsa-a", 0x22),
|
||||
CreateSuccess("tsa-b", 0x23),
|
||||
]);
|
||||
|
||||
var service = CreateService(
|
||||
client,
|
||||
registry,
|
||||
new PipelineTimestampingPolicyOptions
|
||||
{
|
||||
DefaultPolicy = new PipelineTimestampPolicy(),
|
||||
Pipelines = new Dictionary<string, PipelineTimestampPolicy>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["release-pipeline"] = new PipelineTimestampPolicy
|
||||
{
|
||||
Enabled = true,
|
||||
RequiredSuccessCount = 2,
|
||||
MaxAttemptsPerArtifact = 3,
|
||||
RequireDistinctProviders = true,
|
||||
IncludeNonce = false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
var result = await service.TimestampArtifactsAsync(new CiCdTimestampingRequest
|
||||
{
|
||||
TenantId = "tenant-a",
|
||||
PipelineId = "release-pipeline",
|
||||
Environment = "prod",
|
||||
Artifacts =
|
||||
[
|
||||
new CiCdArtifactInput { ArtifactDigest = "sha256:1111", ArtifactType = "sbom" },
|
||||
],
|
||||
});
|
||||
|
||||
var artifact = Assert.Single(result.Artifacts);
|
||||
Assert.Equal(CiCdTimestampingArtifactStatus.Timestamped, artifact.Status);
|
||||
Assert.Equal(2, artifact.Receipts.Count);
|
||||
Assert.Equal(new[] { "tsa-a", "tsa-b" }, artifact.Receipts.Select(static receipt => receipt.ProviderName).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TimestampArtifactsAsync_InvalidDigest_ReturnsFailureWithoutRegistryWrites()
|
||||
{
|
||||
var registry = new InMemoryArtifactTimestampRegistry();
|
||||
var client = new FakeTimeStampAuthorityClient([]);
|
||||
var service = CreateService(client, registry, new PipelineTimestampingPolicyOptions());
|
||||
|
||||
var result = await service.TimestampArtifactsAsync(new CiCdTimestampingRequest
|
||||
{
|
||||
TenantId = "tenant-a",
|
||||
PipelineId = "pipeline-a",
|
||||
Environment = "dev",
|
||||
Artifacts =
|
||||
[
|
||||
new CiCdArtifactInput { ArtifactDigest = "not-a-hex-digest", ArtifactType = "sbom" },
|
||||
],
|
||||
});
|
||||
|
||||
var artifact = Assert.Single(result.Artifacts);
|
||||
Assert.Equal(CiCdTimestampingArtifactStatus.Failed, artifact.Status);
|
||||
Assert.Contains("hexadecimal", artifact.FailureReason, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Empty(await registry.GetByPipelineAsync("tenant-a", "pipeline-a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TimestampArtifactsAsync_DisabledPolicy_SkipsArtifactsWithoutCallingTsa()
|
||||
{
|
||||
var registry = new InMemoryArtifactTimestampRegistry();
|
||||
var client = new FakeTimeStampAuthorityClient([]);
|
||||
var service = CreateService(
|
||||
client,
|
||||
registry,
|
||||
new PipelineTimestampingPolicyOptions
|
||||
{
|
||||
DefaultPolicy = new PipelineTimestampPolicy(),
|
||||
Pipelines = new Dictionary<string, PipelineTimestampPolicy>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["pipeline-a"] = new PipelineTimestampPolicy
|
||||
{
|
||||
Enabled = true,
|
||||
Environments = new Dictionary<string, PipelineTimestampPolicy>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["prod"] = new PipelineTimestampPolicy
|
||||
{
|
||||
Enabled = false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
var result = await service.TimestampArtifactsAsync(new CiCdTimestampingRequest
|
||||
{
|
||||
TenantId = "tenant-a",
|
||||
PipelineId = "pipeline-a",
|
||||
Environment = "prod",
|
||||
Artifacts =
|
||||
[
|
||||
new CiCdArtifactInput { ArtifactDigest = "sha256:aaaa", ArtifactType = "sbom" },
|
||||
],
|
||||
});
|
||||
|
||||
var artifact = Assert.Single(result.Artifacts);
|
||||
Assert.Equal(CiCdTimestampingArtifactStatus.Skipped, artifact.Status);
|
||||
Assert.Equal(0, client.RequestCount);
|
||||
Assert.Empty(await registry.GetByPipelineAsync("tenant-a", "pipeline-a"));
|
||||
}
|
||||
|
||||
private static CiCdTimestampingService CreateService(
|
||||
ITimeStampAuthorityClient client,
|
||||
IArtifactTimestampRegistry registry,
|
||||
PipelineTimestampingPolicyOptions options)
|
||||
{
|
||||
return new CiCdTimestampingService(
|
||||
client,
|
||||
registry,
|
||||
Options.Create(options),
|
||||
new FixedTimeProvider(new DateTimeOffset(2026, 2, 8, 0, 0, 0, TimeSpan.Zero)),
|
||||
NullLogger<CiCdTimestampingService>.Instance);
|
||||
}
|
||||
|
||||
private static TimeStampResponse CreateSuccess(string providerName, byte marker)
|
||||
{
|
||||
return TimeStampResponse.Success(
|
||||
TimestampingTestData.CreateToken(
|
||||
info: TimestampingTestData.CreateTstInfo(
|
||||
messageImprint: new byte[] { 0x01, 0x02 },
|
||||
algorithm: HashAlgorithmName.SHA256,
|
||||
genTime: new DateTimeOffset(2026, 2, 8, 0, 0, marker, TimeSpan.Zero)),
|
||||
encodedToken: new[] { (byte)0x30, marker }),
|
||||
providerName);
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => utcNow;
|
||||
}
|
||||
|
||||
private sealed class FakeTimeStampAuthorityClient(
|
||||
IEnumerable<TimeStampResponse> responses) : ITimeStampAuthorityClient
|
||||
{
|
||||
private readonly Queue<TimeStampResponse> _responses = new(responses);
|
||||
|
||||
public int RequestCount { get; private set; }
|
||||
|
||||
public IReadOnlyList<TsaProviderInfo> Providers => [];
|
||||
|
||||
public Task<TimeStampResponse> GetTimeStampAsync(TimeStampRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequestCount++;
|
||||
return Task.FromResult(_responses.Count > 0
|
||||
? _responses.Dequeue()
|
||||
: TimeStampResponse.Failure(PkiStatus.Rejection, PkiFailureInfo.SystemFailure, "no fake response configured"));
|
||||
}
|
||||
|
||||
public Task<TimeStampVerificationResult> VerifyAsync(
|
||||
TimeStampToken token,
|
||||
ReadOnlyMemory<byte> originalHash,
|
||||
TimeStampVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(TimeStampVerificationResult.Success(
|
||||
verifiedTime: token.TstInfo.GenTime,
|
||||
timeRange: token.TstInfo.GetTimeRange(),
|
||||
policyOid: token.TstInfo.PolicyOid));
|
||||
}
|
||||
|
||||
public TimeStampToken ParseToken(ReadOnlyMemory<byte> encodedToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace StellaOps.Authority.Timestamping.Tests;
|
||||
|
||||
public sealed class InMemoryArtifactTimestampRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpsertAsync_ReplacesExistingRecord_ByDeterministicKey()
|
||||
{
|
||||
var registry = new InMemoryArtifactTimestampRegistry();
|
||||
var baseRecord = CreateRecord(
|
||||
providerName: "tsa-a",
|
||||
tokenDigest: "aa",
|
||||
recordedAt: new DateTimeOffset(2026, 2, 8, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
await registry.UpsertAsync(baseRecord);
|
||||
await registry.UpsertAsync(baseRecord with
|
||||
{
|
||||
EncodedTokenBase64 = "updated-token",
|
||||
RecordedAtUtc = new DateTimeOffset(2026, 2, 8, 0, 1, 0, TimeSpan.Zero),
|
||||
});
|
||||
|
||||
var result = await registry.GetByPipelineAsync("tenant-a", "pipeline-a");
|
||||
var record = Assert.Single(result);
|
||||
Assert.Equal("updated-token", record.EncodedTokenBase64);
|
||||
Assert.Equal(new DateTimeOffset(2026, 2, 8, 0, 1, 0, TimeSpan.Zero), record.RecordedAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByArtifactDigestAsync_ReturnsDeterministicOrdering()
|
||||
{
|
||||
var registry = new InMemoryArtifactTimestampRegistry();
|
||||
|
||||
await registry.UpsertAsync(CreateRecord(
|
||||
providerName: "tsa-b",
|
||||
tokenDigest: "bb",
|
||||
recordedAt: new DateTimeOffset(2026, 2, 8, 0, 0, 1, TimeSpan.Zero)));
|
||||
await registry.UpsertAsync(CreateRecord(
|
||||
providerName: "tsa-a",
|
||||
tokenDigest: "aa",
|
||||
recordedAt: new DateTimeOffset(2026, 2, 8, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
var result = await registry.GetByArtifactDigestAsync("sha256:aaaa");
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("tsa-a", result[0].ProviderName);
|
||||
Assert.Equal("tsa-b", result[1].ProviderName);
|
||||
}
|
||||
|
||||
private static ArtifactTimestampRecord CreateRecord(
|
||||
string providerName,
|
||||
string tokenDigest,
|
||||
DateTimeOffset recordedAt)
|
||||
{
|
||||
return new ArtifactTimestampRecord
|
||||
{
|
||||
TenantId = "tenant-a",
|
||||
PipelineId = "pipeline-a",
|
||||
Environment = "prod",
|
||||
ArtifactType = "sbom",
|
||||
ArtifactDigest = "sha256:aaaa",
|
||||
HashAlgorithm = "SHA256",
|
||||
ProviderName = providerName,
|
||||
TokenDigestSha256 = tokenDigest,
|
||||
EncodedTokenBase64 = "token",
|
||||
TimestampedAtUtc = new DateTimeOffset(2026, 2, 8, 0, 0, 0, TimeSpan.Zero),
|
||||
RecordedAtUtc = recordedAt,
|
||||
CorrelationId = "corr-1",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,5 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | DONE | Added unit tests for Timestamping library remediation gaps. |
|
||||
|
||||
| SPRINT_20260208_025-TESTS | DONE | Deterministic CI/CD timestamping service and artifact registry tests. |
|
||||
|
||||
Reference in New Issue
Block a user