partly or unimplemented features - now implemented

This commit is contained in:
master
2026-02-09 08:53:51 +02:00
parent 1bf6bbf395
commit 4bdc298ec1
674 changed files with 90194 additions and 2271 deletions

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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; } = [];
}

View File

@@ -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. |

View File

@@ -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;

View File

@@ -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();
}
}
}

View File

@@ -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",
};
}
}

View File

@@ -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. |