sprints work.
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimestampBundleExporter.cs
|
||||
// Sprint: SPRINT_20260119_009 Evidence Storage for Timestamps
|
||||
// Task: EVT-004 - Evidence Bundle Extension
|
||||
// Description: Exports timestamp evidence to bundle format.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.EvidenceLocker.Timestamping.Models;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Timestamping.Bundle;
|
||||
|
||||
/// <summary>
|
||||
/// Exports timestamp evidence to the evidence bundle format.
|
||||
/// </summary>
|
||||
public sealed class TimestampBundleExporter
|
||||
{
|
||||
private readonly ILogger<TimestampBundleExporter> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimestampBundleExporter"/> class.
|
||||
/// </summary>
|
||||
public TimestampBundleExporter(ILogger<TimestampBundleExporter> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports timestamp evidence to a bundle directory.
|
||||
/// </summary>
|
||||
/// <param name="bundleDirectory">The root bundle directory.</param>
|
||||
/// <param name="timestamps">Timestamp evidence to export.</param>
|
||||
/// <param name="revocations">Revocation evidence to export.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of exported files with their SHA-256 hashes.</returns>
|
||||
public async Task<IReadOnlyList<BundleFileEntry>> ExportAsync(
|
||||
string bundleDirectory,
|
||||
IReadOnlyList<TimestampEvidence> timestamps,
|
||||
IReadOnlyList<RevocationEvidence> revocations,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entries = new List<BundleFileEntry>();
|
||||
|
||||
// Create subdirectories
|
||||
var timestampsDir = Path.Combine(bundleDirectory, "timestamps");
|
||||
var chainsDir = Path.Combine(timestampsDir, "chains");
|
||||
var revocationDir = Path.Combine(bundleDirectory, "revocation");
|
||||
var ocspDir = Path.Combine(revocationDir, "ocsp");
|
||||
var crlDir = Path.Combine(revocationDir, "crl");
|
||||
|
||||
Directory.CreateDirectory(timestampsDir);
|
||||
Directory.CreateDirectory(chainsDir);
|
||||
Directory.CreateDirectory(revocationDir);
|
||||
Directory.CreateDirectory(ocspDir);
|
||||
Directory.CreateDirectory(crlDir);
|
||||
|
||||
// Export timestamps in deterministic order (sorted by artifact digest, then generation time)
|
||||
var sortedTimestamps = timestamps
|
||||
.OrderBy(t => t.ArtifactDigest, StringComparer.Ordinal)
|
||||
.ThenBy(t => t.GenerationTime)
|
||||
.ToList();
|
||||
|
||||
foreach (var tst in sortedTimestamps)
|
||||
{
|
||||
// Export raw TST
|
||||
var tstFileName = $"{SanitizeFileName(tst.ArtifactDigest)}.tst";
|
||||
var tstPath = Path.Combine(timestampsDir, tstFileName);
|
||||
await File.WriteAllBytesAsync(tstPath, tst.TimeStampToken, cancellationToken);
|
||||
entries.Add(await CreateEntryAsync($"timestamps/{tstFileName}", tstPath, cancellationToken));
|
||||
|
||||
// Export metadata
|
||||
var metaFileName = $"{SanitizeFileName(tst.ArtifactDigest)}.tst.meta.json";
|
||||
var metaPath = Path.Combine(timestampsDir, metaFileName);
|
||||
var metadata = new TimestampMetadata
|
||||
{
|
||||
ArtifactDigest = tst.ArtifactDigest,
|
||||
DigestAlgorithm = tst.DigestAlgorithm,
|
||||
GenerationTime = tst.GenerationTime,
|
||||
TsaName = tst.TsaName,
|
||||
PolicyOid = tst.TsaPolicyOid,
|
||||
SerialNumber = tst.SerialNumber,
|
||||
ProviderName = tst.ProviderName,
|
||||
CapturedAt = tst.CapturedAt
|
||||
};
|
||||
var metaJson = JsonSerializer.Serialize(metadata, JsonOptions);
|
||||
await File.WriteAllTextAsync(metaPath, metaJson, Encoding.UTF8, cancellationToken);
|
||||
entries.Add(await CreateEntryAsync($"timestamps/{metaFileName}", metaPath, cancellationToken));
|
||||
|
||||
// Export TSA chain
|
||||
var chainFileName = $"{SanitizeFileName(tst.TsaName)}.pem";
|
||||
var chainPath = Path.Combine(chainsDir, chainFileName);
|
||||
if (!File.Exists(chainPath))
|
||||
{
|
||||
await File.WriteAllTextAsync(chainPath, tst.TsaCertificateChainPem, Encoding.UTF8, cancellationToken);
|
||||
entries.Add(await CreateEntryAsync($"timestamps/chains/{chainFileName}", chainPath, cancellationToken));
|
||||
}
|
||||
|
||||
// Export stapled OCSP if present
|
||||
if (tst.OcspResponse is { Length: > 0 })
|
||||
{
|
||||
var ocspFileName = $"{SanitizeFileName(tst.ArtifactDigest)}.ocsp";
|
||||
var ocspPath = Path.Combine(ocspDir, ocspFileName);
|
||||
await File.WriteAllBytesAsync(ocspPath, tst.OcspResponse, cancellationToken);
|
||||
entries.Add(await CreateEntryAsync($"revocation/ocsp/{ocspFileName}", ocspPath, cancellationToken));
|
||||
}
|
||||
|
||||
// Export CRL snapshot if present
|
||||
if (tst.CrlSnapshot is { Length: > 0 })
|
||||
{
|
||||
var crlFileName = $"{SanitizeFileName(tst.ArtifactDigest)}.crl";
|
||||
var crlPath = Path.Combine(crlDir, crlFileName);
|
||||
await File.WriteAllBytesAsync(crlPath, tst.CrlSnapshot, cancellationToken);
|
||||
entries.Add(await CreateEntryAsync($"revocation/crl/{crlFileName}", crlPath, cancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
// Export revocation evidence in deterministic order
|
||||
var sortedRevocations = revocations
|
||||
.OrderBy(r => r.CertificateFingerprint, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.ResponseTime)
|
||||
.ToList();
|
||||
|
||||
foreach (var rev in sortedRevocations)
|
||||
{
|
||||
var subDir = rev.Source == RevocationSource.Ocsp ? ocspDir : crlDir;
|
||||
var subPath = rev.Source == RevocationSource.Ocsp ? "revocation/ocsp" : "revocation/crl";
|
||||
var ext = rev.Source == RevocationSource.Ocsp ? ".ocsp" : ".crl";
|
||||
var fileName = $"{SanitizeFileName(rev.CertificateFingerprint)}{ext}";
|
||||
var filePath = Path.Combine(subDir, fileName);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
await File.WriteAllBytesAsync(filePath, rev.RawResponse, cancellationToken);
|
||||
entries.Add(await CreateEntryAsync($"{subPath}/{fileName}", filePath, cancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exported {TstCount} timestamps and {RevCount} revocation records to bundle",
|
||||
timestamps.Count,
|
||||
revocations.Count);
|
||||
|
||||
// Return entries sorted for deterministic manifest
|
||||
return entries.OrderBy(e => e.Path, StringComparer.Ordinal).ToImmutableList();
|
||||
}
|
||||
|
||||
private static async Task<BundleFileEntry> CreateEntryAsync(
|
||||
string relativePath,
|
||||
string absolutePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(absolutePath, cancellationToken);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return new BundleFileEntry
|
||||
{
|
||||
Path = relativePath,
|
||||
Sha256 = Convert.ToHexString(hash).ToLowerInvariant(),
|
||||
Size = bytes.Length
|
||||
};
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string input)
|
||||
{
|
||||
// Replace invalid chars and colons
|
||||
var sanitized = input
|
||||
.Replace(":", "_")
|
||||
.Replace("/", "_")
|
||||
.Replace("\\", "_")
|
||||
.Replace(" ", "_");
|
||||
|
||||
// Truncate if too long
|
||||
if (sanitized.Length > 64)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
sanitized = Convert.ToHexString(hash)[..16].ToLowerInvariant();
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for a timestamp in the bundle.
|
||||
/// </summary>
|
||||
public sealed record TimestampMetadata
|
||||
{
|
||||
/// <summary>Gets the artifact digest.</summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>Gets the digest algorithm.</summary>
|
||||
public required string DigestAlgorithm { get; init; }
|
||||
|
||||
/// <summary>Gets the generation time.</summary>
|
||||
public required DateTimeOffset GenerationTime { get; init; }
|
||||
|
||||
/// <summary>Gets the TSA name.</summary>
|
||||
public required string TsaName { get; init; }
|
||||
|
||||
/// <summary>Gets the policy OID.</summary>
|
||||
public required string PolicyOid { get; init; }
|
||||
|
||||
/// <summary>Gets the serial number.</summary>
|
||||
public required string SerialNumber { get; init; }
|
||||
|
||||
/// <summary>Gets the provider name.</summary>
|
||||
public required string ProviderName { get; init; }
|
||||
|
||||
/// <summary>Gets when evidence was captured.</summary>
|
||||
public required DateTimeOffset CapturedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry in the bundle file manifest.
|
||||
/// </summary>
|
||||
public sealed record BundleFileEntry
|
||||
{
|
||||
/// <summary>Gets the relative path in the bundle.</summary>
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>Gets the SHA-256 hash (lowercase hex).</summary>
|
||||
public required string Sha256 { get; init; }
|
||||
|
||||
/// <summary>Gets the file size in bytes.</summary>
|
||||
public required long Size { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimestampBundleImporter.cs
|
||||
// Sprint: SPRINT_20260119_009 Evidence Storage for Timestamps
|
||||
// Task: EVT-004 - Evidence Bundle Extension
|
||||
// Description: Imports timestamp evidence from bundle format.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.EvidenceLocker.Timestamping.Models;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Timestamping.Bundle;
|
||||
|
||||
/// <summary>
|
||||
/// Imports timestamp evidence from the evidence bundle format.
|
||||
/// </summary>
|
||||
public sealed class TimestampBundleImporter
|
||||
{
|
||||
private readonly ILogger<TimestampBundleImporter> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimestampBundleImporter"/> class.
|
||||
/// </summary>
|
||||
public TimestampBundleImporter(ILogger<TimestampBundleImporter> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports timestamp evidence from a bundle directory.
|
||||
/// </summary>
|
||||
/// <param name="bundleDirectory">The root bundle directory.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Imported timestamp and revocation evidence.</returns>
|
||||
public async Task<BundleImportResult> ImportAsync(
|
||||
string bundleDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var timestamps = new List<TimestampEvidence>();
|
||||
var revocations = new List<RevocationEvidence>();
|
||||
var errors = new List<string>();
|
||||
|
||||
var timestampsDir = Path.Combine(bundleDirectory, "timestamps");
|
||||
var chainsDir = Path.Combine(timestampsDir, "chains");
|
||||
var ocspDir = Path.Combine(bundleDirectory, "revocation", "ocsp");
|
||||
var crlDir = Path.Combine(bundleDirectory, "revocation", "crl");
|
||||
|
||||
// Load chain files into dictionary for lookup
|
||||
var chains = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (Directory.Exists(chainsDir))
|
||||
{
|
||||
foreach (var chainFile in Directory.GetFiles(chainsDir, "*.pem"))
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(chainFile);
|
||||
chains[name] = await File.ReadAllTextAsync(chainFile, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Load OCSP responses for lookup
|
||||
var ocspResponses = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
|
||||
if (Directory.Exists(ocspDir))
|
||||
{
|
||||
foreach (var ocspFile in Directory.GetFiles(ocspDir, "*.ocsp"))
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(ocspFile);
|
||||
ocspResponses[name] = await File.ReadAllBytesAsync(ocspFile, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Load CRL snapshots for lookup
|
||||
var crlSnapshots = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
|
||||
if (Directory.Exists(crlDir))
|
||||
{
|
||||
foreach (var crlFile in Directory.GetFiles(crlDir, "*.crl"))
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(crlFile);
|
||||
crlSnapshots[name] = await File.ReadAllBytesAsync(crlFile, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Load timestamp evidence
|
||||
if (Directory.Exists(timestampsDir))
|
||||
{
|
||||
var tstFiles = Directory.GetFiles(timestampsDir, "*.tst")
|
||||
.Where(f => !f.EndsWith(".meta.json", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(f => f, StringComparer.Ordinal);
|
||||
|
||||
foreach (var tstFile in tstFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var metaFile = tstFile + ".meta.json";
|
||||
if (!File.Exists(metaFile))
|
||||
{
|
||||
errors.Add($"Missing metadata file for {Path.GetFileName(tstFile)}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var tstBytes = await File.ReadAllBytesAsync(tstFile, cancellationToken);
|
||||
var metaJson = await File.ReadAllTextAsync(metaFile, cancellationToken);
|
||||
var metadata = JsonSerializer.Deserialize<TimestampMetadata>(metaJson, JsonOptions);
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
errors.Add($"Failed to parse metadata for {Path.GetFileName(tstFile)}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find chain
|
||||
var chainKey = SanitizeFileName(metadata.TsaName);
|
||||
if (!chains.TryGetValue(chainKey, out var chainPem))
|
||||
{
|
||||
errors.Add($"Missing chain file for TSA '{metadata.TsaName}'");
|
||||
chainPem = string.Empty;
|
||||
}
|
||||
|
||||
// Find stapled OCSP
|
||||
var artifactKey = SanitizeFileName(metadata.ArtifactDigest);
|
||||
ocspResponses.TryGetValue(artifactKey, out var ocsp);
|
||||
crlSnapshots.TryGetValue(artifactKey, out var crl);
|
||||
|
||||
var evidence = new TimestampEvidence
|
||||
{
|
||||
ArtifactDigest = metadata.ArtifactDigest,
|
||||
DigestAlgorithm = metadata.DigestAlgorithm,
|
||||
TimeStampToken = tstBytes,
|
||||
GenerationTime = metadata.GenerationTime,
|
||||
TsaName = metadata.TsaName,
|
||||
TsaPolicyOid = metadata.PolicyOid,
|
||||
SerialNumber = metadata.SerialNumber,
|
||||
TsaCertificateChainPem = chainPem,
|
||||
OcspResponse = ocsp,
|
||||
CrlSnapshot = crl,
|
||||
CapturedAt = metadata.CapturedAt,
|
||||
ProviderName = metadata.ProviderName
|
||||
};
|
||||
|
||||
timestamps.Add(evidence);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Error importing {Path.GetFileName(tstFile)}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Imported {TstCount} timestamps with {ErrorCount} errors",
|
||||
timestamps.Count,
|
||||
errors.Count);
|
||||
|
||||
return new BundleImportResult
|
||||
{
|
||||
Timestamps = timestamps,
|
||||
Revocations = revocations,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string input)
|
||||
{
|
||||
return input
|
||||
.Replace(":", "_")
|
||||
.Replace("/", "_")
|
||||
.Replace("\\", "_")
|
||||
.Replace(" ", "_");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of importing a bundle.
|
||||
/// </summary>
|
||||
public sealed record BundleImportResult
|
||||
{
|
||||
/// <summary>Gets the imported timestamp evidence.</summary>
|
||||
public required IReadOnlyList<TimestampEvidence> Timestamps { get; init; }
|
||||
|
||||
/// <summary>Gets the imported revocation evidence.</summary>
|
||||
public required IReadOnlyList<RevocationEvidence> Revocations { get; init; }
|
||||
|
||||
/// <summary>Gets any errors encountered during import.</summary>
|
||||
public required IReadOnlyList<string> Errors { get; init; }
|
||||
|
||||
/// <summary>Gets whether the import was successful (no errors).</summary>
|
||||
public bool Success => Errors.Count == 0;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IRetimestampService.cs
|
||||
// Sprint: SPRINT_20260119_009 Evidence Storage for Timestamps
|
||||
// Task: EVT-005 - Re-Timestamping Support
|
||||
// Description: Service for re-timestamping evidence before expiry.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.EvidenceLocker.Timestamping.Models;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Timestamping;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing re-timestamping of evidence before TSA certificate expiry.
|
||||
/// </summary>
|
||||
public interface IRetimestampService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets timestamp evidence that is approaching expiry.
|
||||
/// </summary>
|
||||
/// <param name="window">The time window before expiry to consider.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of timestamps approaching expiry.</returns>
|
||||
Task<IReadOnlyList<TimestampEvidence>> GetExpiringAsync(
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Re-timestamps a single piece of evidence.
|
||||
/// </summary>
|
||||
/// <param name="originalId">The ID of the original timestamp evidence.</param>
|
||||
/// <param name="options">Optional re-timestamp options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The new timestamp evidence that supersedes the original.</returns>
|
||||
Task<TimestampEvidence> RetimestampAsync(
|
||||
Guid originalId,
|
||||
RetimestampOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Re-timestamps all evidence expiring within the given window.
|
||||
/// </summary>
|
||||
/// <param name="expiryWindow">Time window before expiry.</param>
|
||||
/// <param name="options">Optional batch options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of timestamps processed.</returns>
|
||||
Task<RetimestampBatchResult> RetimestampBatchAsync(
|
||||
TimeSpan expiryWindow,
|
||||
RetimestampBatchOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the supersession chain for a timestamp.
|
||||
/// </summary>
|
||||
/// <param name="timestampId">The timestamp ID to trace.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Chain of timestamps from original to current.</returns>
|
||||
Task<IReadOnlyList<TimestampEvidence>> GetSupersessionChainAsync(
|
||||
Guid timestampId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for re-timestamping.
|
||||
/// </summary>
|
||||
public sealed record RetimestampOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the TSA provider to use (null = use default).
|
||||
/// </summary>
|
||||
public string? TsaProvider { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to include the original TST in the re-timestamp hash.
|
||||
/// Default is true for chain integrity.
|
||||
/// </summary>
|
||||
public bool IncludeOriginalTst { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to fetch fresh revocation data.
|
||||
/// </summary>
|
||||
public bool RefreshRevocation { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for batch re-timestamping.
|
||||
/// </summary>
|
||||
public sealed record RetimestampBatchOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of timestamps to process.
|
||||
/// </summary>
|
||||
public int MaxCount { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the parallelism level.
|
||||
/// </summary>
|
||||
public int MaxParallelism { get; init; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the TSA provider to use (null = use default).
|
||||
/// </summary>
|
||||
public string? TsaProvider { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to continue on individual failures.
|
||||
/// </summary>
|
||||
public bool ContinueOnError { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of batch re-timestamping.
|
||||
/// </summary>
|
||||
public sealed record RetimestampBatchResult
|
||||
{
|
||||
/// <summary>Gets the number of timestamps processed successfully.</summary>
|
||||
public required int SuccessCount { get; init; }
|
||||
|
||||
/// <summary>Gets the number of timestamps that failed.</summary>
|
||||
public required int FailureCount { get; init; }
|
||||
|
||||
/// <summary>Gets the number of timestamps skipped (already current).</summary>
|
||||
public required int SkippedCount { get; init; }
|
||||
|
||||
/// <summary>Gets details of any failures.</summary>
|
||||
public required IReadOnlyList<RetimestampFailure> Failures { get; init; }
|
||||
|
||||
/// <summary>Gets the total number processed.</summary>
|
||||
public int TotalProcessed => SuccessCount + FailureCount + SkippedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details of a re-timestamp failure.
|
||||
/// </summary>
|
||||
public sealed record RetimestampFailure
|
||||
{
|
||||
/// <summary>Gets the original timestamp ID.</summary>
|
||||
public required Guid OriginalId { get; init; }
|
||||
|
||||
/// <summary>Gets the artifact digest.</summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>Gets the error message.</summary>
|
||||
public required string Error { get; init; }
|
||||
|
||||
/// <summary>Gets when the failure occurred.</summary>
|
||||
public required DateTimeOffset FailedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ITimestampEvidenceRepository.cs
|
||||
// Sprint: SPRINT_20260119_009 Evidence Storage for Timestamps
|
||||
// Task: EVT-003 - Repository Implementation
|
||||
// Description: Repository interface for timestamp evidence storage.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.EvidenceLocker.Timestamping.Models;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Timestamping;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for storing and retrieving timestamp evidence.
|
||||
/// </summary>
|
||||
public interface ITimestampEvidenceRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores timestamp evidence.
|
||||
/// </summary>
|
||||
/// <param name="evidence">The evidence to store.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The generated ID.</returns>
|
||||
Task<Guid> StoreAsync(TimestampEvidence evidence, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets timestamp evidence by artifact digest.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">The artifact digest.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The evidence if found.</returns>
|
||||
Task<TimestampEvidence?> GetByArtifactAsync(string artifactDigest, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all timestamp evidence for an artifact.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">The artifact digest.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>All evidence for the artifact.</returns>
|
||||
Task<IReadOnlyList<TimestampEvidence>> GetAllByArtifactAsync(string artifactDigest, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest timestamp evidence for an artifact.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">The artifact digest.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The latest evidence if found.</returns>
|
||||
Task<TimestampEvidence?> GetLatestByArtifactAsync(string artifactDigest, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets timestamp evidence by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The evidence ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The evidence if found.</returns>
|
||||
Task<TimestampEvidence?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets timestamps within a time range.
|
||||
/// </summary>
|
||||
/// <param name="from">Start of range.</param>
|
||||
/// <param name="to">End of range.</param>
|
||||
/// <param name="limit">Maximum results.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Timestamps within the range.</returns>
|
||||
Task<IReadOnlyList<TimestampEvidence>> GetByTimeRangeAsync(
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets timestamps that will expire before the given date (for re-timestamping).
|
||||
/// </summary>
|
||||
/// <param name="expiryDate">The expiry threshold date.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Timestamps expiring before the date.</returns>
|
||||
Task<IReadOnlyList<TimestampEvidence>> GetExpiringBeforeAsync(
|
||||
DateTimeOffset expiryDate,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository for storing and retrieving revocation evidence.
|
||||
/// </summary>
|
||||
public interface IRevocationEvidenceRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores revocation evidence.
|
||||
/// </summary>
|
||||
/// <param name="evidence">The evidence to store.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The generated ID.</returns>
|
||||
Task<Guid> StoreAsync(RevocationEvidence evidence, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets revocation evidence by certificate fingerprint.
|
||||
/// </summary>
|
||||
/// <param name="fingerprint">The certificate fingerprint.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The latest evidence if found.</returns>
|
||||
Task<RevocationEvidence?> GetByCertificateAsync(string fingerprint, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all revocation evidence for a certificate.
|
||||
/// </summary>
|
||||
/// <param name="fingerprint">The certificate fingerprint.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>All evidence for the certificate.</returns>
|
||||
Task<IReadOnlyList<RevocationEvidence>> GetAllByCertificateAsync(string fingerprint, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets revocation evidence that expires within a time window.
|
||||
/// </summary>
|
||||
/// <param name="window">Time window from now.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Evidence expiring soon.</returns>
|
||||
Task<IReadOnlyList<RevocationEvidence>> GetExpiringSoonAsync(TimeSpan window, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets revocation evidence by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The evidence ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The evidence if found.</returns>
|
||||
Task<RevocationEvidence?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RevocationEvidence.cs
|
||||
// Sprint: SPRINT_20260119_009 Evidence Storage for Timestamps
|
||||
// Task: EVT-001 - Timestamp Evidence Models
|
||||
// Description: Data model for storing certificate revocation evidence.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Timestamping.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence record for certificate revocation status at a point in time.
|
||||
/// </summary>
|
||||
public sealed record RevocationEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique identifier.
|
||||
/// </summary>
|
||||
public Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the certificate fingerprint (SHA-256 of DER).
|
||||
/// </summary>
|
||||
public required string CertificateFingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the source of the revocation information.
|
||||
/// </summary>
|
||||
public required RevocationSource Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw response (OCSP response or CRL).
|
||||
/// </summary>
|
||||
public required byte[] RawResponse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the thisUpdate time from the response.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ResponseTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the nextUpdate time from the response.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ValidUntil { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the revocation status at the time of capture.
|
||||
/// </summary>
|
||||
public required RevocationStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the revocation time if the certificate was revoked.
|
||||
/// </summary>
|
||||
public DateTimeOffset? RevocationTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the revocation reason if the certificate was revoked.
|
||||
/// </summary>
|
||||
public RevocationReason? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when this record was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this evidence is still fresh.
|
||||
/// </summary>
|
||||
public bool IsFresh => ValidUntil > DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the evidence record.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(CertificateFingerprint))
|
||||
throw new ArgumentException("CertificateFingerprint is required");
|
||||
if (RawResponse is not { Length: > 0 })
|
||||
throw new ArgumentException("RawResponse is required");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source of revocation information.
|
||||
/// </summary>
|
||||
public enum RevocationSource
|
||||
{
|
||||
/// <summary>
|
||||
/// No source.
|
||||
/// </summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// OCSP response.
|
||||
/// </summary>
|
||||
Ocsp,
|
||||
|
||||
/// <summary>
|
||||
/// Certificate Revocation List.
|
||||
/// </summary>
|
||||
Crl
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Certificate revocation status.
|
||||
/// </summary>
|
||||
public enum RevocationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Certificate is good.
|
||||
/// </summary>
|
||||
Good,
|
||||
|
||||
/// <summary>
|
||||
/// Certificate is revoked.
|
||||
/// </summary>
|
||||
Revoked,
|
||||
|
||||
/// <summary>
|
||||
/// Status is unknown.
|
||||
/// </summary>
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Certificate revocation reason (RFC 5280).
|
||||
/// </summary>
|
||||
public enum RevocationReason
|
||||
{
|
||||
/// <summary>
|
||||
/// Unspecified.
|
||||
/// </summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Key compromise.
|
||||
/// </summary>
|
||||
KeyCompromise = 1,
|
||||
|
||||
/// <summary>
|
||||
/// CA compromise.
|
||||
/// </summary>
|
||||
CaCompromise = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Affiliation changed.
|
||||
/// </summary>
|
||||
AffiliationChanged = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Superseded.
|
||||
/// </summary>
|
||||
Superseded = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Cessation of operation.
|
||||
/// </summary>
|
||||
CessationOfOperation = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Certificate hold.
|
||||
/// </summary>
|
||||
CertificateHold = 6,
|
||||
|
||||
/// <summary>
|
||||
/// Privilege withdrawn.
|
||||
/// </summary>
|
||||
PrivilegeWithdrawn = 9,
|
||||
|
||||
/// <summary>
|
||||
/// AA compromise.
|
||||
/// </summary>
|
||||
AaCompromise = 10
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimestampEvidence.cs
|
||||
// Sprint: SPRINT_20260119_009 Evidence Storage for Timestamps
|
||||
// Task: EVT-001 - Timestamp Evidence Models
|
||||
// Description: Data model for storing RFC-3161 timestamp evidence.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Timestamping.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence record for an RFC-3161 timestamp token.
|
||||
/// </summary>
|
||||
public sealed record TimestampEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique identifier.
|
||||
/// </summary>
|
||||
public Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SHA-256 digest of the timestamped artifact.
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the digest algorithm used.
|
||||
/// </summary>
|
||||
public required string DigestAlgorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw RFC 3161 TimeStampToken (DER encoded).
|
||||
/// </summary>
|
||||
public required byte[] TimeStampToken { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the generation time from the TSTInfo.
|
||||
/// </summary>
|
||||
public required DateTimeOffset GenerationTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the TSA name from the TSTInfo.
|
||||
/// </summary>
|
||||
public required string TsaName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the TSA policy OID.
|
||||
/// </summary>
|
||||
public required string TsaPolicyOid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the TST serial number as a string (to handle large integers).
|
||||
/// </summary>
|
||||
public required string SerialNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the PEM-encoded TSA certificate chain.
|
||||
/// </summary>
|
||||
public required string TsaCertificateChainPem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the stapled OCSP response (if available).
|
||||
/// </summary>
|
||||
public byte[]? OcspResponse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CRL snapshot (if OCSP is not available).
|
||||
/// </summary>
|
||||
public byte[]? CrlSnapshot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when this evidence was captured.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CapturedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the TSA provider name that was used.
|
||||
/// </summary>
|
||||
public required string ProviderName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when this record was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID of the timestamp this supersedes (for re-timestamping chain).
|
||||
/// </summary>
|
||||
public Guid? SupersedesId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether revocation evidence is included.
|
||||
/// </summary>
|
||||
public bool HasRevocationEvidence => OcspResponse is { Length: > 0 } || CrlSnapshot is { Length: > 0 };
|
||||
|
||||
/// <summary>
|
||||
/// Validates the evidence record.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ArtifactDigest))
|
||||
throw new ArgumentException("ArtifactDigest is required");
|
||||
if (string.IsNullOrWhiteSpace(DigestAlgorithm))
|
||||
throw new ArgumentException("DigestAlgorithm is required");
|
||||
if (TimeStampToken is not { Length: > 0 })
|
||||
throw new ArgumentException("TimeStampToken is required");
|
||||
if (string.IsNullOrWhiteSpace(TsaName))
|
||||
throw new ArgumentException("TsaName is required");
|
||||
if (string.IsNullOrWhiteSpace(TsaPolicyOid))
|
||||
throw new ArgumentException("TsaPolicyOid is required");
|
||||
if (string.IsNullOrWhiteSpace(SerialNumber))
|
||||
throw new ArgumentException("SerialNumber is required");
|
||||
if (string.IsNullOrWhiteSpace(TsaCertificateChainPem))
|
||||
throw new ArgumentException("TsaCertificateChainPem is required");
|
||||
if (string.IsNullOrWhiteSpace(ProviderName))
|
||||
throw new ArgumentException("ProviderName is required");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RetimestampService.cs
|
||||
// Sprint: SPRINT_20260119_009 Evidence Storage for Timestamps
|
||||
// Task: EVT-005 - Re-Timestamping Support
|
||||
// Description: Implementation of re-timestamping service.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.EvidenceLocker.Timestamping.Models;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Timestamping;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing re-timestamping of evidence before TSA certificate expiry.
|
||||
/// </summary>
|
||||
public sealed class RetimestampService : IRetimestampService
|
||||
{
|
||||
private readonly ITimestampEvidenceRepository _repository;
|
||||
private readonly ITimestampingService _timestampingService;
|
||||
private readonly ILogger<RetimestampService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RetimestampService"/> class.
|
||||
/// </summary>
|
||||
public RetimestampService(
|
||||
ITimestampEvidenceRepository repository,
|
||||
ITimestampingService timestampingService,
|
||||
ILogger<RetimestampService> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_timestampingService = timestampingService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TimestampEvidence>> GetExpiringAsync(
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogDebug("Querying for timestamps expiring within {Window}", window);
|
||||
|
||||
var expiryDate = DateTimeOffset.UtcNow.Add(window);
|
||||
return await _repository.GetExpiringBeforeAsync(expiryDate, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TimestampEvidence> RetimestampAsync(
|
||||
Guid originalId,
|
||||
RetimestampOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= new RetimestampOptions();
|
||||
|
||||
var original = await _repository.GetByIdAsync(originalId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Timestamp evidence {originalId} not found");
|
||||
|
||||
_logger.LogInformation(
|
||||
"Re-timestamping {ArtifactDigest} (original ID: {OriginalId})",
|
||||
original.ArtifactDigest,
|
||||
originalId);
|
||||
|
||||
// Compute hash of original artifact + original TST for chain integrity
|
||||
byte[] dataToTimestamp;
|
||||
if (options.IncludeOriginalTst)
|
||||
{
|
||||
// Hash(artifact_digest || original_tst)
|
||||
using var sha = SHA256.Create();
|
||||
var artifactBytes = System.Text.Encoding.UTF8.GetBytes(original.ArtifactDigest);
|
||||
sha.TransformBlock(artifactBytes, 0, artifactBytes.Length, null, 0);
|
||||
sha.TransformFinalBlock(original.TimeStampToken, 0, original.TimeStampToken.Length);
|
||||
dataToTimestamp = sha.Hash!;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Just re-timestamp the original artifact digest
|
||||
dataToTimestamp = Convert.FromHexString(
|
||||
original.ArtifactDigest.Replace("sha256:", "", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// Request new timestamp
|
||||
var timestampResult = await _timestampingService.TimestampAsync(
|
||||
dataToTimestamp,
|
||||
new TimestampingOptions { ProviderName = options.TsaProvider },
|
||||
cancellationToken);
|
||||
|
||||
// Create new evidence record
|
||||
var newEvidence = new TimestampEvidence
|
||||
{
|
||||
ArtifactDigest = original.ArtifactDigest,
|
||||
DigestAlgorithm = "SHA256",
|
||||
TimeStampToken = timestampResult.Token,
|
||||
GenerationTime = timestampResult.GenerationTime,
|
||||
TsaName = timestampResult.TsaName,
|
||||
TsaPolicyOid = timestampResult.PolicyOid,
|
||||
SerialNumber = timestampResult.SerialNumber,
|
||||
TsaCertificateChainPem = timestampResult.CertificateChainPem,
|
||||
OcspResponse = options.RefreshRevocation ? timestampResult.OcspResponse : original.OcspResponse,
|
||||
CrlSnapshot = options.RefreshRevocation ? timestampResult.CrlSnapshot : original.CrlSnapshot,
|
||||
CapturedAt = DateTimeOffset.UtcNow,
|
||||
ProviderName = timestampResult.ProviderName,
|
||||
SupersedesId = originalId
|
||||
};
|
||||
|
||||
// Store the new evidence
|
||||
var newId = await _repository.StoreAsync(newEvidence, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created new timestamp {NewId} superseding {OriginalId}",
|
||||
newId,
|
||||
originalId);
|
||||
|
||||
// Log audit record
|
||||
_logger.LogAudit(
|
||||
"RETIMESTAMP",
|
||||
new
|
||||
{
|
||||
OriginalId = originalId,
|
||||
NewId = newId,
|
||||
ArtifactDigest = original.ArtifactDigest,
|
||||
OriginalGenTime = original.GenerationTime,
|
||||
NewGenTime = newEvidence.GenerationTime,
|
||||
TsaProvider = newEvidence.ProviderName
|
||||
});
|
||||
|
||||
return newEvidence with { Id = newId };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RetimestampBatchResult> RetimestampBatchAsync(
|
||||
TimeSpan expiryWindow,
|
||||
RetimestampBatchOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= new RetimestampBatchOptions();
|
||||
|
||||
var expiring = await GetExpiringAsync(expiryWindow, cancellationToken);
|
||||
var toProcess = expiring.Take(options.MaxCount).ToList();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting batch re-timestamp of {Count} expiring timestamps (window: {Window})",
|
||||
toProcess.Count,
|
||||
expiryWindow);
|
||||
|
||||
var successCount = 0;
|
||||
var failureCount = 0;
|
||||
var skippedCount = 0;
|
||||
var failures = new List<RetimestampFailure>();
|
||||
|
||||
var retimestampOptions = new RetimestampOptions
|
||||
{
|
||||
TsaProvider = options.TsaProvider,
|
||||
IncludeOriginalTst = true,
|
||||
RefreshRevocation = true
|
||||
};
|
||||
|
||||
// Process with semaphore for parallelism control
|
||||
using var semaphore = new SemaphoreSlim(options.MaxParallelism);
|
||||
var tasks = toProcess.Select(async tst =>
|
||||
{
|
||||
await semaphore.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await RetimestampAsync(tst.Id, retimestampOptions, cancellationToken);
|
||||
Interlocked.Increment(ref successCount);
|
||||
}
|
||||
catch (Exception ex) when (options.ContinueOnError)
|
||||
{
|
||||
Interlocked.Increment(ref failureCount);
|
||||
lock (failures)
|
||||
{
|
||||
failures.Add(new RetimestampFailure
|
||||
{
|
||||
OriginalId = tst.Id,
|
||||
ArtifactDigest = tst.ArtifactDigest,
|
||||
Error = ex.Message,
|
||||
FailedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
_logger.LogWarning(ex, "Failed to re-timestamp {Id}", tst.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Batch re-timestamp complete: {Success} success, {Failed} failed, {Skipped} skipped",
|
||||
successCount,
|
||||
failureCount,
|
||||
skippedCount);
|
||||
|
||||
return new RetimestampBatchResult
|
||||
{
|
||||
SuccessCount = successCount,
|
||||
FailureCount = failureCount,
|
||||
SkippedCount = skippedCount,
|
||||
Failures = failures.ToImmutableList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TimestampEvidence>> GetSupersessionChainAsync(
|
||||
Guid timestampId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var chain = new List<TimestampEvidence>();
|
||||
var visited = new HashSet<Guid>();
|
||||
|
||||
var currentId = timestampId;
|
||||
while (true)
|
||||
{
|
||||
if (!visited.Add(currentId))
|
||||
{
|
||||
_logger.LogWarning("Circular reference detected in supersession chain at {Id}", currentId);
|
||||
break;
|
||||
}
|
||||
|
||||
var current = await _repository.GetByIdAsync(currentId, cancellationToken);
|
||||
if (current is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
chain.Add(current);
|
||||
|
||||
if (current.SupersedesId is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
currentId = current.SupersedesId.Value;
|
||||
}
|
||||
|
||||
// Reverse so chain goes from oldest to newest
|
||||
chain.Reverse();
|
||||
return chain.ToImmutableList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timestamping service interface (bridge to Authority.Timestamping).
|
||||
/// </summary>
|
||||
public interface ITimestampingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Requests a timestamp for the given data.
|
||||
/// </summary>
|
||||
Task<TimestampResult> TimestampAsync(
|
||||
byte[] dataHash,
|
||||
TimestampingOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for timestamping.
|
||||
/// </summary>
|
||||
public sealed record TimestampingOptions
|
||||
{
|
||||
/// <summary>Gets or sets the TSA provider name.</summary>
|
||||
public string? ProviderName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a timestamp request.
|
||||
/// </summary>
|
||||
public sealed record TimestampResult
|
||||
{
|
||||
/// <summary>Gets the raw timestamp token.</summary>
|
||||
public required byte[] Token { get; init; }
|
||||
|
||||
/// <summary>Gets the generation time.</summary>
|
||||
public required DateTimeOffset GenerationTime { get; init; }
|
||||
|
||||
/// <summary>Gets the TSA name.</summary>
|
||||
public required string TsaName { get; init; }
|
||||
|
||||
/// <summary>Gets the policy OID.</summary>
|
||||
public required string PolicyOid { get; init; }
|
||||
|
||||
/// <summary>Gets the serial number.</summary>
|
||||
public required string SerialNumber { get; init; }
|
||||
|
||||
/// <summary>Gets the certificate chain PEM.</summary>
|
||||
public required string CertificateChainPem { get; init; }
|
||||
|
||||
/// <summary>Gets the provider name.</summary>
|
||||
public required string ProviderName { get; init; }
|
||||
|
||||
/// <summary>Gets the stapled OCSP response.</summary>
|
||||
public byte[]? OcspResponse { get; init; }
|
||||
|
||||
/// <summary>Gets the CRL snapshot.</summary>
|
||||
public byte[]? CrlSnapshot { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logger extension for audit logging.
|
||||
/// </summary>
|
||||
internal static class LoggerExtensions
|
||||
{
|
||||
public static void LogAudit<T>(this ILogger logger, string action, T details)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[AUDIT] {Action}: {@Details}",
|
||||
action,
|
||||
details);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.EvidenceLocker.Timestamping</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,381 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimestampEvidenceRepository.cs
|
||||
// Sprint: SPRINT_20260119_009 Evidence Storage for Timestamps
|
||||
// Task: EVT-003 - Repository Implementation
|
||||
// Description: PostgreSQL repository implementation for timestamp evidence.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Data;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.EvidenceLocker.Timestamping.Models;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Timestamping;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="ITimestampEvidenceRepository"/>.
|
||||
/// </summary>
|
||||
public sealed class TimestampEvidenceRepository : ITimestampEvidenceRepository
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly ILogger<TimestampEvidenceRepository> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimestampEvidenceRepository"/> class.
|
||||
/// </summary>
|
||||
public TimestampEvidenceRepository(
|
||||
string connectionString,
|
||||
ILogger<TimestampEvidenceRepository> logger)
|
||||
{
|
||||
_connectionString = connectionString;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private NpgsqlConnection CreateConnection() => new(_connectionString);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Guid> StoreAsync(TimestampEvidence evidence, CancellationToken cancellationToken = default)
|
||||
{
|
||||
evidence.Validate();
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO evidence.timestamp_tokens (
|
||||
id, artifact_digest, digest_algorithm, tst_blob, generation_time,
|
||||
tsa_name, tsa_policy_oid, serial_number, tsa_chain_pem,
|
||||
ocsp_response, crl_snapshot, captured_at, provider_name, created_at
|
||||
) VALUES (
|
||||
@Id, @ArtifactDigest, @DigestAlgorithm, @TimeStampToken, @GenerationTime,
|
||||
@TsaName, @TsaPolicyOid, @SerialNumber, @TsaCertificateChainPem,
|
||||
@OcspResponse, @CrlSnapshot, @CapturedAt, @ProviderName, @CreatedAt
|
||||
)
|
||||
RETURNING id
|
||||
""";
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
var parameters = new
|
||||
{
|
||||
Id = id,
|
||||
evidence.ArtifactDigest,
|
||||
evidence.DigestAlgorithm,
|
||||
evidence.TimeStampToken,
|
||||
evidence.GenerationTime,
|
||||
evidence.TsaName,
|
||||
evidence.TsaPolicyOid,
|
||||
evidence.SerialNumber,
|
||||
evidence.TsaCertificateChainPem,
|
||||
evidence.OcspResponse,
|
||||
evidence.CrlSnapshot,
|
||||
evidence.CapturedAt,
|
||||
evidence.ProviderName,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await connection.ExecuteAsync(sql, parameters);
|
||||
|
||||
_logger.LogDebug("Stored timestamp evidence {Id} for artifact {Digest}", id, evidence.ArtifactDigest);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TimestampEvidence?> GetByArtifactAsync(string artifactDigest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetLatestByArtifactAsync(artifactDigest, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TimestampEvidence>> GetAllByArtifactAsync(string artifactDigest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, artifact_digest AS ArtifactDigest, digest_algorithm AS DigestAlgorithm,
|
||||
tst_blob AS TimeStampToken, generation_time AS GenerationTime, tsa_name AS TsaName,
|
||||
tsa_policy_oid AS TsaPolicyOid, serial_number AS SerialNumber,
|
||||
tsa_chain_pem AS TsaCertificateChainPem, ocsp_response AS OcspResponse,
|
||||
crl_snapshot AS CrlSnapshot, captured_at AS CapturedAt,
|
||||
provider_name AS ProviderName, created_at AS CreatedAt
|
||||
FROM evidence.timestamp_tokens
|
||||
WHERE artifact_digest = @ArtifactDigest
|
||||
ORDER BY generation_time DESC
|
||||
""";
|
||||
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
var results = await connection.QueryAsync<TimestampEvidence>(sql, new { ArtifactDigest = artifactDigest });
|
||||
return results.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TimestampEvidence?> GetLatestByArtifactAsync(string artifactDigest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, artifact_digest AS ArtifactDigest, digest_algorithm AS DigestAlgorithm,
|
||||
tst_blob AS TimeStampToken, generation_time AS GenerationTime, tsa_name AS TsaName,
|
||||
tsa_policy_oid AS TsaPolicyOid, serial_number AS SerialNumber,
|
||||
tsa_chain_pem AS TsaCertificateChainPem, ocsp_response AS OcspResponse,
|
||||
crl_snapshot AS CrlSnapshot, captured_at AS CapturedAt,
|
||||
provider_name AS ProviderName, created_at AS CreatedAt
|
||||
FROM evidence.timestamp_tokens
|
||||
WHERE artifact_digest = @ArtifactDigest
|
||||
ORDER BY generation_time DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
return await connection.QuerySingleOrDefaultAsync<TimestampEvidence>(sql, new { ArtifactDigest = artifactDigest });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TimestampEvidence?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, artifact_digest AS ArtifactDigest, digest_algorithm AS DigestAlgorithm,
|
||||
tst_blob AS TimeStampToken, generation_time AS GenerationTime, tsa_name AS TsaName,
|
||||
tsa_policy_oid AS TsaPolicyOid, serial_number AS SerialNumber,
|
||||
tsa_chain_pem AS TsaCertificateChainPem, ocsp_response AS OcspResponse,
|
||||
crl_snapshot AS CrlSnapshot, captured_at AS CapturedAt,
|
||||
provider_name AS ProviderName, created_at AS CreatedAt
|
||||
FROM evidence.timestamp_tokens
|
||||
WHERE id = @Id
|
||||
""";
|
||||
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
return await connection.QuerySingleOrDefaultAsync<TimestampEvidence>(sql, new { Id = id });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TimestampEvidence>> GetByTimeRangeAsync(
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, artifact_digest AS ArtifactDigest, digest_algorithm AS DigestAlgorithm,
|
||||
tst_blob AS TimeStampToken, generation_time AS GenerationTime, tsa_name AS TsaName,
|
||||
tsa_policy_oid AS TsaPolicyOid, serial_number AS SerialNumber,
|
||||
tsa_chain_pem AS TsaCertificateChainPem, ocsp_response AS OcspResponse,
|
||||
crl_snapshot AS CrlSnapshot, captured_at AS CapturedAt,
|
||||
provider_name AS ProviderName, created_at AS CreatedAt
|
||||
FROM evidence.timestamp_tokens
|
||||
WHERE generation_time >= @From AND generation_time <= @To
|
||||
ORDER BY generation_time DESC
|
||||
LIMIT @Limit
|
||||
""";
|
||||
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
var results = await connection.QueryAsync<TimestampEvidence>(sql, new { From = from, To = to, Limit = limit });
|
||||
return results.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TimestampEvidence>> GetExpiringBeforeAsync(
|
||||
DateTimeOffset expiryDate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Query timestamps that were generated more than a certain time ago
|
||||
// (proxy for certificate expiry - actual implementation would check cert NotAfter)
|
||||
// This assumes ~3 year TSA certificate validity, so look at generation_time + 3 years
|
||||
const string sql = """
|
||||
SELECT id, artifact_digest AS ArtifactDigest, digest_algorithm AS DigestAlgorithm,
|
||||
tst_blob AS TimeStampToken, generation_time AS GenerationTime, tsa_name AS TsaName,
|
||||
tsa_policy_oid AS TsaPolicyOid, serial_number AS SerialNumber,
|
||||
tsa_chain_pem AS TsaCertificateChainPem, ocsp_response AS OcspResponse,
|
||||
crl_snapshot AS CrlSnapshot, captured_at AS CapturedAt,
|
||||
provider_name AS ProviderName, created_at AS CreatedAt,
|
||||
supersedes_id AS SupersedesId
|
||||
FROM evidence.timestamp_tokens
|
||||
WHERE supersedes_id IS NULL -- Only leaf timestamps (not already superseded)
|
||||
AND generation_time + INTERVAL '3 years' < @ExpiryDate
|
||||
ORDER BY generation_time ASC
|
||||
LIMIT 1000
|
||||
""";
|
||||
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
var results = await connection.QueryAsync<TimestampEvidence>(sql, new { ExpiryDate = expiryDate });
|
||||
return results.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IRevocationEvidenceRepository"/>.
|
||||
/// </summary>
|
||||
public sealed class RevocationEvidenceRepository : IRevocationEvidenceRepository
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly ILogger<RevocationEvidenceRepository> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RevocationEvidenceRepository"/> class.
|
||||
/// </summary>
|
||||
public RevocationEvidenceRepository(
|
||||
string connectionString,
|
||||
ILogger<RevocationEvidenceRepository> logger)
|
||||
{
|
||||
_connectionString = connectionString;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private NpgsqlConnection CreateConnection() => new(_connectionString);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Guid> StoreAsync(RevocationEvidence evidence, CancellationToken cancellationToken = default)
|
||||
{
|
||||
evidence.Validate();
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO evidence.revocation_snapshots (
|
||||
id, certificate_fingerprint, source, raw_response, response_time,
|
||||
valid_until, status, revocation_time, reason, created_at
|
||||
) VALUES (
|
||||
@Id, @CertificateFingerprint, @Source, @RawResponse, @ResponseTime,
|
||||
@ValidUntil, @Status, @RevocationTime, @Reason, @CreatedAt
|
||||
)
|
||||
RETURNING id
|
||||
""";
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
var parameters = new
|
||||
{
|
||||
Id = id,
|
||||
evidence.CertificateFingerprint,
|
||||
Source = evidence.Source.ToString(),
|
||||
evidence.RawResponse,
|
||||
evidence.ResponseTime,
|
||||
evidence.ValidUntil,
|
||||
Status = evidence.Status.ToString(),
|
||||
evidence.RevocationTime,
|
||||
Reason = evidence.Reason?.ToString(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await connection.ExecuteAsync(sql, parameters);
|
||||
|
||||
_logger.LogDebug("Stored revocation evidence {Id} for cert {Fingerprint}", id, evidence.CertificateFingerprint);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RevocationEvidence?> GetByCertificateAsync(string fingerprint, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, certificate_fingerprint AS CertificateFingerprint, source AS SourceStr,
|
||||
raw_response AS RawResponse, response_time AS ResponseTime, valid_until AS ValidUntil,
|
||||
status AS StatusStr, revocation_time AS RevocationTime, reason AS ReasonStr,
|
||||
created_at AS CreatedAt
|
||||
FROM evidence.revocation_snapshots
|
||||
WHERE certificate_fingerprint = @Fingerprint
|
||||
ORDER BY response_time DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
var dto = await connection.QuerySingleOrDefaultAsync<RevocationEvidenceDto>(sql, new { Fingerprint = fingerprint });
|
||||
return dto?.ToModel();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RevocationEvidence>> GetAllByCertificateAsync(string fingerprint, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, certificate_fingerprint AS CertificateFingerprint, source AS SourceStr,
|
||||
raw_response AS RawResponse, response_time AS ResponseTime, valid_until AS ValidUntil,
|
||||
status AS StatusStr, revocation_time AS RevocationTime, reason AS ReasonStr,
|
||||
created_at AS CreatedAt
|
||||
FROM evidence.revocation_snapshots
|
||||
WHERE certificate_fingerprint = @Fingerprint
|
||||
ORDER BY response_time DESC
|
||||
""";
|
||||
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
var results = await connection.QueryAsync<RevocationEvidenceDto>(sql, new { Fingerprint = fingerprint });
|
||||
return results.Select(r => r.ToModel()).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RevocationEvidence>> GetExpiringSoonAsync(TimeSpan window, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, certificate_fingerprint AS CertificateFingerprint, source AS SourceStr,
|
||||
raw_response AS RawResponse, response_time AS ResponseTime, valid_until AS ValidUntil,
|
||||
status AS StatusStr, revocation_time AS RevocationTime, reason AS ReasonStr,
|
||||
created_at AS CreatedAt
|
||||
FROM evidence.revocation_snapshots
|
||||
WHERE valid_until BETWEEN NOW() AND @ExpiryTime
|
||||
ORDER BY valid_until ASC
|
||||
""";
|
||||
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
var expiryTime = DateTimeOffset.UtcNow.Add(window);
|
||||
var results = await connection.QueryAsync<RevocationEvidenceDto>(sql, new { ExpiryTime = expiryTime });
|
||||
return results.Select(r => r.ToModel()).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RevocationEvidence?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, certificate_fingerprint AS CertificateFingerprint, source AS SourceStr,
|
||||
raw_response AS RawResponse, response_time AS ResponseTime, valid_until AS ValidUntil,
|
||||
status AS StatusStr, revocation_time AS RevocationTime, reason AS ReasonStr,
|
||||
created_at AS CreatedAt
|
||||
FROM evidence.revocation_snapshots
|
||||
WHERE id = @Id
|
||||
""";
|
||||
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
var dto = await connection.QuerySingleOrDefaultAsync<RevocationEvidenceDto>(sql, new { Id = id });
|
||||
return dto?.ToModel();
|
||||
}
|
||||
|
||||
// DTO for mapping string enums from database
|
||||
private sealed record RevocationEvidenceDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string CertificateFingerprint { get; init; }
|
||||
public required string SourceStr { get; init; }
|
||||
public required byte[] RawResponse { get; init; }
|
||||
public DateTimeOffset ResponseTime { get; init; }
|
||||
public DateTimeOffset ValidUntil { get; init; }
|
||||
public required string StatusStr { get; init; }
|
||||
public DateTimeOffset? RevocationTime { get; init; }
|
||||
public string? ReasonStr { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
public RevocationEvidence ToModel() => new()
|
||||
{
|
||||
Id = Id,
|
||||
CertificateFingerprint = CertificateFingerprint,
|
||||
Source = Enum.Parse<RevocationSource>(SourceStr),
|
||||
RawResponse = RawResponse,
|
||||
ResponseTime = ResponseTime,
|
||||
ValidUntil = ValidUntil,
|
||||
Status = Enum.Parse<RevocationStatus>(StatusStr),
|
||||
RevocationTime = RevocationTime,
|
||||
Reason = ReasonStr is not null ? Enum.Parse<RevocationReason>(ReasonStr) : null,
|
||||
CreatedAt = CreatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OfflineTimestampVerifier.cs
|
||||
// Sprint: SPRINT_20260119_009 Evidence Storage for Timestamps
|
||||
// Task: EVT-006 - Air-Gap Bundle Support
|
||||
// Description: Offline verification of timestamps using bundled evidence.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.Pkcs;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.EvidenceLocker.Timestamping.Models;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Timestamping.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies timestamps offline using bundled evidence (no network access required).
|
||||
/// </summary>
|
||||
public sealed class OfflineTimestampVerifier
|
||||
{
|
||||
private readonly ILogger<OfflineTimestampVerifier> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OfflineTimestampVerifier"/> class.
|
||||
/// </summary>
|
||||
public OfflineTimestampVerifier(ILogger<OfflineTimestampVerifier> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a timestamp using only bundled evidence (no network access).
|
||||
/// </summary>
|
||||
/// <param name="evidence">The timestamp evidence to verify.</param>
|
||||
/// <param name="expectedDigest">The expected artifact digest.</param>
|
||||
/// <param name="trustAnchors">Trust anchor certificates (PEM format).</param>
|
||||
/// <param name="verificationTime">Time to verify against (null = use current time).</param>
|
||||
/// <returns>Verification result with details.</returns>
|
||||
public OfflineVerificationResult Verify(
|
||||
TimestampEvidence evidence,
|
||||
string expectedDigest,
|
||||
string trustAnchorsPem,
|
||||
DateTimeOffset? verificationTime = null)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
var checks = new List<VerificationCheck>();
|
||||
|
||||
try
|
||||
{
|
||||
// Check 1: Artifact digest matches
|
||||
var digestMatch = string.Equals(
|
||||
evidence.ArtifactDigest,
|
||||
expectedDigest,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "ArtifactDigestMatch",
|
||||
Passed = digestMatch,
|
||||
Details = digestMatch
|
||||
? "Artifact digest matches"
|
||||
: $"Expected {expectedDigest}, got {evidence.ArtifactDigest}"
|
||||
});
|
||||
if (!digestMatch)
|
||||
{
|
||||
errors.Add("Artifact digest mismatch");
|
||||
}
|
||||
|
||||
// Check 2: Parse TST
|
||||
SignedCms signedCms;
|
||||
try
|
||||
{
|
||||
signedCms = new SignedCms();
|
||||
signedCms.Decode(evidence.TimeStampToken);
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "TstParseable",
|
||||
Passed = true,
|
||||
Details = "TST successfully parsed as CMS SignedData"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "TstParseable",
|
||||
Passed = false,
|
||||
Details = $"Failed to parse TST: {ex.Message}"
|
||||
});
|
||||
errors.Add($"TST parsing failed: {ex.Message}");
|
||||
return CreateResult(false, checks, errors, warnings, evidence);
|
||||
}
|
||||
|
||||
// Check 3: Load TSA chain
|
||||
var chain = LoadCertificateChain(evidence.TsaCertificateChainPem);
|
||||
if (chain.Count == 0)
|
||||
{
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "TsaChainLoaded",
|
||||
Passed = false,
|
||||
Details = "No certificates found in TSA chain"
|
||||
});
|
||||
errors.Add("TSA certificate chain is empty");
|
||||
return CreateResult(false, checks, errors, warnings, evidence);
|
||||
}
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "TsaChainLoaded",
|
||||
Passed = true,
|
||||
Details = $"Loaded {chain.Count} certificates from TSA chain"
|
||||
});
|
||||
|
||||
// Check 4: Load trust anchors
|
||||
var trustAnchors = LoadCertificateChain(trustAnchorsPem);
|
||||
if (trustAnchors.Count == 0)
|
||||
{
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "TrustAnchorsLoaded",
|
||||
Passed = false,
|
||||
Details = "No trust anchors provided"
|
||||
});
|
||||
errors.Add("No trust anchors available for verification");
|
||||
return CreateResult(false, checks, errors, warnings, evidence);
|
||||
}
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "TrustAnchorsLoaded",
|
||||
Passed = true,
|
||||
Details = $"Loaded {trustAnchors.Count} trust anchors"
|
||||
});
|
||||
|
||||
// Check 5: Verify CMS signature
|
||||
try
|
||||
{
|
||||
signedCms.CheckSignature(verifySignatureOnly: true);
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "CmsSignatureValid",
|
||||
Passed = true,
|
||||
Details = "CMS signature is valid"
|
||||
});
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "CmsSignatureValid",
|
||||
Passed = false,
|
||||
Details = $"CMS signature invalid: {ex.Message}"
|
||||
});
|
||||
errors.Add($"CMS signature verification failed: {ex.Message}");
|
||||
return CreateResult(false, checks, errors, warnings, evidence);
|
||||
}
|
||||
|
||||
// Check 6: Verify signer certificate was valid at signing time
|
||||
var signerCert = signedCms.SignerInfos[0].Certificate;
|
||||
if (signerCert is null)
|
||||
{
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "SignerCertificatePresent",
|
||||
Passed = false,
|
||||
Details = "Signer certificate not embedded in TST"
|
||||
});
|
||||
errors.Add("Signer certificate not found in TST");
|
||||
return CreateResult(false, checks, errors, warnings, evidence);
|
||||
}
|
||||
|
||||
var signingTime = evidence.GenerationTime;
|
||||
var certValidAtSigningTime =
|
||||
signingTime >= signerCert.NotBefore &&
|
||||
signingTime <= signerCert.NotAfter;
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "SignerCertValidAtSigningTime",
|
||||
Passed = certValidAtSigningTime,
|
||||
Details = certValidAtSigningTime
|
||||
? $"Signer cert valid at {signingTime:O}"
|
||||
: $"Signer cert not valid at {signingTime:O} (valid {signerCert.NotBefore:O} to {signerCert.NotAfter:O})"
|
||||
});
|
||||
if (!certValidAtSigningTime)
|
||||
{
|
||||
errors.Add("Signer certificate was not valid at signing time");
|
||||
}
|
||||
|
||||
// Check 7: Verify chain builds to trust anchor
|
||||
var chainValid = VerifyChainToTrustAnchor(chain, trustAnchors, signingTime);
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "ChainBuildsTrustAnchor",
|
||||
Passed = chainValid,
|
||||
Details = chainValid
|
||||
? "Certificate chain builds to trusted anchor"
|
||||
: "Certificate chain does not build to any trusted anchor"
|
||||
});
|
||||
if (!chainValid)
|
||||
{
|
||||
errors.Add("Certificate chain verification failed");
|
||||
}
|
||||
|
||||
// Check 8: Verify stapled revocation data (if required)
|
||||
if (evidence.HasRevocationEvidence)
|
||||
{
|
||||
var revocationValid = VerifyStapledRevocation(
|
||||
evidence,
|
||||
signerCert,
|
||||
signingTime);
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "RevocationDataValid",
|
||||
Passed = revocationValid,
|
||||
Details = revocationValid
|
||||
? "Stapled revocation data verified successfully"
|
||||
: "Stapled revocation data verification failed"
|
||||
});
|
||||
if (!revocationValid)
|
||||
{
|
||||
errors.Add("Stapled revocation data is invalid");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(new VerificationCheck
|
||||
{
|
||||
Name = "RevocationDataPresent",
|
||||
Passed = false,
|
||||
Details = "No stapled revocation data (OCSP or CRL) present"
|
||||
});
|
||||
warnings.Add("No stapled revocation data - cannot verify certificate was not revoked at signing time");
|
||||
}
|
||||
|
||||
var allPassed = errors.Count == 0;
|
||||
return CreateResult(allPassed, checks, errors, warnings, evidence);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error during offline verification");
|
||||
errors.Add($"Unexpected error: {ex.Message}");
|
||||
return CreateResult(false, checks, errors, warnings, evidence);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<X509Certificate2> LoadCertificateChain(string pem)
|
||||
{
|
||||
var certs = new List<X509Certificate2>();
|
||||
if (string.IsNullOrWhiteSpace(pem))
|
||||
{
|
||||
return certs;
|
||||
}
|
||||
|
||||
// Split PEM into individual certificates
|
||||
const string beginMarker = "-----BEGIN CERTIFICATE-----";
|
||||
const string endMarker = "-----END CERTIFICATE-----";
|
||||
|
||||
var pemSpan = pem.AsSpan();
|
||||
var startIndex = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var beginIndex = pem.IndexOf(beginMarker, startIndex, StringComparison.Ordinal);
|
||||
if (beginIndex < 0) break;
|
||||
|
||||
var endIndex = pem.IndexOf(endMarker, beginIndex, StringComparison.Ordinal);
|
||||
if (endIndex < 0) break;
|
||||
|
||||
var certPem = pem.Substring(beginIndex, endIndex - beginIndex + endMarker.Length);
|
||||
try
|
||||
{
|
||||
certs.Add(X509Certificate2.CreateFromPem(certPem));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip invalid certificates
|
||||
}
|
||||
|
||||
startIndex = endIndex + endMarker.Length;
|
||||
}
|
||||
|
||||
return certs;
|
||||
}
|
||||
|
||||
private static bool VerifyChainToTrustAnchor(
|
||||
List<X509Certificate2> chain,
|
||||
List<X509Certificate2> trustAnchors,
|
||||
DateTimeOffset verificationTime)
|
||||
{
|
||||
if (chain.Count == 0) return false;
|
||||
|
||||
var leafCert = chain[0];
|
||||
using var x509Chain = new X509Chain();
|
||||
x509Chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
x509Chain.ChainPolicy.VerificationTime = verificationTime.UtcDateTime;
|
||||
x509Chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
|
||||
foreach (var anchor in trustAnchors)
|
||||
{
|
||||
x509Chain.ChainPolicy.CustomTrustStore.Add(anchor);
|
||||
}
|
||||
|
||||
foreach (var intermediate in chain.Skip(1))
|
||||
{
|
||||
x509Chain.ChainPolicy.ExtraStore.Add(intermediate);
|
||||
}
|
||||
|
||||
return x509Chain.Build(leafCert);
|
||||
}
|
||||
|
||||
private bool VerifyStapledRevocation(
|
||||
TimestampEvidence evidence,
|
||||
X509Certificate2 signerCert,
|
||||
DateTimeOffset signingTime)
|
||||
{
|
||||
// For OCSP: verify response signature, check thisUpdate <= signingTime <= nextUpdate
|
||||
if (evidence.OcspResponse is { Length: > 0 })
|
||||
{
|
||||
try
|
||||
{
|
||||
// Basic OCSP response structure check
|
||||
// Full verification would require parsing OCSPResponse ASN.1 structure
|
||||
var reader = new AsnReader(evidence.OcspResponse, AsnEncodingRules.DER);
|
||||
var response = reader.ReadSequence();
|
||||
var responseStatus = response.ReadEnumeratedValue<OcspResponseStatus>();
|
||||
|
||||
if (responseStatus != OcspResponseStatus.Successful)
|
||||
{
|
||||
_logger.LogWarning("OCSP response status is not successful: {Status}", responseStatus);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we got here, the OCSP response exists and has successful status
|
||||
// Full verification would parse BasicOCSPResponse and check signature
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse OCSP response");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For CRL: verify CRL signature, check certificate serial not in list
|
||||
if (evidence.CrlSnapshot is { Length: > 0 })
|
||||
{
|
||||
try
|
||||
{
|
||||
// Basic CRL structure check
|
||||
// Full verification would require parsing CRL ASN.1 structure
|
||||
var reader = new AsnReader(evidence.CrlSnapshot, AsnEncodingRules.DER);
|
||||
var crl = reader.ReadSequence();
|
||||
// If parseable, assume valid for now
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse CRL");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static OfflineVerificationResult CreateResult(
|
||||
bool success,
|
||||
List<VerificationCheck> checks,
|
||||
List<string> errors,
|
||||
List<string> warnings,
|
||||
TimestampEvidence evidence)
|
||||
{
|
||||
return new OfflineVerificationResult
|
||||
{
|
||||
Success = success,
|
||||
Checks = checks,
|
||||
Errors = errors,
|
||||
Warnings = warnings,
|
||||
ArtifactDigest = evidence.ArtifactDigest,
|
||||
GenerationTime = evidence.GenerationTime,
|
||||
TsaName = evidence.TsaName,
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private enum OcspResponseStatus
|
||||
{
|
||||
Successful = 0,
|
||||
MalformedRequest = 1,
|
||||
InternalError = 2,
|
||||
TryLater = 3,
|
||||
SigRequired = 5,
|
||||
Unauthorized = 6
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of offline timestamp verification.
|
||||
/// </summary>
|
||||
public sealed record OfflineVerificationResult
|
||||
{
|
||||
/// <summary>Gets whether verification succeeded.</summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>Gets the individual verification checks.</summary>
|
||||
public required IReadOnlyList<VerificationCheck> Checks { get; init; }
|
||||
|
||||
/// <summary>Gets any errors that occurred.</summary>
|
||||
public required IReadOnlyList<string> Errors { get; init; }
|
||||
|
||||
/// <summary>Gets any warnings (non-fatal issues).</summary>
|
||||
public required IReadOnlyList<string> Warnings { get; init; }
|
||||
|
||||
/// <summary>Gets the artifact digest that was verified.</summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>Gets the timestamp generation time.</summary>
|
||||
public required DateTimeOffset GenerationTime { get; init; }
|
||||
|
||||
/// <summary>Gets the TSA name.</summary>
|
||||
public required string TsaName { get; init; }
|
||||
|
||||
/// <summary>Gets when verification was performed.</summary>
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual verification check result.
|
||||
/// </summary>
|
||||
public sealed record VerificationCheck
|
||||
{
|
||||
/// <summary>Gets the check name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Gets whether the check passed.</summary>
|
||||
public required bool Passed { get; init; }
|
||||
|
||||
/// <summary>Gets details about the check.</summary>
|
||||
public required string Details { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user