Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly.
- Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps.
- Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges.
- Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges.
- Set up project file for the test project with necessary dependencies and configurations.
- Include JSON fixture files for testing purposes.
This commit is contained in:
master
2025-11-04 07:49:39 +02:00
parent f72c5c513a
commit 2eb6852d34
491 changed files with 39445 additions and 3917 deletions

View File

@@ -0,0 +1,39 @@
using StellaOps.EvidenceLocker.Core.Domain;
namespace StellaOps.EvidenceLocker.Core.Builders;
public sealed record EvidenceBundleBuildRequest(
EvidenceBundleId BundleId,
TenantId TenantId,
EvidenceBundleKind Kind,
DateTimeOffset CreatedAt,
IReadOnlyDictionary<string, string> Metadata,
IReadOnlyList<EvidenceBundleMaterial> Materials);
public sealed record EvidenceBundleMaterial(
string Section,
string Path,
string Sha256,
long SizeBytes,
string MediaType,
IReadOnlyDictionary<string, string>? Attributes = null);
public sealed record EvidenceManifestEntry(
string Section,
string CanonicalPath,
string Sha256,
long SizeBytes,
string MediaType,
IReadOnlyDictionary<string, string> Attributes);
public sealed record EvidenceBundleManifest(
EvidenceBundleId BundleId,
TenantId TenantId,
EvidenceBundleKind Kind,
DateTimeOffset CreatedAt,
IReadOnlyDictionary<string, string> Metadata,
IReadOnlyList<EvidenceManifestEntry> Entries);
public sealed record EvidenceBundleBuildResult(
string RootHash,
EvidenceBundleManifest Manifest);

View File

@@ -0,0 +1,8 @@
namespace StellaOps.EvidenceLocker.Core.Builders;
public interface IEvidenceBundleBuilder
{
Task<EvidenceBundleBuildResult> BuildAsync(
EvidenceBundleBuildRequest request,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,54 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.EvidenceLocker.Core.Builders;
public interface IMerkleTreeCalculator
{
string CalculateRootHash(IEnumerable<string> canonicalLeafValues);
}
public sealed class MerkleTreeCalculator : IMerkleTreeCalculator
{
public string CalculateRootHash(IEnumerable<string> canonicalLeafValues)
{
var leaves = canonicalLeafValues
.Select(value => HashString(value))
.ToArray();
if (leaves.Length == 0)
{
return HashString("stellaops:evidence:empty");
}
return BuildTree(leaves);
}
private static string BuildTree(IReadOnlyList<string> currentLevel)
{
if (currentLevel.Count == 1)
{
return currentLevel[0];
}
var nextLevel = new List<string>((currentLevel.Count + 1) / 2);
for (var i = 0; i < currentLevel.Count; i += 2)
{
var left = currentLevel[i];
var right = i + 1 < currentLevel.Count ? currentLevel[i + 1] : left;
var combined = string.CompareOrdinal(left, right) <= 0
? $"{left}|{right}"
: $"{right}|{left}";
nextLevel.Add(HashString(combined));
}
return BuildTree(nextLevel);
}
private static string HashString(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -1,6 +0,0 @@
namespace StellaOps.EvidenceLocker.Core;
public class Class1
{
}

View File

@@ -0,0 +1,210 @@
using System.ComponentModel.DataAnnotations;
using StellaOps.Cryptography;
namespace StellaOps.EvidenceLocker.Core.Configuration;
public sealed class EvidenceLockerOptions
{
public const string SectionName = "EvidenceLocker";
[Required]
public required DatabaseOptions Database { get; init; }
[Required]
public required ObjectStoreOptions ObjectStore { get; init; }
[Required]
public required QuotaOptions Quotas { get; init; }
[Required]
public required SigningOptions Signing { get; init; }
public TimelineOptions? Timeline { get; init; }
public PortableOptions Portable { get; init; } = new();
public IncidentModeOptions Incident { get; init; } = new();
}
public sealed class DatabaseOptions
{
[Required]
public required string ConnectionString { get; init; }
/// <summary>
/// Enables automatic execution of SQL migrations at startup.
/// </summary>
public bool ApplyMigrationsAtStartup { get; init; } = true;
}
public enum ObjectStoreKind
{
FileSystem = 1,
AmazonS3 = 2
}
public sealed class ObjectStoreOptions
{
[Required]
public required ObjectStoreKind Kind { get; init; }
/// <summary>
/// When true, drivers must prevent object overwrite (WORM mode).
/// </summary>
public bool EnforceWriteOnce { get; init; } = true;
public FileSystemStoreOptions? FileSystem { get; init; }
public AmazonS3StoreOptions? AmazonS3 { get; init; }
}
public sealed class FileSystemStoreOptions
{
[Required]
public required string RootPath { get; init; }
}
public sealed class AmazonS3StoreOptions
{
[Required]
public required string BucketName { get; init; }
[Required]
public required string Region { get; init; }
/// <summary>
/// Optional prefix to namespace evidence objects.
/// </summary>
public string? Prefix { get; init; }
public bool UseIntelligentTiering { get; init; }
}
public sealed class QuotaOptions
{
[Range(1, 10_000)]
public int MaxMaterialCount { get; init; } = 128;
[Range(1, long.MaxValue)]
public long MaxTotalMaterialSizeBytes { get; init; } = 512L * 1024 * 1024;
[Range(0, 10_000)]
public int MaxMetadataEntries { get; init; } = 64;
[Range(0, 2048)]
public int MaxMetadataKeyLength { get; init; } = 128;
[Range(0, 8192)]
public int MaxMetadataValueLength { get; init; } = 512;
}
public sealed class SigningOptions
{
public bool Enabled { get; init; } = true;
[Required]
public string Algorithm { get; init; } = SignatureAlgorithms.Es256;
[Required]
public string KeyId { get; init; } = string.Empty;
public string? Provider { get; init; }
public string PayloadType { get; init; } = "application/vnd.stella.evidence.manifest+json";
public SigningKeyMaterialOptions? KeyMaterial { get; init; }
public TimestampingOptions? Timestamping { get; init; }
}
public sealed class SigningKeyMaterialOptions
{
/// <summary>
/// Optional PEM-encoded EC private key used to seed the default provider.
/// </summary>
public string? EcPrivateKeyPem { get; init; }
/// <summary>
/// Optional PEM-encoded EC public key to accompany the private key when seeding providers that require explicit public material.
/// </summary>
public string? EcPublicKeyPem { get; init; }
}
public sealed class TimestampingOptions
{
public bool Enabled { get; init; }
[Url]
public string? Endpoint { get; init; }
public string HashAlgorithm { get; init; } = "SHA256";
public bool RequireTimestamp { get; init; }
[Range(1, 300)]
public int RequestTimeoutSeconds { get; init; } = 30;
public TimestampAuthorityAuthenticationOptions? Authentication { get; init; }
}
public sealed class TimestampAuthorityAuthenticationOptions
{
public string? Username { get; init; }
public string? Password { get; init; }
}
public sealed class IncidentModeOptions
{
public bool Enabled { get; init; }
[Range(0, 3650)]
public int RetentionExtensionDays { get; init; } = 30;
public bool CaptureRequestSnapshot { get; init; } = true;
}
public sealed class TimelineOptions
{
public bool Enabled { get; init; }
[Url]
public string? Endpoint { get; init; }
[Range(1, 300)]
public int RequestTimeoutSeconds { get; init; } = 15;
public string Source { get; init; } = "stellaops.evidence-locker";
public TimelineAuthenticationOptions? Authentication { get; init; }
}
public sealed class TimelineAuthenticationOptions
{
public string HeaderName { get; init; } = "Authorization";
public string Scheme { get; init; } = "Bearer";
public string? Token { get; init; }
}
public sealed class PortableOptions
{
public bool Enabled { get; init; } = true;
[Required]
[MinLength(1)]
public string ArtifactName { get; init; } = "portable-bundle-v1.tgz";
[Required]
[MinLength(1)]
public string InstructionsFileName { get; init; } = "instructions-portable.txt";
[Required]
[MinLength(1)]
public string OfflineScriptFileName { get; init; } = "verify-offline.sh";
[Required]
[MinLength(1)]
public string MetadataFileName { get; init; } = "bundle.json";
}

View File

@@ -0,0 +1,54 @@
namespace StellaOps.EvidenceLocker.Core.Domain;
public enum EvidenceBundleKind
{
Evaluation = 1,
Job = 2,
Export = 3
}
public enum EvidenceBundleStatus
{
Pending = 1,
Assembling = 2,
Sealed = 3,
Failed = 4,
Archived = 5
}
public sealed record EvidenceBundle(
EvidenceBundleId Id,
TenantId TenantId,
EvidenceBundleKind Kind,
EvidenceBundleStatus Status,
string RootHash,
string StorageKey,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
string? Description = null,
DateTimeOffset? SealedAt = null,
DateTimeOffset? ExpiresAt = null,
string? PortableStorageKey = null,
DateTimeOffset? PortableGeneratedAt = null);
public sealed record EvidenceArtifact(
EvidenceArtifactId Id,
EvidenceBundleId BundleId,
TenantId TenantId,
string Name,
string ContentType,
long SizeBytes,
string StorageKey,
string Sha256,
DateTimeOffset CreatedAt);
public sealed record EvidenceHold(
EvidenceHoldId Id,
TenantId TenantId,
EvidenceBundleId? BundleId,
string CaseId,
string Reason,
DateTimeOffset CreatedAt,
DateTimeOffset? ExpiresAt,
DateTimeOffset? ReleasedAt,
string? Notes = null);

View File

@@ -0,0 +1,21 @@
using System;
namespace StellaOps.EvidenceLocker.Core.Domain;
public sealed record EvidenceBundleSignature(
EvidenceBundleId BundleId,
TenantId TenantId,
string PayloadType,
string Payload,
string Signature,
string? KeyId,
string Algorithm,
string Provider,
DateTimeOffset SignedAt,
DateTimeOffset? TimestampedAt = null,
string? TimestampAuthority = null,
byte[]? TimestampToken = null);
public sealed record EvidenceBundleDetails(
EvidenceBundle Bundle,
EvidenceBundleSignature? Signature);

View File

@@ -0,0 +1,41 @@
namespace StellaOps.EvidenceLocker.Core.Domain;
public readonly record struct TenantId(Guid Value)
{
public static TenantId FromGuid(Guid value)
=> value == Guid.Empty
? throw new ArgumentException("Tenant identifier cannot be empty.", nameof(value))
: new TenantId(value);
public override string ToString() => Value.ToString("N");
}
public readonly record struct EvidenceBundleId(Guid Value)
{
public static EvidenceBundleId FromGuid(Guid value)
=> value == Guid.Empty
? throw new ArgumentException("Bundle identifier cannot be empty.", nameof(value))
: new EvidenceBundleId(value);
public override string ToString() => Value.ToString("N");
}
public readonly record struct EvidenceArtifactId(Guid Value)
{
public static EvidenceArtifactId FromGuid(Guid value)
=> value == Guid.Empty
? throw new ArgumentException("Artifact identifier cannot be empty.", nameof(value))
: new EvidenceArtifactId(value);
public override string ToString() => Value.ToString("N");
}
public readonly record struct EvidenceHoldId(Guid Value)
{
public static EvidenceHoldId FromGuid(Guid value)
=> value == Guid.Empty
? throw new ArgumentException("Hold identifier cannot be empty.", nameof(value))
: new EvidenceHoldId(value);
public override string ToString() => Value.ToString("N");
}

View File

@@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using StellaOps.EvidenceLocker.Core.Builders;
namespace StellaOps.EvidenceLocker.Core.Domain;
public sealed record EvidenceSnapshotRequest
{
public EvidenceBundleKind Kind { get; init; }
public string? Description { get; init; }
public IDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
public IList<EvidenceSnapshotMaterial> Materials { get; init; } = new List<EvidenceSnapshotMaterial>();
}
public sealed record EvidenceSnapshotMaterial
{
public string? Section { get; init; }
public string? Path { get; init; }
public string Sha256 { get; init; } = string.Empty;
public long SizeBytes { get; init; }
public string? MediaType { get; init; }
public IDictionary<string, string> Attributes { get; init; } = new Dictionary<string, string>();
}
public sealed record EvidenceSnapshotResult(
Guid BundleId,
string RootHash,
EvidenceBundleManifest Manifest,
EvidenceBundleSignature? Signature);
public sealed record EvidenceHoldRequest
{
public Guid? BundleId { get; init; }
public string Reason { get; init; } = string.Empty;
public DateTimeOffset? ExpiresAt { get; init; }
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,21 @@
using System;
namespace StellaOps.EvidenceLocker.Core.Incident;
public interface IIncidentModeState
{
IncidentModeSnapshot Current { get; }
bool IsActive { get; }
}
public sealed record IncidentModeSnapshot(
bool IsActive,
DateTimeOffset ChangedAt,
int RetentionExtensionDays,
bool CaptureRequestSnapshot);
public sealed record IncidentModeChange(
bool IsActive,
DateTimeOffset ChangedAt,
int RetentionExtensionDays);

View File

@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.EvidenceLocker.Core.Incident;
namespace StellaOps.EvidenceLocker.Core.Notifications;
public interface IEvidenceIncidentNotifier
{
Task PublishIncidentModeChangedAsync(IncidentModeChange change, CancellationToken cancellationToken);
}
public sealed class NullEvidenceIncidentNotifier : IEvidenceIncidentNotifier
{
public Task PublishIncidentModeChangedAsync(IncidentModeChange change, CancellationToken cancellationToken)
=> Task.CompletedTask;
}

View File

@@ -0,0 +1,53 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.EvidenceLocker.Core.Domain;
namespace StellaOps.EvidenceLocker.Core.Repositories;
public interface IEvidenceBundleRepository
{
Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken);
Task SetBundleAssemblyAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
EvidenceBundleStatus status,
string rootHash,
DateTimeOffset updatedAt,
CancellationToken cancellationToken);
Task MarkBundleSealedAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
EvidenceBundleStatus status,
DateTimeOffset sealedAt,
CancellationToken cancellationToken);
Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken);
Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken);
Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken);
Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken);
Task ExtendBundleRetentionAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
DateTimeOffset? holdExpiresAt,
DateTimeOffset processedAt,
CancellationToken cancellationToken);
Task UpdateStorageKeyAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
string storageKey,
CancellationToken cancellationToken);
Task UpdatePortableStorageKeyAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
string storageKey,
DateTimeOffset generatedAt,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,15 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
namespace StellaOps.EvidenceLocker.Core.Signing;
public interface IEvidenceSignatureService
{
Task<EvidenceBundleSignature?> SignManifestAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
EvidenceBundleManifest manifest,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,18 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.EvidenceLocker.Core.Signing;
public interface ITimestampAuthorityClient
{
Task<TimestampResult?> RequestTimestampAsync(
ReadOnlyMemory<byte> signature,
string hashAlgorithm,
CancellationToken cancellationToken);
}
public sealed record TimestampResult(
DateTimeOffset Timestamp,
string Authority,
byte[] Token);

View File

@@ -1,18 +1,14 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
<?xml version="1.0"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,36 @@
using System.Collections.Generic;
using StellaOps.EvidenceLocker.Core.Domain;
namespace StellaOps.EvidenceLocker.Core.Storage;
public sealed record EvidenceObjectMetadata(
string StorageKey,
string ContentType,
long SizeBytes,
string Sha256,
string? ETag,
DateTimeOffset CreatedAt);
public sealed record EvidenceObjectWriteOptions(
TenantId TenantId,
EvidenceBundleId BundleId,
string ArtifactName,
string ContentType,
bool EnforceWriteOnce = true,
IDictionary<string, string>? Tags = null);
public interface IEvidenceObjectStore
{
Task<EvidenceObjectMetadata> StoreAsync(
Stream content,
EvidenceObjectWriteOptions options,
CancellationToken cancellationToken);
Task<Stream> OpenReadAsync(
string storageKey,
CancellationToken cancellationToken);
Task<bool> ExistsAsync(
string storageKey,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,24 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Incident;
namespace StellaOps.EvidenceLocker.Core.Timeline;
public interface IEvidenceTimelinePublisher
{
Task PublishBundleSealedAsync(
EvidenceBundleSignature signature,
EvidenceBundleManifest manifest,
string rootHash,
CancellationToken cancellationToken);
Task PublishHoldCreatedAsync(
EvidenceHold hold,
CancellationToken cancellationToken);
Task PublishIncidentModeChangedAsync(
IncidentModeChange change,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.EvidenceLocker.Tests")]

View File

@@ -0,0 +1,120 @@
using System.Collections.Immutable;
using System.Text;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
namespace StellaOps.EvidenceLocker.Infrastructure.Builders;
internal sealed class EvidenceBundleBuilder(
IEvidenceBundleRepository repository,
IMerkleTreeCalculator merkleTreeCalculator) : IEvidenceBundleBuilder
{
public async Task<EvidenceBundleBuildResult> BuildAsync(
EvidenceBundleBuildRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var manifest = CreateManifest(request);
var rootHash = merkleTreeCalculator.CalculateRootHash(
manifest.Entries.Select(entry => $"{entry.CanonicalPath}|{entry.Sha256}"));
await repository.SetBundleAssemblyAsync(
request.BundleId,
request.TenantId,
EvidenceBundleStatus.Sealed,
rootHash,
request.CreatedAt,
cancellationToken);
return new EvidenceBundleBuildResult(rootHash, manifest);
}
private static EvidenceBundleManifest CreateManifest(EvidenceBundleBuildRequest request)
{
var normalizedMetadata = request.Metadata?.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.ToImmutableDictionary(pair => pair.Key, pair => pair.Value)
?? ImmutableDictionary<string, string>.Empty;
var entries = request.Materials?
.Select(material => CreateEntry(material))
.OrderBy(entry => entry.CanonicalPath, StringComparer.Ordinal)
.ToImmutableArray() ?? ImmutableArray<EvidenceManifestEntry>.Empty;
return new EvidenceBundleManifest(
request.BundleId,
request.TenantId,
request.Kind,
request.CreatedAt,
normalizedMetadata,
entries);
}
private static EvidenceManifestEntry CreateEntry(EvidenceBundleMaterial material)
{
var canonicalSection = NormalizeSection(material.Section);
var canonicalPath = $"{canonicalSection}/{NormalizePath(material.Path)}";
var attributes = material.Attributes?
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
.ToImmutableDictionary(pair => pair.Key, pair => pair.Value)
?? ImmutableDictionary<string, string>.Empty;
return new EvidenceManifestEntry(
canonicalSection,
canonicalPath,
material.Sha256.ToLowerInvariant(),
material.SizeBytes,
material.MediaType,
attributes);
}
private static string NormalizeSection(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "root";
}
return NormalizeSegment(value);
}
private static string NormalizePath(string value)
{
if (string.IsNullOrEmpty(value))
{
return "item";
}
var segments = value.Split(['/','\\'], StringSplitOptions.RemoveEmptyEntries);
var normalized = segments
.Where(segment => segment is not "." and not "..")
.Select(NormalizeSegment);
return string.Join('/', normalized);
}
private static string NormalizeSegment(string value)
{
Span<char> buffer = stackalloc char[value.Length];
var index = 0;
foreach (var ch in value.Trim())
{
if (char.IsLetterOrDigit(ch))
{
buffer[index++] = char.ToLowerInvariant(ch);
}
else if (ch is '-' or '_' || ch == '.')
{
buffer[index++] = ch;
}
else
{
buffer[index++] = '-';
}
}
return index > 0 ? new string(buffer[..index]) : "item";
}
}

View File

@@ -1,6 +0,0 @@
namespace StellaOps.EvidenceLocker.Infrastructure;
public class Class1
{
}

View File

@@ -0,0 +1,71 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
namespace StellaOps.EvidenceLocker.Infrastructure.Db;
public sealed class EvidenceLockerDataSource : IAsyncDisposable
{
private readonly NpgsqlDataSource _dataSource;
private readonly ILogger<EvidenceLockerDataSource> _logger;
public EvidenceLockerDataSource(
DatabaseOptions databaseOptions,
ILogger<EvidenceLockerDataSource> logger)
{
ArgumentNullException.ThrowIfNull(databaseOptions);
ArgumentException.ThrowIfNullOrWhiteSpace(databaseOptions.ConnectionString);
_logger = logger;
_dataSource = CreateDataSource(databaseOptions.ConnectionString);
}
public async ValueTask DisposeAsync()
{
await _dataSource.DisposeAsync();
}
public Task<NpgsqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
=> OpenConnectionAsync(null, cancellationToken);
public async Task<NpgsqlConnection> OpenConnectionAsync(TenantId? tenantId, CancellationToken cancellationToken)
{
var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await ConfigureSessionAsync(connection, tenantId, cancellationToken);
return connection;
}
private static NpgsqlDataSource CreateDataSource(string connectionString)
{
var builder = new NpgsqlDataSourceBuilder(connectionString);
builder.EnableDynamicJson();
return builder.Build();
}
private async Task ConfigureSessionAsync(NpgsqlConnection connection, TenantId? tenantId, CancellationToken cancellationToken)
{
try
{
await using var command = new NpgsqlCommand("SET TIME ZONE 'UTC';", connection);
await command.ExecuteNonQueryAsync(cancellationToken);
if (tenantId.HasValue)
{
await using var tenantCommand = new NpgsqlCommand("SELECT set_config('app.current_tenant', @tenant, false);", connection);
tenantCommand.Parameters.AddWithValue("tenant", tenantId.Value.Value.ToString("D"));
await tenantCommand.ExecuteNonQueryAsync(cancellationToken);
}
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error))
{
_logger.LogError(ex, "Failed to configure Evidence Locker session state.");
}
await connection.DisposeAsync();
throw;
}
}
}

View File

@@ -0,0 +1,120 @@
using Microsoft.Extensions.Logging;
using Npgsql;
namespace StellaOps.EvidenceLocker.Infrastructure.Db;
public interface IEvidenceLockerMigrationRunner
{
Task ApplyAsync(CancellationToken cancellationToken);
}
internal sealed class EvidenceLockerMigrationRunner(
EvidenceLockerDataSource dataSource,
ILogger<EvidenceLockerMigrationRunner> logger) : IEvidenceLockerMigrationRunner
{
private const string VersionTableSql = """
CREATE TABLE IF NOT EXISTS evidence_locker.evidence_schema_version
(
version integer PRIMARY KEY,
script_name text NOT NULL,
script_checksum text NOT NULL,
applied_at_utc timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')
);
""";
public async Task ApplyAsync(CancellationToken cancellationToken)
{
var scripts = MigrationLoader.LoadAll();
if (scripts.Count == 0)
{
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogDebug("No migrations discovered for Evidence Locker.");
}
return;
}
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
await EnsureVersionTableAsync(connection, transaction, cancellationToken);
var appliedScripts = await LoadAppliedScriptsAsync(connection, transaction, cancellationToken);
foreach (var script in scripts)
{
if (appliedScripts.TryGetValue(script.Version, out var existingChecksum))
{
if (!string.Equals(existingChecksum, script.Sha256, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Checksum mismatch for migration {script.Name}. Expected {existingChecksum}, computed {script.Sha256}.");
}
continue;
}
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation("Applying Evidence Locker migration {Version}: {Name}", script.Version, script.Name);
}
await ExecuteScriptAsync(connection, transaction, script.Sql, cancellationToken);
await RecordAppliedScriptAsync(connection, transaction, script, cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
private static async Task EnsureVersionTableAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(VersionTableSql, connection, transaction);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task<Dictionary<int, string>> LoadAppliedScriptsAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, CancellationToken cancellationToken)
{
const string sql = """
SELECT version, script_checksum
FROM evidence_locker.evidence_schema_version
ORDER BY version;
""";
await using var command = new NpgsqlCommand(sql, connection, transaction);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var dictionary = new Dictionary<int, string>();
while (await reader.ReadAsync(cancellationToken))
{
var version = reader.GetInt32(0);
var checksum = reader.GetString(1);
dictionary[version] = checksum;
}
return dictionary;
}
private static async Task ExecuteScriptAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, string sql, CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(sql, connection, transaction)
{
CommandTimeout = 0
};
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task RecordAppliedScriptAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, MigrationScript script, CancellationToken cancellationToken)
{
const string insertSql = """
INSERT INTO evidence_locker.evidence_schema_version(version, script_name, script_checksum)
VALUES (@version, @name, @checksum);
""";
await using var command = new NpgsqlCommand(insertSql, connection, transaction);
command.Parameters.AddWithValue("version", script.Version);
command.Parameters.AddWithValue("name", script.Name);
command.Parameters.AddWithValue("checksum", script.Sha256);
await command.ExecuteNonQueryAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,39 @@
using System.Reflection;
namespace StellaOps.EvidenceLocker.Infrastructure.Db;
internal static class MigrationLoader
{
private static readonly Assembly Assembly = typeof(MigrationLoader).Assembly;
public static IReadOnlyList<MigrationScript> LoadAll()
{
var scripts = new List<MigrationScript>();
foreach (var resourceName in Assembly.GetManifestResourceNames())
{
if (!resourceName.Contains(".Db.Migrations.", StringComparison.OrdinalIgnoreCase))
{
continue;
}
using var stream = Assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
continue;
}
using var reader = new StreamReader(stream);
var sql = reader.ReadToEnd();
if (MigrationScript.TryCreate(resourceName, sql, out var script))
{
scripts.Add(script);
}
}
return scripts
.OrderBy(script => script.Version)
.ToArray();
}
}

View File

@@ -0,0 +1,53 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace StellaOps.EvidenceLocker.Infrastructure.Db;
internal sealed class MigrationScript
{
private static readonly Regex VersionRegex = new(@"^(?<version>\d{3,})[_-]", RegexOptions.Compiled);
private MigrationScript(int version, string name, string sql)
{
Version = version;
Name = name;
Sql = sql;
Sha256 = ComputeSha256(sql);
}
public int Version { get; }
public string Name { get; }
public string Sql { get; }
public string Sha256 { get; }
public static bool TryCreate(string resourceName, string sql, [NotNullWhen(true)] out MigrationScript? script)
{
var fileName = resourceName.Split('.').Last();
var match = VersionRegex.Match(fileName);
if (!match.Success || !int.TryParse(match.Groups["version"].Value, out var version))
{
script = null;
return false;
}
script = new MigrationScript(version, fileName, sql);
return true;
}
private static string ComputeSha256(string sql)
{
var normalized = NormalizeLineEndings(sql);
var bytes = Encoding.UTF8.GetBytes(normalized);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string NormalizeLineEndings(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
}

View File

@@ -0,0 +1,100 @@
-- 001_initial_schema.sql
-- Establishes core schema, RLS policies, and supporting functions for the Evidence Locker.
CREATE SCHEMA IF NOT EXISTS evidence_locker;
CREATE SCHEMA IF NOT EXISTS evidence_locker_app;
CREATE OR REPLACE FUNCTION evidence_locker_app.require_current_tenant()
RETURNS uuid
LANGUAGE plpgsql
AS $$
DECLARE
tenant_text text;
BEGIN
tenant_text := current_setting('app.current_tenant', true);
IF tenant_text IS NULL OR length(tenant_text) = 0 THEN
RAISE EXCEPTION 'app.current_tenant is not set for the current session';
END IF;
RETURN tenant_text::uuid;
END;
$$;
CREATE TABLE IF NOT EXISTS evidence_locker.evidence_bundles
(
bundle_id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
kind smallint NOT NULL CHECK (kind BETWEEN 1 AND 3),
status smallint NOT NULL CHECK (status BETWEEN 1 AND 5),
root_hash text NOT NULL CHECK (root_hash ~ '^[0-9a-f]{64}$'),
storage_key text NOT NULL,
description text,
sealed_at timestamptz,
expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
updated_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_evidence_bundles_storage_key
ON evidence_locker.evidence_bundles (tenant_id, storage_key);
ALTER TABLE evidence_locker.evidence_bundles
ENABLE ROW LEVEL SECURITY;
CREATE POLICY IF NOT EXISTS evidence_bundles_isolation
ON evidence_locker.evidence_bundles
USING (tenant_id = evidence_locker_app.require_current_tenant())
WITH CHECK (tenant_id = evidence_locker_app.require_current_tenant());
CREATE TABLE IF NOT EXISTS evidence_locker.evidence_artifacts
(
artifact_id uuid PRIMARY KEY,
bundle_id uuid NOT NULL,
tenant_id uuid NOT NULL,
name text NOT NULL,
content_type text NOT NULL,
size_bytes bigint NOT NULL CHECK (size_bytes >= 0),
storage_key text NOT NULL,
sha256 text NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
created_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
CONSTRAINT fk_artifacts_bundle FOREIGN KEY (bundle_id) REFERENCES evidence_locker.evidence_bundles (bundle_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_evidence_artifacts_bundle_id
ON evidence_locker.evidence_artifacts (bundle_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_evidence_artifacts_storage_key
ON evidence_locker.evidence_artifacts (tenant_id, storage_key);
ALTER TABLE evidence_locker.evidence_artifacts
ENABLE ROW LEVEL SECURITY;
CREATE POLICY IF NOT EXISTS evidence_artifacts_isolation
ON evidence_locker.evidence_artifacts
USING (tenant_id = evidence_locker_app.require_current_tenant())
WITH CHECK (tenant_id = evidence_locker_app.require_current_tenant());
CREATE TABLE IF NOT EXISTS evidence_locker.evidence_holds
(
hold_id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
bundle_id uuid,
case_id text NOT NULL,
reason text NOT NULL,
notes text,
created_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
expires_at timestamptz,
released_at timestamptz,
CONSTRAINT fk_evidence_holds_bundle
FOREIGN KEY (bundle_id) REFERENCES evidence_locker.evidence_bundles (bundle_id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_evidence_holds_case
ON evidence_locker.evidence_holds (tenant_id, case_id);
ALTER TABLE evidence_locker.evidence_holds
ENABLE ROW LEVEL SECURITY;
CREATE POLICY IF NOT EXISTS evidence_holds_isolation
ON evidence_locker.evidence_holds
USING (tenant_id = evidence_locker_app.require_current_tenant())
WITH CHECK (tenant_id = evidence_locker_app.require_current_tenant());

View File

@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS evidence_locker.evidence_bundle_signatures
(
bundle_id uuid NOT NULL,
tenant_id uuid NOT NULL,
payload_type text NOT NULL,
payload text NOT NULL,
signature text NOT NULL,
key_id text,
algorithm text NOT NULL,
provider text NOT NULL,
signed_at timestamptz NOT NULL,
timestamped_at timestamptz,
timestamp_authority text,
timestamp_token bytea,
PRIMARY KEY (bundle_id, tenant_id),
CONSTRAINT fk_evidence_bundle_signatures_bundle
FOREIGN KEY (bundle_id) REFERENCES evidence_locker.evidence_bundles (bundle_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_evidence_bundle_signatures_signed_at
ON evidence_locker.evidence_bundle_signatures (tenant_id, signed_at DESC);

View File

@@ -0,0 +1,10 @@
-- 003_portable_bundles.sql
-- Adds portable evidence bundle storage metadata for sealed-mode packaging.
ALTER TABLE evidence_locker.evidence_bundles
ADD COLUMN IF NOT EXISTS portable_storage_key text,
ADD COLUMN IF NOT EXISTS portable_generated_at timestamptz;
CREATE UNIQUE INDEX IF NOT EXISTS uq_evidence_bundles_portable_storage_key
ON evidence_locker.evidence_bundles (tenant_id, portable_storage_key)
WHERE portable_storage_key IS NOT NULL;

View File

@@ -0,0 +1,201 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using Amazon;
using Amazon.S3;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Cryptography.Plugin.BouncyCastle;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Incident;
using StellaOps.EvidenceLocker.Core.Notifications;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Signing;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Core.Timeline;
using StellaOps.EvidenceLocker.Infrastructure.Builders;
using StellaOps.EvidenceLocker.Infrastructure.Db;
using StellaOps.EvidenceLocker.Infrastructure.Repositories;
using StellaOps.EvidenceLocker.Infrastructure.Services;
using StellaOps.EvidenceLocker.Infrastructure.Signing;
using StellaOps.EvidenceLocker.Infrastructure.Storage;
using StellaOps.EvidenceLocker.Infrastructure.Timeline;
namespace StellaOps.EvidenceLocker.Infrastructure.DependencyInjection;
public static class EvidenceLockerInfrastructureServiceCollectionExtensions
{
public static IServiceCollection AddEvidenceLockerInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services
.AddOptions<EvidenceLockerOptions>()
.Bind(configuration.GetSection(EvidenceLockerOptions.SectionName))
.ValidateDataAnnotations()
.Validate(static options => options.Signing is not null, "Signing options must be provided.")
.Validate(static options => ValidateObjectStore(options.ObjectStore), "Invalid object-store configuration.")
.Validate(static options => ValidateTimeline(options.Timeline), "Invalid timeline configuration.")
.Validate(static options => ValidateIncident(options.Incident), "Invalid incident configuration.")
.Validate(static options => ValidatePortable(options.Portable), "Invalid portable configuration.");
services.AddStellaOpsCrypto();
services.AddBouncyCastleEd25519Provider();
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton(provider =>
{
var options = provider.GetRequiredService<IOptions<EvidenceLockerOptions>>().Value;
var logger = provider.GetRequiredService<ILogger<EvidenceLockerDataSource>>();
return new EvidenceLockerDataSource(options.Database, logger);
});
services.AddSingleton<IEvidenceLockerMigrationRunner, EvidenceLockerMigrationRunner>();
services.AddHostedService<EvidenceLockerMigrationHostedService>();
services.AddSingleton<IMerkleTreeCalculator, MerkleTreeCalculator>();
services.AddScoped<IEvidenceBundleBuilder, EvidenceBundleBuilder>();
services.AddScoped<IEvidenceBundleRepository, EvidenceBundleRepository>();
services.AddSingleton<NullEvidenceTimelinePublisher>();
services.AddHttpClient<TimelineIndexerEvidenceTimelinePublisher>((provider, client) =>
{
var timeline = provider.GetRequiredService<IOptions<EvidenceLockerOptions>>().Value.Timeline!;
client.BaseAddress = new Uri(timeline.Endpoint!, UriKind.Absolute);
client.Timeout = TimeSpan.FromSeconds(timeline.RequestTimeoutSeconds);
var auth = timeline.Authentication;
if (auth?.Token is { Length: > 0 })
{
if (string.Equals(auth.HeaderName, "Authorization", StringComparison.OrdinalIgnoreCase))
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(auth.Scheme, auth.Token);
}
else
{
var value = string.IsNullOrWhiteSpace(auth.Scheme)
? auth.Token
: $"{auth.Scheme} {auth.Token}";
client.DefaultRequestHeaders.Remove(auth.HeaderName);
client.DefaultRequestHeaders.Add(auth.HeaderName, value);
}
}
})
.ConfigurePrimaryHttpMessageHandler(static () => new SocketsHttpHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.All
});
services.AddSingleton<IEvidenceTimelinePublisher>(provider =>
{
var options = provider.GetRequiredService<IOptions<EvidenceLockerOptions>>().Value;
if (options.Timeline?.Enabled is true)
{
return provider.GetRequiredService<TimelineIndexerEvidenceTimelinePublisher>();
}
return provider.GetRequiredService<NullEvidenceTimelinePublisher>();
});
services.TryAddSingleton<IEvidenceIncidentNotifier, NullEvidenceIncidentNotifier>();
services.AddSingleton<IncidentModeManager>();
services.AddSingleton<IIncidentModeState>(provider => provider.GetRequiredService<IncidentModeManager>());
services.TryAddSingleton<ITimestampAuthorityClient, NullTimestampAuthorityClient>();
services.AddScoped<IEvidenceSignatureService, EvidenceSignatureService>();
services.AddScoped<EvidenceSnapshotService>();
services.AddScoped<EvidenceBundlePackagingService>();
services.AddScoped<EvidencePortableBundleService>();
services.AddSingleton<IEvidenceObjectStore>(provider =>
{
var options = provider.GetRequiredService<IOptions<EvidenceLockerOptions>>().Value;
var enforceWriteOnce = options.ObjectStore.EnforceWriteOnce;
return options.ObjectStore.Kind switch
{
ObjectStoreKind.FileSystem => CreateFileSystemStore(
options.ObjectStore.FileSystem!,
enforceWriteOnce,
provider.GetRequiredService<ILogger<FileSystemEvidenceObjectStore>>()),
ObjectStoreKind.AmazonS3 => CreateS3Store(
options.ObjectStore.AmazonS3!,
enforceWriteOnce,
provider.GetRequiredService<ILogger<S3EvidenceObjectStore>>()),
_ => throw new InvalidOperationException($"Unsupported object-store kind '{options.ObjectStore.Kind}'.")
};
});
return services;
}
private static bool ValidateObjectStore(ObjectStoreOptions options)
{
return options.Kind switch
{
ObjectStoreKind.FileSystem => options.FileSystem is not null &&
!string.IsNullOrWhiteSpace(options.FileSystem.RootPath),
ObjectStoreKind.AmazonS3 => options.AmazonS3 is not null &&
!string.IsNullOrWhiteSpace(options.AmazonS3.BucketName) &&
!string.IsNullOrWhiteSpace(options.AmazonS3.Region),
_ => false
};
}
private static bool ValidateTimeline(TimelineOptions? options)
{
if (options is null || !options.Enabled)
{
return true;
}
return !string.IsNullOrWhiteSpace(options.Endpoint);
}
private static bool ValidateIncident(IncidentModeOptions? options)
{
if (options is null || !options.Enabled)
{
return true;
}
return options.RetentionExtensionDays >= 1;
}
private static bool ValidatePortable(PortableOptions? options)
{
if (options is null)
{
return true;
}
return !string.IsNullOrWhiteSpace(options.ArtifactName)
&& !string.IsNullOrWhiteSpace(options.MetadataFileName)
&& !string.IsNullOrWhiteSpace(options.InstructionsFileName)
&& !string.IsNullOrWhiteSpace(options.OfflineScriptFileName);
}
private static IEvidenceObjectStore CreateFileSystemStore(
FileSystemStoreOptions options,
bool enforceWriteOnce,
ILogger<FileSystemEvidenceObjectStore> logger)
=> new FileSystemEvidenceObjectStore(options, enforceWriteOnce, logger);
private static IEvidenceObjectStore CreateS3Store(
AmazonS3StoreOptions options,
bool enforceWriteOnce,
ILogger<S3EvidenceObjectStore> logger)
{
var region = RegionEndpoint.GetBySystemName(options.Region);
var client = new AmazonS3Client(region);
return new S3EvidenceObjectStore(client, options, enforceWriteOnce, logger);
}
}

View File

@@ -0,0 +1,30 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Infrastructure.Db;
namespace StellaOps.EvidenceLocker.Infrastructure.DependencyInjection;
internal sealed class EvidenceLockerMigrationHostedService(
IEvidenceLockerMigrationRunner migrationRunner,
IOptions<EvidenceLockerOptions> options,
ILogger<EvidenceLockerMigrationHostedService> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
if (!options.Value.Database.ApplyMigrationsAtStartup)
{
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation("Evidence Locker migrations skipped (ApplyMigrationsAtStartup = false).");
}
return;
}
await migrationRunner.ApplyAsync(cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -0,0 +1,384 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Npgsql;
using NpgsqlTypes;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Infrastructure.Db;
namespace StellaOps.EvidenceLocker.Infrastructure.Repositories;
internal sealed class EvidenceBundleRepository(EvidenceLockerDataSource dataSource) : IEvidenceBundleRepository
{
private const string InsertBundleSql = """
INSERT INTO evidence_locker.evidence_bundles
(bundle_id, tenant_id, kind, status, root_hash, storage_key, description, created_at, updated_at)
VALUES
(@bundle_id, @tenant_id, @kind, @status, @root_hash, @storage_key, @description, @created_at, @updated_at);
""";
private const string UpdateBundleSql = """
UPDATE evidence_locker.evidence_bundles
SET status = @status,
root_hash = @root_hash,
updated_at = @updated_at
WHERE bundle_id = @bundle_id
AND tenant_id = @tenant_id;
""";
private const string MarkBundleSealedSql = """
UPDATE evidence_locker.evidence_bundles
SET status = @status,
sealed_at = @sealed_at,
updated_at = @sealed_at
WHERE bundle_id = @bundle_id
AND tenant_id = @tenant_id;
""";
private const string UpsertSignatureSql = """
INSERT INTO evidence_locker.evidence_bundle_signatures
(bundle_id, tenant_id, payload_type, payload, signature, key_id, algorithm, provider, signed_at, timestamped_at, timestamp_authority, timestamp_token)
VALUES
(@bundle_id, @tenant_id, @payload_type, @payload, @signature, @key_id, @algorithm, @provider, @signed_at, @timestamped_at, @timestamp_authority, @timestamp_token)
ON CONFLICT (bundle_id, tenant_id)
DO UPDATE SET
payload_type = EXCLUDED.payload_type,
payload = EXCLUDED.payload,
signature = EXCLUDED.signature,
key_id = EXCLUDED.key_id,
algorithm = EXCLUDED.algorithm,
provider = EXCLUDED.provider,
signed_at = EXCLUDED.signed_at,
timestamped_at = EXCLUDED.timestamped_at,
timestamp_authority = EXCLUDED.timestamp_authority,
timestamp_token = EXCLUDED.timestamp_token;
""";
private const string SelectBundleSql = """
SELECT b.bundle_id, b.tenant_id, b.kind, b.status, b.root_hash, b.storage_key, b.description, b.sealed_at, b.created_at, b.updated_at, b.expires_at,
b.portable_storage_key, b.portable_generated_at,
s.payload_type, s.payload, s.signature, s.key_id, s.algorithm, s.provider, s.signed_at, s.timestamped_at, s.timestamp_authority, s.timestamp_token
FROM evidence_locker.evidence_bundles b
LEFT JOIN evidence_locker.evidence_bundle_signatures s
ON s.bundle_id = b.bundle_id AND s.tenant_id = b.tenant_id
WHERE b.bundle_id = @bundle_id AND b.tenant_id = @tenant_id;
""";
private const string ExistsSql = """
SELECT 1
FROM evidence_locker.evidence_bundles
WHERE bundle_id = @bundle_id AND tenant_id = @tenant_id;
""";
private const string InsertHoldSql = """
INSERT INTO evidence_locker.evidence_holds
(hold_id, tenant_id, bundle_id, case_id, reason, notes, created_at, expires_at)
VALUES
(@hold_id, @tenant_id, @bundle_id, @case_id, @reason, @notes, @created_at, @expires_at)
RETURNING hold_id, tenant_id, bundle_id, case_id, reason, notes, created_at, expires_at, released_at;
""";
private const string ExtendRetentionSql = """
UPDATE evidence_locker.evidence_bundles
SET expires_at = CASE
WHEN @hold_expires_at IS NULL THEN NULL
WHEN expires_at IS NULL THEN @hold_expires_at
WHEN expires_at < @hold_expires_at THEN @hold_expires_at
ELSE expires_at
END,
updated_at = GREATEST(updated_at, @processed_at)
WHERE bundle_id = @bundle_id
AND tenant_id = @tenant_id;
""";
private const string UpdateStorageKeySql = """
UPDATE evidence_locker.evidence_bundles
SET storage_key = @storage_key,
updated_at = NOW() AT TIME ZONE 'UTC'
WHERE bundle_id = @bundle_id
AND tenant_id = @tenant_id;
""";
private const string UpdatePortableStorageKeySql = """
UPDATE evidence_locker.evidence_bundles
SET portable_storage_key = @storage_key,
portable_generated_at = @generated_at,
updated_at = GREATEST(updated_at, @generated_at)
WHERE bundle_id = @bundle_id
AND tenant_id = @tenant_id;
""";
public async Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(bundle.TenantId, cancellationToken);
await using var command = new NpgsqlCommand(InsertBundleSql, connection);
command.Parameters.AddWithValue("bundle_id", bundle.Id.Value);
command.Parameters.AddWithValue("tenant_id", bundle.TenantId.Value);
command.Parameters.AddWithValue("kind", (int)bundle.Kind);
command.Parameters.AddWithValue("status", (int)bundle.Status);
command.Parameters.AddWithValue("root_hash", bundle.RootHash);
command.Parameters.AddWithValue("storage_key", bundle.StorageKey);
command.Parameters.AddWithValue("description", (object?)bundle.Description ?? DBNull.Value);
command.Parameters.AddWithValue("created_at", bundle.CreatedAt.UtcDateTime);
command.Parameters.AddWithValue("updated_at", bundle.UpdatedAt.UtcDateTime);
await command.ExecuteNonQueryAsync(cancellationToken);
}
public async Task SetBundleAssemblyAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
EvidenceBundleStatus status,
string rootHash,
DateTimeOffset updatedAt,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var command = new NpgsqlCommand(UpdateBundleSql, connection);
command.Parameters.AddWithValue("status", (int)status);
command.Parameters.AddWithValue("root_hash", rootHash);
command.Parameters.AddWithValue("updated_at", updatedAt.UtcDateTime);
command.Parameters.AddWithValue("bundle_id", bundleId.Value);
command.Parameters.AddWithValue("tenant_id", tenantId.Value);
var affected = await command.ExecuteNonQueryAsync(cancellationToken);
if (affected == 0)
{
throw new InvalidOperationException("Evidence bundle record not found for update.");
}
}
public async Task MarkBundleSealedAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
EvidenceBundleStatus status,
DateTimeOffset sealedAt,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var command = new NpgsqlCommand(MarkBundleSealedSql, connection);
command.Parameters.AddWithValue("status", (int)status);
command.Parameters.AddWithValue("sealed_at", sealedAt.UtcDateTime);
command.Parameters.AddWithValue("bundle_id", bundleId.Value);
command.Parameters.AddWithValue("tenant_id", tenantId.Value);
var affected = await command.ExecuteNonQueryAsync(cancellationToken);
if (affected == 0)
{
throw new InvalidOperationException("Evidence bundle record not found for sealing.");
}
}
public async Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(signature.TenantId, cancellationToken);
await using var command = new NpgsqlCommand(UpsertSignatureSql, connection);
command.Parameters.AddWithValue("bundle_id", signature.BundleId.Value);
command.Parameters.AddWithValue("tenant_id", signature.TenantId.Value);
command.Parameters.AddWithValue("payload_type", signature.PayloadType);
command.Parameters.AddWithValue("payload", signature.Payload);
command.Parameters.AddWithValue("signature", signature.Signature);
command.Parameters.AddWithValue("key_id", (object?)signature.KeyId ?? DBNull.Value);
command.Parameters.AddWithValue("algorithm", signature.Algorithm);
command.Parameters.AddWithValue("provider", signature.Provider);
command.Parameters.AddWithValue("signed_at", signature.SignedAt.UtcDateTime);
command.Parameters.AddWithValue("timestamped_at", signature.TimestampedAt?.UtcDateTime ?? (object)DBNull.Value);
command.Parameters.AddWithValue("timestamp_authority", (object?)signature.TimestampAuthority ?? DBNull.Value);
var timestampTokenParameter = command.Parameters.Add("timestamp_token", NpgsqlDbType.Bytea);
timestampTokenParameter.Value = signature.TimestampToken ?? (object)DBNull.Value;
await command.ExecuteNonQueryAsync(cancellationToken);
}
public async Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var command = new NpgsqlCommand(SelectBundleSql, connection);
command.Parameters.AddWithValue("bundle_id", bundleId.Value);
command.Parameters.AddWithValue("tenant_id", tenantId.Value);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
var createdAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(8), DateTimeKind.Utc));
var updatedAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(9), DateTimeKind.Utc));
DateTimeOffset? sealedAt = null;
if (!reader.IsDBNull(7))
{
sealedAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(7), DateTimeKind.Utc));
}
DateTimeOffset? expiresAt = null;
if (!reader.IsDBNull(10))
{
expiresAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(10), DateTimeKind.Utc));
}
var portableStorageKey = reader.IsDBNull(11) ? null : reader.GetString(11);
DateTimeOffset? portableGeneratedAt = null;
if (!reader.IsDBNull(12))
{
portableGeneratedAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(12), DateTimeKind.Utc));
}
EvidenceBundleSignature? signature = null;
if (!reader.IsDBNull(13))
{
var signedAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(19), DateTimeKind.Utc));
DateTimeOffset? timestampedAt = null;
if (!reader.IsDBNull(20))
{
timestampedAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(20), DateTimeKind.Utc));
}
byte[]? timestampToken = null;
if (!reader.IsDBNull(22))
{
timestampToken = (byte[])reader[22];
}
signature = new EvidenceBundleSignature(
EvidenceBundleId.FromGuid(reader.GetGuid(0)),
TenantId.FromGuid(reader.GetGuid(1)),
reader.GetString(13),
reader.GetString(14),
reader.GetString(15),
reader.IsDBNull(16) ? null : reader.GetString(16),
reader.GetString(17),
reader.GetString(18),
signedAt,
timestampedAt,
reader.IsDBNull(21) ? null : reader.GetString(21),
timestampToken);
}
var bundle = new EvidenceBundle(
bundleId,
tenantId,
(EvidenceBundleKind)reader.GetInt16(2),
(EvidenceBundleStatus)reader.GetInt16(3),
reader.GetString(4),
reader.GetString(5),
createdAt,
updatedAt,
reader.IsDBNull(6) ? null : reader.GetString(6),
sealedAt,
expiresAt,
portableStorageKey,
portableGeneratedAt);
return new EvidenceBundleDetails(bundle, signature);
}
public async Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var command = new NpgsqlCommand(ExistsSql, connection);
command.Parameters.AddWithValue("bundle_id", bundleId.Value);
command.Parameters.AddWithValue("tenant_id", tenantId.Value);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken);
}
public async Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(hold.TenantId, cancellationToken);
await using var command = new NpgsqlCommand(InsertHoldSql, connection);
command.Parameters.AddWithValue("hold_id", hold.Id.Value);
command.Parameters.AddWithValue("tenant_id", hold.TenantId.Value);
command.Parameters.AddWithValue("bundle_id", hold.BundleId?.Value ?? (object)DBNull.Value);
command.Parameters.AddWithValue("case_id", hold.CaseId);
command.Parameters.AddWithValue("reason", hold.Reason);
command.Parameters.AddWithValue("notes", hold.Notes ?? (object)DBNull.Value);
command.Parameters.AddWithValue("created_at", hold.CreatedAt.UtcDateTime);
command.Parameters.AddWithValue("expires_at", hold.ExpiresAt?.UtcDateTime ?? (object)DBNull.Value);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
await reader.ReadAsync(cancellationToken);
var holdId = EvidenceHoldId.FromGuid(reader.GetGuid(0));
var tenantId = TenantId.FromGuid(reader.GetGuid(1));
EvidenceBundleId? bundleId = null;
if (!reader.IsDBNull(2))
{
bundleId = EvidenceBundleId.FromGuid(reader.GetGuid(2));
}
var createdAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(6), DateTimeKind.Utc));
DateTimeOffset? expiresAt = null;
if (!reader.IsDBNull(7))
{
expiresAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(7), DateTimeKind.Utc));
}
DateTimeOffset? releasedAt = null;
if (!reader.IsDBNull(8))
{
releasedAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(8), DateTimeKind.Utc));
}
return new EvidenceHold(
holdId,
tenantId,
bundleId,
reader.GetString(3),
reader.GetString(4),
createdAt,
expiresAt,
releasedAt,
reader.IsDBNull(5) ? null : reader.GetString(5));
}
public async Task ExtendBundleRetentionAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
DateTimeOffset? holdExpiresAt,
DateTimeOffset processedAt,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var command = new NpgsqlCommand(ExtendRetentionSql, connection);
command.Parameters.AddWithValue("bundle_id", bundleId.Value);
command.Parameters.AddWithValue("tenant_id", tenantId.Value);
command.Parameters.AddWithValue("processed_at", processedAt.UtcDateTime);
command.Parameters.AddWithValue("hold_expires_at", holdExpiresAt?.UtcDateTime ?? (object)DBNull.Value);
await command.ExecuteNonQueryAsync(cancellationToken);
}
public async Task UpdateStorageKeyAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
string storageKey,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var command = new NpgsqlCommand(UpdateStorageKeySql, connection);
command.Parameters.AddWithValue("bundle_id", bundleId.Value);
command.Parameters.AddWithValue("tenant_id", tenantId.Value);
command.Parameters.AddWithValue("storage_key", storageKey);
await command.ExecuteNonQueryAsync(cancellationToken);
}
public async Task UpdatePortableStorageKeyAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
string storageKey,
DateTimeOffset generatedAt,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(tenantId, cancellationToken);
await using var command = new NpgsqlCommand(UpdatePortableStorageKeySql, connection);
command.Parameters.AddWithValue("bundle_id", bundleId.Value);
command.Parameters.AddWithValue("tenant_id", tenantId.Value);
command.Parameters.AddWithValue("storage_key", storageKey);
command.Parameters.AddWithValue("generated_at", generatedAt.UtcDateTime);
await command.ExecuteNonQueryAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,313 @@
using System.Buffers.Binary;
using System.Formats.Tar;
using System.IO;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Storage;
namespace StellaOps.EvidenceLocker.Infrastructure.Services;
public sealed class EvidenceBundlePackagingService
{
private static readonly DateTimeOffset FixedTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly IEvidenceBundleRepository _repository;
private readonly IEvidenceObjectStore _objectStore;
private readonly ILogger<EvidenceBundlePackagingService> _logger;
public EvidenceBundlePackagingService(
IEvidenceBundleRepository repository,
IEvidenceObjectStore objectStore,
ILogger<EvidenceBundlePackagingService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<EvidenceBundlePackageResult> EnsurePackageAsync(
TenantId tenantId,
EvidenceBundleId bundleId,
CancellationToken cancellationToken)
{
if (tenantId.Value == Guid.Empty)
{
throw new ArgumentException("Tenant identifier cannot be empty.", nameof(tenantId));
}
if (bundleId.Value == Guid.Empty)
{
throw new ArgumentException("Bundle identifier cannot be empty.", nameof(bundleId));
}
var details = await _repository.GetBundleAsync(bundleId, tenantId, cancellationToken)
.ConfigureAwait(false)
?? throw new InvalidOperationException($"Evidence bundle '{bundleId.Value:D}' not found for tenant '{tenantId.Value:D}'.");
if (details.Bundle.Status != EvidenceBundleStatus.Sealed)
{
throw new InvalidOperationException("Evidence bundle must be sealed before packaging.");
}
if (string.IsNullOrWhiteSpace(details.Bundle.StorageKey))
{
throw new InvalidOperationException("Evidence bundle storage key is not set.");
}
if (await _objectStore.ExistsAsync(details.Bundle.StorageKey, cancellationToken).ConfigureAwait(false))
{
return new EvidenceBundlePackageResult(details.Bundle.StorageKey, details.Bundle.RootHash, Created: false);
}
if (details.Signature is null)
{
throw new InvalidOperationException("Evidence bundle signature is required for packaging.");
}
var manifestDocument = DecodeManifest(details.Signature);
var packageStream = BuildPackageStream(details, manifestDocument);
var metadata = await _objectStore.StoreAsync(
packageStream,
new EvidenceObjectWriteOptions(
tenantId,
bundleId,
"bundle.tgz",
"application/gzip",
EnforceWriteOnce: true),
cancellationToken)
.ConfigureAwait(false);
if (!string.Equals(metadata.StorageKey, details.Bundle.StorageKey, StringComparison.Ordinal))
{
await _repository.UpdateStorageKeyAsync(bundleId, tenantId, metadata.StorageKey, cancellationToken)
.ConfigureAwait(false);
}
_logger.LogInformation(
"Packaged evidence bundle {BundleId} for tenant {TenantId} at storage key {StorageKey}.",
bundleId.Value,
tenantId.Value,
metadata.StorageKey);
return new EvidenceBundlePackageResult(metadata.StorageKey, details.Bundle.RootHash, Created: true);
}
private ManifestDocument DecodeManifest(EvidenceBundleSignature signature)
{
byte[] payload;
try
{
payload = Convert.FromBase64String(signature.Payload);
}
catch (FormatException ex)
{
_logger.LogError(
ex,
"Evidence bundle manifest payload for bundle {BundleId} (tenant {TenantId}) is not valid base64.",
signature.BundleId.Value,
signature.TenantId.Value);
throw new InvalidOperationException("Evidence bundle manifest payload is invalid.", ex);
}
try
{
var document = JsonSerializer.Deserialize<ManifestDocument>(payload, SerializerOptions)
?? throw new InvalidOperationException();
return document;
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
{
_logger.LogError(
ex,
"Evidence bundle manifest payload for bundle {BundleId} (tenant {TenantId}) could not be parsed.",
signature.BundleId.Value,
signature.TenantId.Value);
throw new InvalidOperationException("Evidence bundle manifest payload is invalid.", ex);
}
}
private static Stream BuildPackageStream(EvidenceBundleDetails details, ManifestDocument manifest)
{
var stream = new MemoryStream();
using (var gzip = new GZipStream(stream, CompressionLevel.SmallestSize, leaveOpen: true))
using (var tarWriter = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true))
{
WriteTextEntry(tarWriter, "manifest.json", GetManifestJson(details.Signature!));
WriteTextEntry(tarWriter, "signature.json", GetSignatureJson(details.Signature!));
WriteTextEntry(tarWriter, "bundle.json", GetBundleMetadataJson(details));
WriteTextEntry(tarWriter, "checksums.txt", BuildChecksums(manifest, details.Bundle.RootHash));
WriteTextEntry(tarWriter, "instructions.txt", BuildInstructions(details, manifest));
}
ApplyDeterministicGZipHeader(stream);
stream.Position = 0;
return stream;
}
private static void WriteTextEntry(TarWriter writer, string path, string content)
{
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
{
Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead,
ModificationTime = FixedTimestamp
};
var bytes = Encoding.UTF8.GetBytes(content);
entry.DataStream = new MemoryStream(bytes);
writer.WriteEntry(entry);
}
private static string GetManifestJson(EvidenceBundleSignature signature)
{
var json = Encoding.UTF8.GetString(Convert.FromBase64String(signature.Payload));
using var document = JsonDocument.Parse(json);
return JsonSerializer.Serialize(document.RootElement, SerializerOptions);
}
private static string GetSignatureJson(EvidenceBundleSignature signature)
{
var model = new SignatureDocument(
signature.PayloadType,
signature.Payload,
signature.Signature,
signature.KeyId,
signature.Algorithm,
signature.Provider,
signature.SignedAt,
signature.TimestampedAt,
signature.TimestampAuthority,
signature.TimestampToken is null ? null : Convert.ToBase64String(signature.TimestampToken));
return JsonSerializer.Serialize(model, SerializerOptions);
}
private static string GetBundleMetadataJson(EvidenceBundleDetails details)
{
var document = new BundleMetadataDocument(
details.Bundle.Id.Value,
details.Bundle.TenantId.Value,
details.Bundle.Kind,
details.Bundle.Status,
details.Bundle.RootHash,
details.Bundle.StorageKey,
details.Bundle.CreatedAt,
details.Bundle.SealedAt);
return JsonSerializer.Serialize(document, SerializerOptions);
}
private static string BuildChecksums(ManifestDocument manifest, string rootHash)
{
var builder = new StringBuilder();
builder.AppendLine("# Evidence bundle checksums (sha256)");
builder.Append("root ").AppendLine(rootHash);
var entries = manifest.Entries ?? Array.Empty<ManifestEntryDocument>();
foreach (var entry in entries.OrderBy(e => e.CanonicalPath, StringComparer.Ordinal))
{
builder.Append(entry.Sha256)
.Append(" ")
.AppendLine(entry.CanonicalPath);
}
return builder.ToString();
}
private static string BuildInstructions(EvidenceBundleDetails details, ManifestDocument manifest)
{
var builder = new StringBuilder();
builder.AppendLine("Evidence Bundle Instructions");
builder.AppendLine("============================");
builder.Append("Bundle ID: ").AppendLine(details.Bundle.Id.Value.ToString("D"));
builder.Append("Root Hash: ").AppendLine(details.Bundle.RootHash);
builder.Append("Created At: ").AppendLine(manifest.CreatedAt.ToString("O"));
if (details.Signature?.TimestampedAt is { } timestampedAt)
{
builder.Append("Timestamped At: ").AppendLine(timestampedAt.ToString("O"));
}
builder.AppendLine();
builder.AppendLine("Verification steps:");
builder.AppendLine("1. Inspect `manifest.json` and ensure the bundle contents match expectations.");
builder.AppendLine("2. Compute the Merkle root using the manifest entries and compare with the Root Hash above.");
builder.AppendLine("3. Validate `signature.json` using the StellaOps provenance verifier (`stella evidence verify <bundle.tgz>`).");
if (details.Signature?.TimestampToken is not null)
{
builder.AppendLine("4. Validate the RFC3161 timestamp token with your configured TSA before trusting the bundle.");
builder.AppendLine("5. Review `checksums.txt` when transferring the bundle between systems.");
}
else
{
builder.AppendLine("4. Review `checksums.txt` when transferring the bundle between systems.");
}
builder.AppendLine();
builder.AppendLine("For offline verification guidance, consult docs/forensics/evidence-locker.md (portable evidence section).");
return builder.ToString();
}
private static void ApplyDeterministicGZipHeader(MemoryStream stream)
{
if (stream.Length < 10)
{
throw new InvalidOperationException("GZip header not fully written for evidence bundle package.");
}
var seconds = checked((int)(FixedTimestamp - DateTimeOffset.UnixEpoch).TotalSeconds);
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteInt32LittleEndian(buffer, seconds);
var originalPosition = stream.Position;
stream.Position = 4;
stream.Write(buffer);
stream.Position = originalPosition;
}
private sealed record ManifestDocument(
Guid BundleId,
Guid TenantId,
int Kind,
DateTimeOffset CreatedAt,
IDictionary<string, string>? Metadata,
ManifestEntryDocument[]? Entries);
private sealed record ManifestEntryDocument(
string Section,
string CanonicalPath,
string Sha256,
long SizeBytes,
string? MediaType,
IDictionary<string, string>? Attributes);
private sealed record SignatureDocument(
string PayloadType,
string Payload,
string Signature,
string? KeyId,
string Algorithm,
string Provider,
DateTimeOffset SignedAt,
DateTimeOffset? TimestampedAt,
string? TimestampAuthority,
string? TimestampToken);
private sealed record BundleMetadataDocument(
Guid BundleId,
Guid TenantId,
EvidenceBundleKind Kind,
EvidenceBundleStatus Status,
string RootHash,
string StorageKey,
DateTimeOffset CreatedAt,
DateTimeOffset? SealedAt);
}
public sealed record EvidenceBundlePackageResult(string StorageKey, string RootHash, bool Created);

View File

@@ -0,0 +1,414 @@
using System.Buffers.Binary;
using System.Collections.ObjectModel;
using System.Formats.Tar;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Storage;
namespace StellaOps.EvidenceLocker.Infrastructure.Services;
public sealed class EvidencePortableBundleService
{
private const string PortableManifestFileName = "manifest.json";
private const string PortableSignatureFileName = "signature.json";
private const string PortableChecksumsFileName = "checksums.txt";
private static readonly DateTimeOffset FixedTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private static readonly UnixFileMode DefaultFileMode =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
private static readonly UnixFileMode ExecutableFileMode =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
private readonly IEvidenceBundleRepository _repository;
private readonly IEvidenceObjectStore _objectStore;
private readonly ILogger<EvidencePortableBundleService> _logger;
private readonly PortableOptions _options;
private readonly TimeProvider _timeProvider;
public EvidencePortableBundleService(
IEvidenceBundleRepository repository,
IEvidenceObjectStore objectStore,
IOptions<EvidenceLockerOptions> options,
TimeProvider timeProvider,
ILogger<EvidencePortableBundleService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
_timeProvider = timeProvider ?? TimeProvider.System;
ArgumentNullException.ThrowIfNull(options);
_options = options.Value.Portable ?? new PortableOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<EvidenceBundlePackageResult> EnsurePortablePackageAsync(
TenantId tenantId,
EvidenceBundleId bundleId,
CancellationToken cancellationToken)
{
if (tenantId.Value == Guid.Empty)
{
throw new ArgumentException("Tenant identifier cannot be empty.", nameof(tenantId));
}
if (bundleId.Value == Guid.Empty)
{
throw new ArgumentException("Bundle identifier cannot be empty.", nameof(bundleId));
}
if (!_options.Enabled)
{
throw new InvalidOperationException("Portable bundle packaging is disabled for this deployment.");
}
var details = await _repository
.GetBundleAsync(bundleId, tenantId, cancellationToken)
.ConfigureAwait(false)
?? throw new InvalidOperationException($"Evidence bundle '{bundleId.Value:D}' not found for tenant '{tenantId.Value:D}'.");
if (details.Bundle.Status != EvidenceBundleStatus.Sealed)
{
throw new InvalidOperationException("Evidence bundle must be sealed before creating a portable package.");
}
if (details.Signature is null)
{
throw new InvalidOperationException("Evidence bundle signature is required for portable packaging.");
}
if (!string.IsNullOrEmpty(details.Bundle.PortableStorageKey)
&& await _objectStore.ExistsAsync(details.Bundle.PortableStorageKey, cancellationToken).ConfigureAwait(false))
{
return new EvidenceBundlePackageResult(details.Bundle.PortableStorageKey!, details.Bundle.RootHash, Created: false);
}
var manifestDocument = DecodeManifest(details.Signature);
var generatedAt = _timeProvider.GetUtcNow();
var packageStream = BuildPackageStream(details, manifestDocument, generatedAt);
var metadata = await _objectStore
.StoreAsync(
packageStream,
new EvidenceObjectWriteOptions(
tenantId,
bundleId,
_options.ArtifactName,
"application/gzip",
EnforceWriteOnce: true),
cancellationToken)
.ConfigureAwait(false);
await _repository
.UpdatePortableStorageKeyAsync(bundleId, tenantId, metadata.StorageKey, generatedAt, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation(
"Portable evidence bundle {BundleId} for tenant {TenantId} stored at {StorageKey}.",
bundleId.Value,
tenantId.Value,
metadata.StorageKey);
return new EvidenceBundlePackageResult(metadata.StorageKey, details.Bundle.RootHash, Created: true);
}
private static Stream BuildPackageStream(
EvidenceBundleDetails details,
ManifestDocument manifest,
DateTimeOffset generatedAt)
{
var stream = new MemoryStream();
using (var gzip = new GZipStream(stream, CompressionLevel.SmallestSize, leaveOpen: true))
using (var tarWriter = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true))
{
WriteTextEntry(tarWriter, PortableManifestFileName, GetManifestJson(details.Signature!));
WriteTextEntry(tarWriter, PortableSignatureFileName, GetSignatureJson(details.Signature!));
WriteTextEntry(tarWriter, PortableChecksumsFileName, BuildChecksums(manifest, details.Bundle.RootHash));
var metadataDocument = BuildPortableMetadata(details, manifest, generatedAt);
WriteTextEntry(
tarWriter,
_options.MetadataFileName,
JsonSerializer.Serialize(metadataDocument, SerializerOptions));
WriteTextEntry(
tarWriter,
_options.InstructionsFileName,
BuildInstructions(details, manifest, generatedAt));
WriteTextEntry(
tarWriter,
_options.OfflineScriptFileName,
BuildOfflineScript(_options.ArtifactName, _options.MetadataFileName),
ExecutableFileMode);
}
ApplyDeterministicGZipHeader(stream);
stream.Position = 0;
return stream;
}
private static ManifestDocument DecodeManifest(EvidenceBundleSignature signature)
{
byte[] payload;
try
{
payload = Convert.FromBase64String(signature.Payload);
}
catch (FormatException ex)
{
throw new InvalidOperationException("Evidence bundle manifest payload is invalid.", ex);
}
try
{
return JsonSerializer.Deserialize<ManifestDocument>(payload, SerializerOptions)
?? throw new InvalidOperationException("Evidence bundle manifest payload is empty.");
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
{
throw new InvalidOperationException("Evidence bundle manifest payload is invalid.", ex);
}
}
private static PortableBundleMetadataDocument BuildPortableMetadata(
EvidenceBundleDetails details,
ManifestDocument manifest,
DateTimeOffset generatedAt)
{
var entries = manifest.Entries ?? Array.Empty<ManifestEntryDocument>();
var entryCount = entries.Length;
var totalSize = entries.Sum(e => e.SizeBytes);
IReadOnlyDictionary<string, string>? incidentMetadata = null;
if (manifest.Metadata is { Count: > 0 })
{
var incidentPairs = manifest.Metadata
.Where(kvp => kvp.Key.StartsWith("incident.", StringComparison.Ordinal))
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal);
if (incidentPairs.Count > 0)
{
incidentMetadata = new ReadOnlyDictionary<string, string>(incidentPairs);
}
}
return new PortableBundleMetadataDocument(
details.Bundle.Id.Value,
details.Bundle.Kind,
details.Bundle.RootHash,
manifest.CreatedAt,
details.Bundle.SealedAt,
details.Bundle.ExpiresAt,
details.Signature?.TimestampedAt is not null,
details.Signature?.TimestampedAt,
generatedAt,
entryCount,
totalSize,
incidentMetadata);
}
private static string BuildInstructions(
EvidenceBundleDetails details,
ManifestDocument manifest,
DateTimeOffset generatedAt)
{
var builder = new StringBuilder();
builder.AppendLine("Portable Evidence Bundle Instructions");
builder.AppendLine("===================================");
builder.Append("Bundle ID: ").AppendLine(details.Bundle.Id.Value.ToString("D"));
builder.Append("Root Hash: ").AppendLine(details.Bundle.RootHash);
builder.Append("Created At: ").AppendLine(manifest.CreatedAt.ToString("O"));
if (details.Bundle.SealedAt is { } sealedAt)
{
builder.Append("Sealed At: ").AppendLine(sealedAt.ToString("O"));
}
if (details.Signature?.TimestampedAt is { } timestampedAt)
{
builder.Append("Timestamped At: ").AppendLine(timestampedAt.ToString("O"));
}
builder.Append("Portable Generated At: ").AppendLine(generatedAt.ToString("O"));
builder.AppendLine();
builder.AppendLine("Verification steps:");
builder.Append("1. Copy '").Append(_options.ArtifactName).AppendLine("' into the sealed environment.");
builder.Append("2. Execute './").Append(_options.OfflineScriptFileName).Append(' ');
builder.Append(_options.ArtifactName).AppendLine("' to extract contents and verify checksums.");
builder.AppendLine("3. Review 'bundle.json' for sanitized metadata and incident context.");
builder.AppendLine("4. Run 'stella evidence verify --bundle <path>' or use an offline verifier with 'manifest.json' + 'signature.json'.");
builder.AppendLine("5. Store the bundle and verification output with the receiving enclave's evidence locker.");
builder.AppendLine();
builder.AppendLine("Notes:");
builder.AppendLine("- Metadata is redacted to remove tenant identifiers, storage coordinates, and free-form descriptions.");
builder.AppendLine("- Incident metadata (if present) is exposed under 'incidentMetadata'.");
builder.AppendLine("- Checksums cover every canonical entry and the Merkle root hash for tamper detection.");
return builder.ToString();
}
private static string BuildOfflineScript(string defaultArchiveName, string metadataFileName)
{
var builder = new StringBuilder();
builder.AppendLine("#!/usr/bin/env sh");
builder.AppendLine("set -euo pipefail");
builder.AppendLine();
builder.AppendLine($"ARCHIVE=\"${{1:-{defaultArchiveName}}}\"");
builder.AppendLine("if [ ! -f \"$ARCHIVE\" ]; then");
builder.AppendLine(" echo \"Usage: $0 <portable-bundle.tgz>\" >&2");
builder.AppendLine(" exit 1");
builder.AppendLine("fi");
builder.AppendLine();
builder.AppendLine("WORKDIR=\"$(mktemp -d)\"");
builder.AppendLine("cleanup() { rm -rf \"$WORKDIR\"; }");
builder.AppendLine("trap cleanup EXIT INT TERM");
builder.AppendLine();
builder.AppendLine("tar -xzf \"$ARCHIVE\" -C \"$WORKDIR\"");
builder.AppendLine("echo \"Portable evidence extracted to $WORKDIR\"");
builder.AppendLine();
builder.AppendLine("if command -v sha256sum >/dev/null 2>&1; then");
builder.AppendLine(" (cd \"$WORKDIR\" && sha256sum --check checksums.txt)");
builder.AppendLine("else");
builder.AppendLine(" (cd \"$WORKDIR\" && shasum -a 256 --check checksums.txt)");
builder.AppendLine("fi");
builder.AppendLine();
builder.AppendLine("ROOT_HASH=$(sed -n 's/.*\"rootHash\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$WORKDIR\"/" + metadataFileName + " | head -n 1)");
builder.AppendLine("echo \"Root hash: ${ROOT_HASH:-unknown}\"");
builder.AppendLine("echo \"Verify DSSE signature with: stella evidence verify --bundle $ARCHIVE\"");
builder.AppendLine("echo \"or provide manifest.json and signature.json to an offline verifier.\"");
builder.AppendLine();
builder.AppendLine("echo \"Leaving extracted contents in $WORKDIR for manual inspection.\"");
return builder.ToString();
}
private static string GetManifestJson(EvidenceBundleSignature signature)
{
var json = Encoding.UTF8.GetString(Convert.FromBase64String(signature.Payload));
using var document = JsonDocument.Parse(json);
return JsonSerializer.Serialize(document.RootElement, SerializerOptions);
}
private static string GetSignatureJson(EvidenceBundleSignature signature)
{
var model = new SignatureDocument(
signature.PayloadType,
signature.Payload,
signature.Signature,
signature.KeyId,
signature.Algorithm,
signature.Provider,
signature.SignedAt,
signature.TimestampedAt,
signature.TimestampAuthority,
signature.TimestampToken is null ? null : Convert.ToBase64String(signature.TimestampToken));
return JsonSerializer.Serialize(model, SerializerOptions);
}
private static string BuildChecksums(ManifestDocument manifest, string rootHash)
{
var builder = new StringBuilder();
builder.AppendLine("# Evidence bundle checksums (sha256)");
builder.Append("root ").AppendLine(rootHash);
var entries = manifest.Entries ?? Array.Empty<ManifestEntryDocument>();
foreach (var entry in entries.OrderBy(e => e.CanonicalPath, StringComparer.Ordinal))
{
builder.Append(entry.Sha256)
.Append(" ")
.AppendLine(entry.CanonicalPath);
}
return builder.ToString();
}
private static void WriteTextEntry(
TarWriter writer,
string path,
string content,
UnixFileMode mode = default)
{
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
{
Mode = mode == default ? DefaultFileMode : mode,
ModificationTime = FixedTimestamp
};
var bytes = Encoding.UTF8.GetBytes(content);
entry.DataStream = new MemoryStream(bytes);
writer.WriteEntry(entry);
}
private static void ApplyDeterministicGZipHeader(MemoryStream stream)
{
if (stream.Length < 10)
{
throw new InvalidOperationException("GZip header not fully written for portable evidence package.");
}
var seconds = checked((int)(FixedTimestamp - DateTimeOffset.UnixEpoch).TotalSeconds);
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteInt32LittleEndian(buffer, seconds);
var originalPosition = stream.Position;
stream.Position = 4;
stream.Write(buffer);
stream.Position = originalPosition;
}
private sealed record ManifestDocument(
Guid BundleId,
Guid TenantId,
int Kind,
DateTimeOffset CreatedAt,
IDictionary<string, string>? Metadata,
ManifestEntryDocument[]? Entries);
private sealed record ManifestEntryDocument(
string Section,
string CanonicalPath,
string Sha256,
long SizeBytes,
string? MediaType,
IDictionary<string, string>? Attributes);
private sealed record SignatureDocument(
string PayloadType,
string Payload,
string Signature,
string? KeyId,
string Algorithm,
string Provider,
DateTimeOffset SignedAt,
DateTimeOffset? TimestampedAt,
string? TimestampAuthority,
string? TimestampToken);
private sealed record PortableBundleMetadataDocument(
Guid BundleId,
EvidenceBundleKind Kind,
string RootHash,
DateTimeOffset CreatedAt,
DateTimeOffset? SealedAt,
DateTimeOffset? ExpiresAt,
bool Timestamped,
DateTimeOffset? TimestampedAt,
DateTimeOffset PortableGeneratedAt,
int EntryCount,
long TotalSizeBytes,
IReadOnlyDictionary<string, string>? IncidentMetadata);
}

View File

@@ -0,0 +1,483 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Signing;
using StellaOps.EvidenceLocker.Core.Incident;
using StellaOps.EvidenceLocker.Core.Timeline;
using StellaOps.EvidenceLocker.Core.Storage;
namespace StellaOps.EvidenceLocker.Infrastructure.Services;
public sealed class EvidenceSnapshotService
{
private static readonly string EmptyRoot = new('0', 64);
private static readonly JsonSerializerOptions IncidentSerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly IEvidenceBundleRepository _repository;
private readonly IEvidenceBundleBuilder _bundleBuilder;
private readonly IEvidenceSignatureService _signatureService;
private readonly IEvidenceTimelinePublisher _timelinePublisher;
private readonly IIncidentModeState _incidentMode;
private readonly IEvidenceObjectStore _objectStore;
private readonly TimeProvider _timeProvider;
private readonly ILogger<EvidenceSnapshotService> _logger;
private readonly QuotaOptions _quotas;
public EvidenceSnapshotService(
IEvidenceBundleRepository repository,
IEvidenceBundleBuilder bundleBuilder,
IEvidenceSignatureService signatureService,
IEvidenceTimelinePublisher timelinePublisher,
IIncidentModeState incidentMode,
IEvidenceObjectStore objectStore,
TimeProvider timeProvider,
IOptions<EvidenceLockerOptions> options,
ILogger<EvidenceSnapshotService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_bundleBuilder = bundleBuilder ?? throw new ArgumentNullException(nameof(bundleBuilder));
_signatureService = signatureService ?? throw new ArgumentNullException(nameof(signatureService));
_timelinePublisher = timelinePublisher ?? throw new ArgumentNullException(nameof(timelinePublisher));
_incidentMode = incidentMode ?? throw new ArgumentNullException(nameof(incidentMode));
_objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
_timeProvider = timeProvider ?? TimeProvider.System;
ArgumentNullException.ThrowIfNull(options);
_quotas = options.Value.Quotas ?? throw new InvalidOperationException("Quota options are required.");
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<EvidenceSnapshotResult> CreateSnapshotAsync(
TenantId tenantId,
EvidenceSnapshotRequest request,
CancellationToken cancellationToken)
{
if (tenantId == default)
{
throw new ArgumentException("Tenant identifier is required.", nameof(tenantId));
}
ArgumentNullException.ThrowIfNull(request);
ValidateRequest(request);
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var createdAt = _timeProvider.GetUtcNow();
var storageKey = $"tenants/{tenantId.Value:N}/bundles/{bundleId.Value:N}/bundle.tgz";
var incidentSnapshot = _incidentMode.Current;
DateTimeOffset? expiresAt = null;
if (incidentSnapshot.IsActive && incidentSnapshot.RetentionExtensionDays > 0)
{
expiresAt = createdAt.AddDays(incidentSnapshot.RetentionExtensionDays);
}
var metadataBuffer = new Dictionary<string, string>(
request.Metadata ?? new Dictionary<string, string>(),
StringComparer.Ordinal);
if (incidentSnapshot.IsActive)
{
metadataBuffer["incident.mode"] = "enabled";
metadataBuffer["incident.changedAt"] = incidentSnapshot.ChangedAt.ToString("O", CultureInfo.InvariantCulture);
metadataBuffer["incident.retentionExtensionDays"] = incidentSnapshot.RetentionExtensionDays.ToString(CultureInfo.InvariantCulture);
}
var normalizedMetadata = NormalizeMetadata(metadataBuffer);
var bundle = new EvidenceBundle(
bundleId,
tenantId,
request.Kind,
EvidenceBundleStatus.Pending,
EmptyRoot,
storageKey,
createdAt,
createdAt,
request.Description,
null,
expiresAt);
await _repository.CreateBundleAsync(bundle, cancellationToken).ConfigureAwait(false);
var normalizedMaterials = request.Materials
.Select(material => new EvidenceBundleMaterial(
material.Section ?? string.Empty,
material.Path ?? string.Empty,
material.Sha256,
material.SizeBytes,
material.MediaType ?? "application/octet-stream",
NormalizeAttributes(material.Attributes)))
.ToList();
if (incidentSnapshot.IsActive &&
incidentSnapshot.CaptureRequestSnapshot &&
normalizedMaterials.Count < _quotas.MaxMaterialCount)
{
var incidentMaterial = await TryCaptureIncidentSnapshotAsync(
tenantId,
bundleId,
incidentSnapshot,
request,
normalizedMetadata,
createdAt,
cancellationToken).ConfigureAwait(false);
if (incidentMaterial is not null)
{
normalizedMaterials.Add(incidentMaterial);
}
}
var buildRequest = new EvidenceBundleBuildRequest(
bundleId,
tenantId,
request.Kind,
createdAt,
normalizedMetadata,
normalizedMaterials);
var buildResult = await _bundleBuilder.BuildAsync(buildRequest, cancellationToken).ConfigureAwait(false);
await _repository.SetBundleAssemblyAsync(
bundleId,
tenantId,
EvidenceBundleStatus.Assembling,
buildResult.RootHash,
createdAt,
cancellationToken).ConfigureAwait(false);
var signature = await _signatureService.SignManifestAsync(
bundleId,
tenantId,
buildResult.Manifest,
cancellationToken).ConfigureAwait(false);
if (signature is not null)
{
await _repository.UpsertSignatureAsync(signature, cancellationToken).ConfigureAwait(false);
await _timelinePublisher.PublishBundleSealedAsync(signature, buildResult.Manifest, buildResult.RootHash, cancellationToken)
.ConfigureAwait(false);
}
var sealedAt = signature?.TimestampedAt ?? signature?.SignedAt ?? _timeProvider.GetUtcNow();
await _repository.MarkBundleSealedAsync(
bundleId,
tenantId,
EvidenceBundleStatus.Sealed,
sealedAt,
cancellationToken).ConfigureAwait(false);
return new EvidenceSnapshotResult(bundleId.Value, buildResult.RootHash, buildResult.Manifest, signature);
}
public Task<EvidenceBundleDetails?> GetBundleAsync(
TenantId tenantId,
EvidenceBundleId bundleId,
CancellationToken cancellationToken)
{
if (tenantId == default)
{
throw new ArgumentException("Tenant identifier is required.", nameof(tenantId));
}
if (bundleId == default)
{
throw new ArgumentException("Bundle identifier is required.", nameof(bundleId));
}
return _repository.GetBundleAsync(bundleId, tenantId, cancellationToken);
}
public async Task<bool> VerifyAsync(
TenantId tenantId,
EvidenceBundleId bundleId,
string expectedRootHash,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(expectedRootHash))
{
throw new ArgumentException("Expected root hash must be provided.", nameof(expectedRootHash));
}
var details = await _repository.GetBundleAsync(bundleId, tenantId, cancellationToken).ConfigureAwait(false);
return details is not null &&
string.Equals(details.Bundle.RootHash, expectedRootHash, StringComparison.OrdinalIgnoreCase);
}
public async Task<EvidenceHold> CreateHoldAsync(
TenantId tenantId,
string caseId,
EvidenceHoldRequest request,
CancellationToken cancellationToken)
{
if (tenantId == default)
{
throw new ArgumentException("Tenant identifier is required.", nameof(tenantId));
}
ArgumentException.ThrowIfNullOrWhiteSpace(caseId);
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.Reason);
EvidenceBundleId? bundleId = null;
if (request.BundleId.HasValue)
{
bundleId = EvidenceBundleId.FromGuid(request.BundleId.Value);
var exists = await _repository.ExistsAsync(bundleId.Value, tenantId, cancellationToken).ConfigureAwait(false);
if (!exists)
{
throw new InvalidOperationException($"Referenced bundle '{bundleId.Value.Value:D}' does not exist for tenant '{tenantId.Value:D}'.");
}
}
var holdId = EvidenceHoldId.FromGuid(Guid.NewGuid());
var createdAt = _timeProvider.GetUtcNow();
var hold = new EvidenceHold(
holdId,
tenantId,
bundleId,
caseId,
request.Reason,
createdAt,
request.ExpiresAt,
null,
request.Notes);
EvidenceHold persisted;
try
{
persisted = await _repository.CreateHoldAsync(hold, cancellationToken).ConfigureAwait(false);
}
catch (PostgresException ex) when (string.Equals(ex.SqlState, PostgresErrorCodes.UniqueViolation, StringComparison.Ordinal))
{
throw new InvalidOperationException($"A hold already exists for case '{caseId}' in tenant '{tenantId.Value:D}'.", ex);
}
if (bundleId.HasValue)
{
await _repository.ExtendBundleRetentionAsync(
bundleId.Value,
tenantId,
request.ExpiresAt,
createdAt,
cancellationToken).ConfigureAwait(false);
}
await _timelinePublisher.PublishHoldCreatedAsync(persisted, cancellationToken).ConfigureAwait(false);
return persisted;
}
private void ValidateRequest(EvidenceSnapshotRequest request)
{
if (!Enum.IsDefined(typeof(EvidenceBundleKind), request.Kind))
{
throw new InvalidOperationException($"Unsupported evidence bundle kind '{request.Kind}'.");
}
var metadataCount = request.Metadata?.Count ?? 0;
if (metadataCount > _quotas.MaxMetadataEntries)
{
throw new InvalidOperationException($"Metadata entry count {metadataCount} exceeds limit of {_quotas.MaxMetadataEntries}.");
}
if (request.Materials is null || request.Materials.Count == 0)
{
throw new InvalidOperationException("At least one material must be supplied for an evidence snapshot.");
}
if (request.Materials.Count > _quotas.MaxMaterialCount)
{
throw new InvalidOperationException($"Material count {request.Materials.Count} exceeds limit of {_quotas.MaxMaterialCount}.");
}
long totalSizeBytes = 0;
foreach (var entry in request.Metadata ?? new Dictionary<string, string>())
{
ValidateMetadata(entry.Key, entry.Value);
}
foreach (var material in request.Materials)
{
ValidateMaterial(material);
totalSizeBytes = checked(totalSizeBytes + material.SizeBytes);
if (totalSizeBytes > _quotas.MaxTotalMaterialSizeBytes)
{
throw new InvalidOperationException($"Material size total {totalSizeBytes} exceeds limit of {_quotas.MaxTotalMaterialSizeBytes} bytes.");
}
}
}
private void ValidateMetadata(string key, string value)
{
if (string.IsNullOrWhiteSpace(key))
{
throw new InvalidOperationException("Metadata keys must be non-empty.");
}
if (key.Length > _quotas.MaxMetadataKeyLength)
{
throw new InvalidOperationException($"Metadata key '{key}' exceeds length limit of {_quotas.MaxMetadataKeyLength} characters.");
}
if (value is null)
{
throw new InvalidOperationException($"Metadata value for key '{key}' must not be null.");
}
if (value.Length > _quotas.MaxMetadataValueLength)
{
throw new InvalidOperationException($"Metadata value for key '{key}' exceeds length limit of {_quotas.MaxMetadataValueLength} characters.");
}
}
private void ValidateMaterial(EvidenceSnapshotMaterial material)
{
if (string.IsNullOrWhiteSpace(material.Sha256))
{
throw new InvalidOperationException("Material SHA-256 digest must be provided.");
}
if (material.Sha256.Length != 64 || !IsHex(material.Sha256))
{
throw new InvalidOperationException($"Material SHA-256 digest '{material.Sha256}' must be 64 hex characters.");
}
if (material.SizeBytes < 0)
{
throw new InvalidOperationException("Material size bytes cannot be negative.");
}
foreach (var attribute in material.Attributes ?? new Dictionary<string, string>())
{
ValidateMetadata(attribute.Key, attribute.Value);
}
}
private static IReadOnlyDictionary<string, string> NormalizeMetadata(IDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal));
}
return new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(metadata, StringComparer.Ordinal));
}
private static IReadOnlyDictionary<string, string> NormalizeAttributes(IDictionary<string, string>? attributes)
{
if (attributes is null || attributes.Count == 0)
{
return new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal));
}
return new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
}
private static bool IsHex(string value)
{
for (var i = 0; i < value.Length; i++)
{
var ch = value[i];
var isHex = ch is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F';
if (!isHex)
{
return false;
}
}
return true;
}
private async Task<EvidenceBundleMaterial?> TryCaptureIncidentSnapshotAsync(
TenantId tenantId,
EvidenceBundleId bundleId,
IncidentModeSnapshot incidentSnapshot,
EvidenceSnapshotRequest request,
IReadOnlyDictionary<string, string> normalizedMetadata,
DateTimeOffset capturedAt,
CancellationToken cancellationToken)
{
try
{
var payload = new
{
capturedAt = capturedAt,
incident = new
{
state = incidentSnapshot.IsActive ? "enabled" : "disabled",
retentionExtensionDays = incidentSnapshot.RetentionExtensionDays
},
request = new
{
kind = request.Kind,
metadata = normalizedMetadata,
materials = request.Materials.Select(material => new
{
section = material.Section,
path = material.Path,
sha256 = material.Sha256,
sizeBytes = material.SizeBytes,
mediaType = material.MediaType,
attributes = material.Attributes
})
}
};
var bytes = JsonSerializer.SerializeToUtf8Bytes(payload, IncidentSerializerOptions);
var artifactFileName = $"request-{capturedAt:yyyyMMddHHmmssfff}.json";
var artifactName = $"incident/{artifactFileName}";
await using var stream = new MemoryStream(bytes);
var metadata = await _objectStore.StoreAsync(
stream,
new EvidenceObjectWriteOptions(
tenantId,
bundleId,
artifactName,
"application/json"),
cancellationToken)
.ConfigureAwait(false);
var attributes = new Dictionary<string, string>(StringComparer.Ordinal)
{
["storageKey"] = metadata.StorageKey
};
return new EvidenceBundleMaterial(
"incident",
artifactFileName,
metadata.Sha256,
metadata.SizeBytes,
metadata.ContentType,
attributes);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to capture incident snapshot for bundle {BundleId}: {Message}",
bundleId.Value,
ex.Message);
return null;
}
}
}

View File

@@ -0,0 +1,134 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Incident;
using StellaOps.EvidenceLocker.Core.Notifications;
using StellaOps.EvidenceLocker.Core.Timeline;
namespace StellaOps.EvidenceLocker.Infrastructure.Services;
internal sealed class IncidentModeManager : IIncidentModeState, IDisposable
{
private readonly IEvidenceTimelinePublisher _timelinePublisher;
private readonly IEvidenceIncidentNotifier _incidentNotifier;
private readonly TimeProvider _timeProvider;
private readonly ILogger<IncidentModeManager> _logger;
private readonly CancellationTokenSource _cts = new();
private readonly IDisposable _subscription = null!;
private IncidentModeSnapshot _current;
public IncidentModeManager(
IOptionsMonitor<EvidenceLockerOptions> optionsMonitor,
IEvidenceTimelinePublisher timelinePublisher,
IEvidenceIncidentNotifier incidentNotifier,
TimeProvider timeProvider,
ILogger<IncidentModeManager> logger)
{
ArgumentNullException.ThrowIfNull(optionsMonitor);
_timelinePublisher = timelinePublisher ?? throw new ArgumentNullException(nameof(timelinePublisher));
_incidentNotifier = incidentNotifier ?? throw new ArgumentNullException(nameof(incidentNotifier));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_current = CreateSnapshot(optionsMonitor.CurrentValue?.Incident, _timeProvider.GetUtcNow());
if (_current.IsActive)
{
var initialChange = new IncidentModeChange(_current.IsActive, _current.ChangedAt, _current.RetentionExtensionDays);
_ = Task.Run(() => PublishChangeAsync(initialChange, _cts.Token), _cts.Token);
}
_subscription = optionsMonitor.OnChange((options, _) => HandleOptionsChanged(options?.Incident))!;
}
public IncidentModeSnapshot Current => _current;
public bool IsActive => _current.IsActive;
private void HandleOptionsChanged(IncidentModeOptions? options)
{
var now = _timeProvider.GetUtcNow();
var next = CreateSnapshot(options, now);
var previous = Interlocked.Exchange(ref _current, next);
if (previous.IsActive == next.IsActive &&
previous.RetentionExtensionDays == next.RetentionExtensionDays &&
previous.CaptureRequestSnapshot == next.CaptureRequestSnapshot)
{
return;
}
if (previous.IsActive != next.IsActive)
{
var change = new IncidentModeChange(next.IsActive, next.ChangedAt, next.RetentionExtensionDays);
_logger.LogInformation(
"Incident mode changed to {State} at {ChangedAt} (retention extension: {RetentionDays} days).",
next.IsActive ? "enabled" : "disabled",
next.ChangedAt,
next.RetentionExtensionDays);
_ = Task.Run(() => PublishChangeAsync(change, _cts.Token), _cts.Token);
}
else
{
_logger.LogInformation(
"Incident mode configuration updated (retention extension: {RetentionDays} days, capture request snapshot: {CaptureRequestSnapshot}).",
next.RetentionExtensionDays,
next.CaptureRequestSnapshot);
}
}
private async Task PublishChangeAsync(IncidentModeChange change, CancellationToken cancellationToken)
{
try
{
await _timelinePublisher.PublishIncidentModeChangedAsync(change, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to publish incident mode change to timeline: {Message}", ex.Message);
}
try
{
await _incidentNotifier.PublishIncidentModeChangedAsync(change, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to publish incident mode change notification: {Message}", ex.Message);
}
}
private IncidentModeSnapshot CreateSnapshot(IncidentModeOptions? options, DateTimeOffset timestamp)
{
if (options is null)
{
return new IncidentModeSnapshot(false, timestamp, 0, false);
}
var retentionExtensionDays = Math.Max(0, options.RetentionExtensionDays);
return new IncidentModeSnapshot(
options.Enabled,
timestamp,
retentionExtensionDays,
options.CaptureRequestSnapshot);
}
public void Dispose()
{
try
{
_cts.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed by another path.
}
_subscription.Dispose();
_cts.Dispose();
}
}

View File

@@ -0,0 +1,236 @@
using System;
using System.Buffers;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Signing;
namespace StellaOps.EvidenceLocker.Infrastructure.Signing;
public sealed class EvidenceSignatureService : IEvidenceSignatureService
{
private readonly ICryptoProviderRegistry _cryptoRegistry;
private readonly ITimestampAuthorityClient _timestampAuthorityClient;
private readonly ILogger<EvidenceSignatureService> _logger;
private readonly TimeProvider _timeProvider;
private readonly SigningOptions _options;
private readonly TimestampingOptions? _timestampingOptions;
private bool _signerPrepared;
private readonly SemaphoreSlim _initializationGate = new(1, 1);
public EvidenceSignatureService(
ICryptoProviderRegistry cryptoRegistry,
ITimestampAuthorityClient timestampAuthorityClient,
IOptions<EvidenceLockerOptions> options,
TimeProvider timeProvider,
ILogger<EvidenceSignatureService> logger)
{
_cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
_timestampAuthorityClient = timestampAuthorityClient ?? throw new ArgumentNullException(nameof(timestampAuthorityClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
ArgumentNullException.ThrowIfNull(options);
_options = options.Value.Signing ?? throw new InvalidOperationException("Signing options are required.");
_timestampingOptions = _options.Timestamping;
if (_timestampingOptions?.Enabled is true && string.IsNullOrWhiteSpace(_timestampingOptions.Endpoint))
{
throw new InvalidOperationException("Evidence Locker timestamping endpoint must be configured when timestamping is enabled.");
}
}
public async Task<EvidenceBundleSignature?> SignManifestAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
EvidenceBundleManifest manifest,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
{
return null;
}
ArgumentNullException.ThrowIfNull(manifest);
await EnsureSigningKeyAsync(cancellationToken).ConfigureAwait(false);
var payloadBytes = SerializeManifest(manifest);
var signerResolution = _cryptoRegistry.ResolveSigner(
CryptoCapability.Signing,
_options.Algorithm,
new CryptoKeyReference(_options.KeyId, _options.Provider),
_options.Provider);
var signatureBytes = await signerResolution.Signer.SignAsync(payloadBytes, cancellationToken).ConfigureAwait(false);
var signedAt = _timeProvider.GetUtcNow();
TimestampResult? timestampResult = null;
if (_timestampingOptions?.Enabled is true)
{
try
{
timestampResult = await _timestampAuthorityClient.RequestTimestampAsync(
signatureBytes,
_timestampingOptions.HashAlgorithm,
cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
if (_timestampingOptions.RequireTimestamp)
{
throw new InvalidOperationException("Timestamp authority request failed.", ex);
}
_logger.LogWarning(ex, "Failed to obtain timestamp for evidence bundle {BundleId}.", bundleId);
}
}
return new EvidenceBundleSignature(
bundleId,
tenantId,
_options.PayloadType,
Convert.ToBase64String(payloadBytes),
Convert.ToBase64String(signatureBytes),
signerResolution.Signer.KeyId,
signerResolution.Signer.AlgorithmId,
signerResolution.ProviderName,
signedAt,
timestampResult?.Timestamp,
timestampResult?.Authority,
timestampResult?.Token);
}
private async Task EnsureSigningKeyAsync(CancellationToken cancellationToken)
{
if (_signerPrepared)
{
return;
}
await _initializationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_signerPrepared)
{
return;
}
try
{
_ = _cryptoRegistry.ResolveSigner(
CryptoCapability.Signing,
_options.Algorithm,
new CryptoKeyReference(_options.KeyId, _options.Provider),
_options.Provider);
_signerPrepared = true;
return;
}
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException)
{
_logger.LogInformation(
ex,
"Provisioning signing key {KeyId} for provider {Provider} using configured material.",
_options.KeyId,
_options.Provider ?? "default");
var provider = ResolveProvider();
var signingKey = LoadSigningKeyMaterial();
provider.UpsertSigningKey(signingKey);
_signerPrepared = true;
}
}
finally
{
_initializationGate.Release();
}
}
private ICryptoProvider ResolveProvider()
{
if (!string.IsNullOrWhiteSpace(_options.Provider) &&
_cryptoRegistry.TryResolve(_options.Provider, out var hinted))
{
return hinted;
}
return _cryptoRegistry.ResolveOrThrow(CryptoCapability.Signing, _options.Algorithm);
}
private CryptoSigningKey LoadSigningKeyMaterial()
{
if (_options.KeyMaterial?.EcPrivateKeyPem is { Length: > 0 })
{
using var ecdsa = ECDsa.Create();
ecdsa.ImportFromPem(_options.KeyMaterial.EcPrivateKeyPem);
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
return new CryptoSigningKey(
new CryptoKeyReference(_options.KeyId, _options.Provider),
_options.Algorithm,
parameters,
_timeProvider.GetUtcNow());
}
_logger.LogWarning(
"Evidence Locker signing key material not configured; generating transient key for provider {Provider}.",
_options.Provider ?? "default");
using var fallback = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var ephemeralParams = fallback.ExportParameters(includePrivateParameters: true);
return new CryptoSigningKey(
new CryptoKeyReference(_options.KeyId, _options.Provider),
_options.Algorithm,
ephemeralParams,
_timeProvider.GetUtcNow());
}
private byte[] SerializeManifest(EvidenceBundleManifest manifest)
{
var buffer = new ArrayBufferWriter<byte>();
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false });
writer.WriteStartObject();
writer.WriteString("bundleId", manifest.BundleId.Value.ToString("D"));
writer.WriteString("tenantId", manifest.TenantId.Value.ToString("D"));
writer.WriteNumber("kind", (int)manifest.Kind);
writer.WriteString("createdAt", manifest.CreatedAt.UtcDateTime.ToString("O"));
writer.WriteStartObject("metadata");
foreach (var kvp in manifest.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
writer.WriteString(kvp.Key, kvp.Value);
}
writer.WriteEndObject();
writer.WriteStartArray("entries");
foreach (var entry in manifest.Entries)
{
writer.WriteStartObject();
writer.WriteString("section", entry.Section);
writer.WriteString("canonicalPath", entry.CanonicalPath);
writer.WriteString("sha256", entry.Sha256);
writer.WriteNumber("sizeBytes", entry.SizeBytes);
if (!string.IsNullOrWhiteSpace(entry.MediaType))
{
writer.WriteString("mediaType", entry.MediaType);
}
writer.WriteStartObject("attributes");
foreach (var attribute in entry.Attributes.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
writer.WriteString(attribute.Key, attribute.Value);
}
writer.WriteEndObject();
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
writer.Flush();
return buffer.WrittenSpan.ToArray();
}
}

View File

@@ -0,0 +1,26 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Core.Signing;
namespace StellaOps.EvidenceLocker.Infrastructure.Signing;
public sealed class NullTimestampAuthorityClient : ITimestampAuthorityClient
{
private readonly ILogger<NullTimestampAuthorityClient> _logger;
public NullTimestampAuthorityClient(ILogger<NullTimestampAuthorityClient> logger)
{
_logger = logger;
}
public Task<TimestampResult?> RequestTimestampAsync(
ReadOnlyMemory<byte> signature,
string hashAlgorithm,
CancellationToken cancellationToken)
{
_logger.LogDebug("Timestamp authority disabled; skipping timestamp generation.");
return Task.FromResult<TimestampResult?>(null);
}
}

View File

@@ -0,0 +1,172 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Org.BouncyCastle.Asn1;
using Org.BouncyCastle.Asn1.Nist;
using Org.BouncyCastle.Asn1.Oiw;
using Org.BouncyCastle.Math;
using Org.BouncyCastle.Tsp;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Signing;
namespace StellaOps.EvidenceLocker.Infrastructure.Signing;
public sealed class Rfc3161TimestampAuthorityClient : ITimestampAuthorityClient
{
private readonly HttpClient _httpClient;
private readonly IOptions<EvidenceLockerOptions> _options;
private readonly ILogger<Rfc3161TimestampAuthorityClient> _logger;
public Rfc3161TimestampAuthorityClient(
HttpClient httpClient,
IOptions<EvidenceLockerOptions> options,
ILogger<Rfc3161TimestampAuthorityClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<TimestampResult?> RequestTimestampAsync(
ReadOnlyMemory<byte> signature,
string hashAlgorithm,
CancellationToken cancellationToken)
{
var timestamping = _options.Value.Signing?.Timestamping;
if (timestamping is null || !timestamping.Enabled)
{
return null;
}
if (string.IsNullOrWhiteSpace(timestamping.Endpoint))
{
throw new InvalidOperationException("Timestamping endpoint must be configured when enabled.");
}
var digest = ComputeDigest(signature.Span, hashAlgorithm);
var hashOid = ResolveHashAlgorithmOid(hashAlgorithm);
var requestGenerator = new TimeStampRequestGenerator();
requestGenerator.SetCertReq(true);
Span<byte> nonceBuffer = stackalloc byte[16];
RandomNumberGenerator.Fill(nonceBuffer);
var nonce = new BigInteger(1, nonceBuffer);
var timeStampRequest = requestGenerator.Generate(new DerObjectIdentifier(hashOid), digest, nonce);
var requestBytes = timeStampRequest.GetEncoded();
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, timestamping.Endpoint);
httpRequest.Content = new ByteArrayContent(requestBytes);
httpRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/timestamp-query");
httpRequest.Headers.Accept.Clear();
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/timestamp-reply"));
httpRequest.Headers.UserAgent.ParseAdd("StellaOpsEvidenceLocker/1.0");
if (timestamping.RequestTimeoutSeconds > 0)
{
_httpClient.Timeout = TimeSpan.FromSeconds(timestamping.RequestTimeoutSeconds);
}
if (timestamping.Authentication is { Username: { Length: > 0 } } auth)
{
var credentials = $"{auth.Username}:{auth.Password ?? string.Empty}";
var credentialBytes = Encoding.UTF8.GetBytes(credentials);
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(credentialBytes));
}
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
if (timestamping.RequireTimestamp)
{
throw new InvalidOperationException($"Timestamp authority responded with status code {(int)response.StatusCode} ({response.StatusCode}).");
}
if (_logger.IsEnabled(LogLevel.Warning))
{
_logger.LogWarning("Timestamp authority request failed with status {StatusCode}.", response.StatusCode);
}
return null;
}
var responseBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
TimeStampResponse tspResponse;
try
{
tspResponse = new TimeStampResponse(responseBytes);
tspResponse.Validate(timeStampRequest);
}
catch (Exception ex) when (!timestamping.RequireTimestamp)
{
_logger.LogWarning(ex, "Timestamp authority returned an invalid response.");
return null;
}
if (tspResponse.Status is not 0 and not 1)
{
if (timestamping.RequireTimestamp)
{
throw new InvalidOperationException($"Timestamp authority declined request with status code {tspResponse.Status}.");
}
_logger.LogWarning("Timestamp authority declined request with status code {Status}.", tspResponse.Status);
return null;
}
var token = tspResponse.TimeStampToken;
if (token is null)
{
if (timestamping.RequireTimestamp)
{
throw new InvalidOperationException("Timestamp authority response did not include a token.");
}
_logger.LogWarning("Timestamp authority response missing token for bundle timestamp request.");
return null;
}
var info = token.TimeStampInfo;
var authority = info.Tsa?.Name?.ToString() ?? timestamping.Endpoint;
var tokenBytes = token.GetEncoded();
return new TimestampResult(info.GenTime, authority, tokenBytes);
}
private static byte[] ComputeDigest(ReadOnlySpan<byte> data, string algorithm)
{
var hashAlgorithm = GetHashAlgorithmName(algorithm);
using var hasher = IncrementalHash.CreateHash(hashAlgorithm);
hasher.AppendData(data);
return hasher.GetHashAndReset();
}
private static HashAlgorithmName GetHashAlgorithmName(string algorithm)
=> (algorithm ?? string.Empty).ToUpperInvariant() switch
{
"SHA256" => HashAlgorithmName.SHA256,
"SHA384" => HashAlgorithmName.SHA384,
"SHA512" => HashAlgorithmName.SHA512,
"SHA1" => HashAlgorithmName.SHA1,
_ => throw new InvalidOperationException($"Unsupported timestamp hash algorithm '{algorithm}'.")
};
private static string ResolveHashAlgorithmOid(string algorithm)
=> (algorithm ?? string.Empty).ToUpperInvariant() switch
{
"SHA256" => NistObjectIdentifiers.IdSha256.Id,
"SHA384" => NistObjectIdentifiers.IdSha384.Id,
"SHA512" => NistObjectIdentifiers.IdSha512.Id,
"SHA1" => OiwObjectIdentifiers.IdSha1.Id,
_ => throw new InvalidOperationException($"Unsupported timestamp hash algorithm '{algorithm}'.")
};
}

View File

@@ -1,28 +1,33 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\StellaOps.EvidenceLocker.Core\StellaOps.EvidenceLocker.Core.csproj"/>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
<?xml version="1.0"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.EvidenceLocker.Core\StellaOps.EvidenceLocker.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.BouncyCastle\StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.7.303.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Npgsql" Version="8.0.3" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Db\Migrations\*.sql" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,150 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Storage;
namespace StellaOps.EvidenceLocker.Infrastructure.Storage;
internal sealed class FileSystemEvidenceObjectStore : IEvidenceObjectStore
{
private readonly string _rootPath;
private readonly bool _enforceWriteOnce;
private readonly ILogger<FileSystemEvidenceObjectStore> _logger;
public FileSystemEvidenceObjectStore(
FileSystemStoreOptions options,
bool enforceWriteOnce,
ILogger<FileSystemEvidenceObjectStore> logger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(options.RootPath);
_rootPath = Path.GetFullPath(options.RootPath);
_enforceWriteOnce = enforceWriteOnce;
_logger = logger;
Directory.CreateDirectory(_rootPath);
}
public async Task<EvidenceObjectMetadata> StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(options);
var writeOnce = _enforceWriteOnce || options.EnforceWriteOnce;
var utcNow = DateTimeOffset.UtcNow;
var tempFilePath = Path.Combine(_rootPath, ".tmp", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path.GetDirectoryName(tempFilePath)!);
await using var tempStream = new FileStream(
tempFilePath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
FileOptions.Asynchronous | FileOptions.SequentialScan | FileOptions.WriteThrough);
using var sha = SHA256.Create();
long totalBytes = 0;
var buffer = new byte[81920];
int bytesRead;
while ((bytesRead = await content.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0)
{
await tempStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
sha.TransformBlock(buffer, 0, bytesRead, null, 0);
totalBytes += bytesRead;
}
await tempStream.FlushAsync(cancellationToken);
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
var sha256 = Convert.ToHexString(sha.Hash!).ToLowerInvariant();
var storageKey = StorageKeyGenerator.BuildObjectKey(options.TenantId, options.BundleId, options.ArtifactName, sha256);
var destinationPath = Path.Combine(_rootPath, storageKey.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
if (writeOnce && File.Exists(destinationPath))
{
await DeleteTempFileAsync(tempFilePath);
throw new InvalidOperationException($"Evidence object already exists for key '{storageKey}'.");
}
try
{
if (!writeOnce && File.Exists(destinationPath))
{
File.Delete(destinationPath);
}
File.Move(tempFilePath, destinationPath, overwrite: false);
}
catch (Exception ex)
{
await DeleteTempFileAsync(tempFilePath);
if (_logger.IsEnabled(LogLevel.Error))
{
_logger.LogError(ex, "Failed to persist evidence object to filesystem store.");
}
throw;
}
return new EvidenceObjectMetadata(
StorageKey: storageKey,
ContentType: options.ContentType,
SizeBytes: totalBytes,
Sha256: sha256,
ETag: null,
CreatedAt: utcNow);
}
public Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(storageKey);
var path = Path.Combine(_rootPath, storageKey.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(path))
{
throw new FileNotFoundException("Evidence object not found.", path);
}
Stream stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 81920,
FileOptions.Asynchronous | FileOptions.SequentialScan);
return Task.FromResult(stream);
}
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(storageKey);
var path = Path.Combine(_rootPath, storageKey.Replace('/', Path.DirectorySeparatorChar));
var exists = File.Exists(path);
return Task.FromResult(exists);
}
private static async Task DeleteTempFileAsync(string path)
{
try
{
if (File.Exists(path))
{
await Task.Run(() => File.Delete(path));
}
}
catch
{
// Swallow cleanup errors best effort only.
}
}
}

View File

@@ -0,0 +1,261 @@
using System.Linq;
using System.Security.Cryptography;
using Amazon.S3;
using Amazon.S3.Model;
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Storage;
namespace StellaOps.EvidenceLocker.Infrastructure.Storage;
internal sealed class S3EvidenceObjectStore : IEvidenceObjectStore, IDisposable
{
private readonly IAmazonS3 _s3;
private readonly AmazonS3StoreOptions _options;
private readonly bool _enforceWriteOnce;
private readonly ILogger<S3EvidenceObjectStore> _logger;
public S3EvidenceObjectStore(
IAmazonS3 s3,
AmazonS3StoreOptions options,
bool enforceWriteOnce,
ILogger<S3EvidenceObjectStore> logger)
{
_s3 = s3 ?? throw new ArgumentNullException(nameof(s3));
_options = options ?? throw new ArgumentNullException(nameof(options));
_enforceWriteOnce = enforceWriteOnce;
_logger = logger;
}
public async Task<EvidenceObjectMetadata> StoreAsync(
Stream content,
EvidenceObjectWriteOptions options,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(options);
var writeOnce = _enforceWriteOnce || options.EnforceWriteOnce;
var tempFilePath = Path.Combine(Path.GetTempPath(), $"evidence-{Guid.NewGuid():N}.tmp");
using var sha = SHA256.Create();
long totalBytes = 0;
await using (var fileStream = new FileStream(
tempFilePath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
FileOptions.Asynchronous | FileOptions.SequentialScan))
{
var buffer = new byte[81920];
int bytesRead;
while ((bytesRead = await content.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0)
{
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
sha.TransformBlock(buffer, 0, bytesRead, null, 0);
totalBytes += bytesRead;
}
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
}
var sha256 = Convert.ToHexString(sha.Hash!).ToLowerInvariant();
var storageKey = StorageKeyGenerator.BuildObjectKey(options.TenantId, options.BundleId, options.ArtifactName, sha256, _options.Prefix);
string? eTag;
try
{
eTag = await UploadAsync(storageKey, tempFilePath, options, totalBytes, sha256, writeOnce, cancellationToken);
}
finally
{
TryCleanupTempFile(tempFilePath);
}
return new EvidenceObjectMetadata(
StorageKey: storageKey,
ContentType: options.ContentType,
SizeBytes: totalBytes,
Sha256: sha256,
ETag: eTag,
CreatedAt: DateTimeOffset.UtcNow);
}
public async Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(storageKey);
try
{
var response = await _s3.GetObjectAsync(new GetObjectRequest
{
BucketName = _options.BucketName,
Key = storageKey
}, cancellationToken);
return new S3ObjectReadStream(response);
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
throw new FileNotFoundException($"Evidence object '{storageKey}' not found in bucket '{_options.BucketName}'.", ex);
}
}
public async Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(storageKey);
try
{
var metadata = await _s3.GetObjectMetadataAsync(
new GetObjectMetadataRequest
{
BucketName = _options.BucketName,
Key = storageKey
},
cancellationToken);
return metadata.HttpStatusCode == System.Net.HttpStatusCode.OK;
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return false;
}
}
private async Task<string?> UploadAsync(
string storageKey,
string tempFilePath,
EvidenceObjectWriteOptions options,
long contentLength,
string sha256,
bool writeOnce,
CancellationToken cancellationToken)
{
await using var inputStream = new FileStream(
tempFilePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 81920,
FileOptions.Asynchronous | FileOptions.SequentialScan);
var request = new PutObjectRequest
{
BucketName = _options.BucketName,
Key = storageKey,
InputStream = inputStream,
AutoCloseStream = false,
ContentType = options.ContentType
};
request.Headers.ContentLength = contentLength;
request.Metadata["sha256"] = sha256;
request.Metadata["tenant-id"] = options.TenantId.Value.ToString("D");
request.Metadata["bundle-id"] = options.BundleId.Value.ToString("D");
if (options.Tags is not null)
{
request.TagSet = options.Tags
.Select(tag => new Tag { Key = tag.Key, Value = tag.Value })
.ToList();
foreach (var tag in options.Tags)
{
request.Metadata[$"tag-{tag.Key}"] = tag.Value;
}
}
if (_options.UseIntelligentTiering)
{
request.StorageClass = S3StorageClass.IntelligentTiering;
}
if (writeOnce)
{
request.Headers["If-None-Match"] = "*";
}
try
{
var response = await _s3.PutObjectAsync(request, cancellationToken);
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Uploaded evidence object {Key} to bucket {Bucket} (ETag: {ETag}).", storageKey, _options.BucketName, response.ETag);
}
return response.ETag;
}
catch (AmazonS3Exception ex) when (writeOnce && ex.StatusCode == System.Net.HttpStatusCode.PreconditionFailed)
{
throw new InvalidOperationException($"Evidence object already exists for key '{storageKey}'.", ex);
}
catch (AmazonS3Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error))
{
_logger.LogError(ex, "Failed to upload evidence object {Key} to bucket {Bucket}.", storageKey, _options.BucketName);
}
throw;
}
}
private static void TryCleanupTempFile(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// ignored
}
}
public void Dispose()
{
(_s3 as IDisposable)?.Dispose();
GC.SuppressFinalize(this);
}
private sealed class S3ObjectReadStream(GetObjectResponse response) : Stream
{
private readonly GetObjectResponse _response = response ?? throw new ArgumentNullException(nameof(response));
private readonly Stream _inner = response.ResponseStream;
public override bool CanRead => _inner.CanRead;
public override bool CanSeek => _inner.CanSeek;
public override bool CanWrite => false;
public override long Length => _inner.Length;
public override long Position { get => _inner.Position; set => _inner.Position = value; }
public override void Flush() => _inner.Flush();
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin);
public override void SetLength(long value) => _inner.SetLength(value);
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
=> await _inner.ReadAsync(buffer, cancellationToken);
protected override void Dispose(bool disposing)
{
if (disposing)
{
_inner.Dispose();
_response.Dispose();
}
base.Dispose(disposing);
}
}
}

View File

@@ -0,0 +1,56 @@
using System.Text;
using System.Text.RegularExpressions;
using StellaOps.EvidenceLocker.Core.Domain;
namespace StellaOps.EvidenceLocker.Infrastructure.Storage;
internal static partial class StorageKeyGenerator
{
public static string BuildObjectKey(TenantId tenantId, EvidenceBundleId bundleId, string artifactName, string sha256, string? prefix = null)
{
var sanitizedName = SanitizeComponent(artifactName);
var tenantSegment = tenantId.Value.ToString("N");
var bundleSegment = bundleId.Value.ToString("N");
var builder = new StringBuilder();
if (!string.IsNullOrWhiteSpace(prefix))
{
builder.Append(prefix.Trim().Trim('/'));
builder.Append('/');
}
builder.Append("tenants/");
builder.Append(tenantSegment);
builder.Append("/bundles/");
builder.Append(bundleSegment);
builder.Append('/');
builder.Append(sha256);
if (!string.IsNullOrEmpty(sanitizedName))
{
builder.Append('-');
builder.Append(sanitizedName);
}
return builder.ToString();
}
private static string SanitizeComponent(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var trimmed = value.Trim();
var normalized = InvalidCharacters().Replace(trimmed, "-").ToLowerInvariant();
return normalized.Length switch
{
> 80 => normalized[..80],
_ => normalized
};
}
[GeneratedRegex("[^a-zA-Z0-9._-]+")]
private static partial Regex InvalidCharacters();
}

View File

@@ -0,0 +1,47 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Incident;
using StellaOps.EvidenceLocker.Core.Timeline;
namespace StellaOps.EvidenceLocker.Infrastructure.Timeline;
public sealed class NullEvidenceTimelinePublisher : IEvidenceTimelinePublisher
{
private readonly ILogger<NullEvidenceTimelinePublisher> _logger;
public NullEvidenceTimelinePublisher(ILogger<NullEvidenceTimelinePublisher> logger)
{
_logger = logger;
}
public Task PublishBundleSealedAsync(
EvidenceBundleSignature signature,
EvidenceBundleManifest manifest,
string rootHash,
CancellationToken cancellationToken)
{
_logger.LogDebug(
"Timeline publisher not configured; skipping bundle sealed event for {BundleId}.",
signature.BundleId.Value);
return Task.CompletedTask;
}
public Task PublishHoldCreatedAsync(EvidenceHold hold, CancellationToken cancellationToken)
{
_logger.LogDebug(
"Timeline publisher not configured; skipping hold event for case {CaseId}.",
hold.CaseId);
return Task.CompletedTask;
}
public Task PublishIncidentModeChangedAsync(IncidentModeChange change, CancellationToken cancellationToken)
{
_logger.LogDebug(
"Timeline publisher not configured; skipping incident mode event (state: {State}).",
change.IsActive ? "enabled" : "disabled");
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,317 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Incident;
using StellaOps.EvidenceLocker.Core.Timeline;
namespace StellaOps.EvidenceLocker.Infrastructure.Timeline;
internal sealed class TimelineIndexerEvidenceTimelinePublisher : IEvidenceTimelinePublisher
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
private readonly HttpClient _httpClient;
private readonly TimelineOptions _options;
private readonly ILogger<TimelineIndexerEvidenceTimelinePublisher> _logger;
private readonly Uri _endpoint;
public TimelineIndexerEvidenceTimelinePublisher(
HttpClient httpClient,
IOptions<EvidenceLockerOptions> options,
TimeProvider timeProvider,
ILogger<TimelineIndexerEvidenceTimelinePublisher> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value.Timeline ?? throw new InvalidOperationException("Timeline options must be configured when the publisher is enabled.");
if (!_options.Enabled)
{
throw new InvalidOperationException("Timeline publisher cannot be constructed when disabled.");
}
if (string.IsNullOrWhiteSpace(_options.Endpoint))
{
throw new InvalidOperationException("Timeline endpoint must be provided when publishing is enabled.");
}
_endpoint = new Uri(_options.Endpoint, UriKind.Absolute);
ArgumentNullException.ThrowIfNull(timeProvider);
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task PublishBundleSealedAsync(
EvidenceBundleSignature signature,
EvidenceBundleManifest manifest,
string rootHash,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(signature);
ArgumentNullException.ThrowIfNull(manifest);
ArgumentException.ThrowIfNullOrWhiteSpace(rootHash);
var envelope = BuildBundleEvent(signature, manifest, rootHash);
await SendAsync(envelope, signature.BundleId.Value, cancellationToken).ConfigureAwait(false);
}
public async Task PublishHoldCreatedAsync(EvidenceHold hold, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(hold);
var envelope = BuildHoldEvent(hold);
await SendAsync(envelope, hold.Id.Value, cancellationToken).ConfigureAwait(false);
}
public async Task PublishIncidentModeChangedAsync(IncidentModeChange change, CancellationToken cancellationToken)
{
var envelope = BuildIncidentEvent(change);
await SendAsync(envelope, Guid.Empty, cancellationToken).ConfigureAwait(false);
}
private TimelineEventEnvelope BuildBundleEvent(EvidenceBundleSignature signature, EvidenceBundleManifest manifest, string rootHash)
{
var eventId = Guid.NewGuid();
var occurredAt = signature.TimestampedAt ?? signature.SignedAt;
var attributes = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["bundleId"] = signature.BundleId.Value.ToString("D"),
["bundleKind"] = ((int)manifest.Kind).ToString(),
["rootHash"] = rootHash,
["payloadType"] = signature.PayloadType
};
var metadata = ToSortedDictionary(manifest.Metadata);
var manifestEntries = manifest.Entries
.Select(entry => new TimelineManifestEntryRecord(
entry.Section,
entry.CanonicalPath,
entry.Sha256,
entry.SizeBytes,
entry.MediaType,
ToSortedDictionary(entry.Attributes)))
.ToArray();
var signatureRecord = new TimelineSignatureRecord(
signature.Payload,
signature.Signature,
signature.KeyId,
signature.Algorithm,
signature.Provider,
signature.SignedAt,
signature.TimestampedAt,
signature.TimestampAuthority,
signature.TimestampToken is null ? null : Convert.ToBase64String(signature.TimestampToken));
var bundleRecord = new TimelineBundleRecord(
signature.BundleId.Value,
manifest.Kind,
rootHash,
metadata,
manifestEntries,
signatureRecord);
return new TimelineEventEnvelope(
eventId,
signature.TenantId.Value,
_options.Source,
"evidence.bundle.sealed",
occurredAt,
attributes,
Bundle: bundleRecord,
Hold: null,
Incident: null);
}
private TimelineEventEnvelope BuildHoldEvent(EvidenceHold hold)
{
var eventId = Guid.NewGuid();
var occurredAt = hold.CreatedAt;
var attributes = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["caseId"] = hold.CaseId
};
if (hold.BundleId is not null)
{
attributes["bundleId"] = hold.BundleId.Value.Value.ToString("D");
}
var holdRecord = new TimelineHoldRecord(
hold.Id.Value,
hold.CaseId,
hold.BundleId?.Value,
hold.Reason,
hold.CreatedAt,
hold.ExpiresAt,
hold.ReleasedAt,
hold.Notes);
return new TimelineEventEnvelope(
eventId,
hold.TenantId.Value,
_options.Source,
"evidence.hold.created",
occurredAt,
attributes,
Bundle: null,
Hold: holdRecord,
Incident: null);
}
private TimelineEventEnvelope BuildIncidentEvent(IncidentModeChange change)
{
var eventId = Guid.NewGuid();
var attributes = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["state"] = change.IsActive ? "enabled" : "disabled",
["retentionExtensionDays"] = change.RetentionExtensionDays.ToString(CultureInfo.InvariantCulture)
};
var incidentRecord = new TimelineIncidentRecord(
change.IsActive,
change.ChangedAt,
change.RetentionExtensionDays);
return new TimelineEventEnvelope(
eventId,
Guid.Empty,
_options.Source,
"evidence.incident.mode",
change.ChangedAt,
attributes,
Bundle: null,
Hold: null,
Incident: incidentRecord);
}
private async Task SendAsync(TimelineEventEnvelope envelope, Guid referenceId, CancellationToken cancellationToken)
{
try
{
using var response = await _httpClient.PostAsJsonAsync(_endpoint, envelope, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
_logger.LogDebug("Published timeline event {EventId} for reference {ReferenceId}.", envelope.EventId, referenceId);
return;
}
var body = await SafeReadBodyAsync(response, cancellationToken).ConfigureAwait(false);
_logger.LogWarning(
"Timeline publish for {ReferenceId} failed with status {StatusCode}. Body: {Body}",
referenceId,
response.StatusCode,
body);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or InvalidOperationException)
{
_logger.LogWarning(
ex,
"Timeline publish for {ReferenceId} failed: {Message}",
referenceId,
ex.Message);
}
}
private static async Task<string?> SafeReadBodyAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.Content is null)
{
return null;
}
try
{
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return string.IsNullOrWhiteSpace(text) ? null : text;
}
catch
{
return null;
}
}
private static SortedDictionary<string, string> ToSortedDictionary(IReadOnlyDictionary<string, string> source)
{
var result = new SortedDictionary<string, string>(StringComparer.Ordinal);
foreach (var kvp in source)
{
result[kvp.Key] = kvp.Value;
}
return result;
}
private sealed record TimelineEventEnvelope(
Guid EventId,
Guid TenantId,
string Source,
string Kind,
DateTimeOffset OccurredAt,
IReadOnlyDictionary<string, string> Attributes,
TimelineBundleRecord? Bundle,
TimelineHoldRecord? Hold,
TimelineIncidentRecord? Incident);
private sealed record TimelineBundleRecord(
Guid BundleId,
EvidenceBundleKind Kind,
string RootHash,
IReadOnlyDictionary<string, string> Metadata,
IReadOnlyList<TimelineManifestEntryRecord> Entries,
TimelineSignatureRecord Signature);
private sealed record TimelineManifestEntryRecord(
string Section,
string CanonicalPath,
string Sha256,
long SizeBytes,
string? MediaType,
IReadOnlyDictionary<string, string> Attributes);
private sealed record TimelineSignatureRecord(
string Payload,
string Signature,
string? KeyId,
string Algorithm,
string Provider,
DateTimeOffset SignedAt,
DateTimeOffset? TimestampedAt,
string? TimestampAuthority,
string? TimestampToken);
private sealed record TimelineHoldRecord(
Guid HoldId,
string CaseId,
Guid? BundleId,
string Reason,
DateTimeOffset CreatedAt,
DateTimeOffset? ExpiresAt,
DateTimeOffset? ReleasedAt,
string? Notes);
private sealed record TimelineIncidentRecord(
bool Enabled,
DateTimeOffset ChangedAt,
int RetentionExtensionDays);
}

View File

@@ -0,0 +1,156 @@
using System;
using System.Net.Http;
using Docker.DotNet;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Configurations;
using DotNet.Testcontainers.Containers;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Infrastructure.Db;
using Xunit;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class DatabaseMigrationTests : IAsyncLifetime
{
private readonly PostgreSqlTestcontainer _postgres;
private EvidenceLockerDataSource? _dataSource;
private IEvidenceLockerMigrationRunner? _migrationRunner;
private string? _skipReason;
public DatabaseMigrationTests()
{
_postgres = new TestcontainersBuilder<PostgreSqlTestcontainer>()
.WithDatabase(new PostgreSqlTestcontainerConfiguration
{
Database = "evidence_locker_tests",
Username = "postgres",
Password = "postgres"
})
.WithCleanUp(true)
.Build();
}
[Fact]
public async Task ApplyAsync_CreatesExpectedSchemaAndPolicies()
{
if (_skipReason is not null)
{
Assert.Skip(_skipReason);
}
var cancellationToken = TestContext.Current.CancellationToken;
await _migrationRunner!.ApplyAsync(cancellationToken);
await using var connection = await _dataSource!.OpenConnectionAsync(cancellationToken);
await using var tablesCommand = new NpgsqlCommand(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'evidence_locker' ORDER BY table_name;",
connection);
var tables = new List<string>();
await using (var reader = await tablesCommand.ExecuteReaderAsync(cancellationToken))
{
while (await reader.ReadAsync(cancellationToken))
{
tables.Add(reader.GetString(0));
}
}
Assert.Contains("evidence_artifacts", tables);
Assert.Contains("evidence_bundles", tables);
Assert.Contains("evidence_holds", tables);
Assert.Contains("evidence_schema_version", tables);
await using var versionCommand = new NpgsqlCommand(
"SELECT COUNT(*) FROM evidence_locker.evidence_schema_version WHERE version = 1;",
connection);
var applied = Convert.ToInt64(await versionCommand.ExecuteScalarAsync(cancellationToken) ?? 0L);
Assert.Equal(1, applied);
var tenant = TenantId.FromGuid(Guid.NewGuid());
await using var tenantConnection = await _dataSource.OpenConnectionAsync(tenant, cancellationToken);
await using var insertCommand = new NpgsqlCommand(@"
INSERT INTO evidence_locker.evidence_bundles
(bundle_id, tenant_id, kind, status, root_hash, storage_key)
VALUES
(@bundle, @tenant, 1, 3, @hash, @key);",
tenantConnection);
insertCommand.Parameters.AddWithValue("bundle", Guid.NewGuid());
insertCommand.Parameters.AddWithValue("tenant", tenant.Value);
insertCommand.Parameters.AddWithValue("hash", new string('a', 64));
insertCommand.Parameters.AddWithValue("key", $"tenants/{tenant.Value:N}/bundles/test/resource");
await insertCommand.ExecuteNonQueryAsync(cancellationToken);
await using var isolationConnection = await _dataSource.OpenConnectionAsync(tenant, cancellationToken);
await using var selectCommand = new NpgsqlCommand(
"SELECT COUNT(*) FROM evidence_locker.evidence_bundles;",
isolationConnection);
var visibleCount = Convert.ToInt64(await selectCommand.ExecuteScalarAsync(cancellationToken) ?? 0L);
Assert.Equal(1, visibleCount);
await using var otherTenantConnection = await _dataSource.OpenConnectionAsync(TenantId.FromGuid(Guid.NewGuid()), cancellationToken);
await using var otherSelectCommand = new NpgsqlCommand(
"SELECT COUNT(*) FROM evidence_locker.evidence_bundles;",
otherTenantConnection);
var otherVisible = Convert.ToInt64(await otherSelectCommand.ExecuteScalarAsync(cancellationToken) ?? 0L);
Assert.Equal(0, otherVisible);
await using var violationConnection = await _dataSource.OpenConnectionAsync(tenant, cancellationToken);
await using var violationCommand = new NpgsqlCommand(@"
INSERT INTO evidence_locker.evidence_bundles
(bundle_id, tenant_id, kind, status, root_hash, storage_key)
VALUES
(@bundle, @tenant, 1, 3, @hash, @key);",
violationConnection);
violationCommand.Parameters.AddWithValue("bundle", Guid.NewGuid());
violationCommand.Parameters.AddWithValue("tenant", Guid.NewGuid());
violationCommand.Parameters.AddWithValue("hash", new string('b', 64));
violationCommand.Parameters.AddWithValue("key", "tenants/other/bundles/resource");
await Assert.ThrowsAsync<PostgresException>(() => violationCommand.ExecuteNonQueryAsync(cancellationToken));
}
public async ValueTask InitializeAsync()
{
try
{
await _postgres.StartAsync();
}
catch (HttpRequestException ex)
{
_skipReason = $"Docker endpoint unavailable: {ex.Message}";
return;
}
catch (Docker.DotNet.DockerApiException ex)
{
_skipReason = $"Docker API error: {ex.Message}";
return;
}
var databaseOptions = new DatabaseOptions
{
ConnectionString = _postgres.ConnectionString,
ApplyMigrationsAtStartup = false
};
_dataSource = new EvidenceLockerDataSource(databaseOptions, NullLogger<EvidenceLockerDataSource>.Instance);
_migrationRunner = new EvidenceLockerMigrationRunner(_dataSource, NullLogger<EvidenceLockerMigrationRunner>.Instance);
}
public async ValueTask DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
if (_dataSource is not null)
{
await _dataSource.DisposeAsync();
}
await _postgres.DisposeAsync();
}
}

View File

@@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Infrastructure.Builders;
using Xunit;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidenceBundleBuilderTests
{
private readonly FakeRepository _repository = new();
private readonly IEvidenceBundleBuilder _builder;
public EvidenceBundleBuilderTests()
{
_builder = new EvidenceBundleBuilder(_repository, new MerkleTreeCalculator());
}
[Fact]
public async Task BuildAsync_ComputesDeterministicRootAndPersists()
{
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var request = new EvidenceBundleBuildRequest(
bundleId,
tenantId,
EvidenceBundleKind.Job,
DateTimeOffset.Parse("2025-11-03T15:04:05Z"),
new Dictionary<string, string> { ["run-id"] = "job-42" },
new List<EvidenceBundleMaterial>
{
new("inputs", "config/env.json", "5a6b7c", 1024, "application/json"),
new("outputs", "reports/result.txt", "7f8e9d", 2048, "text/plain")
});
var result = await _builder.BuildAsync(request, CancellationToken.None);
Assert.Equal(EvidenceBundleStatus.Sealed, _repository.LastStatus);
Assert.Equal(bundleId, _repository.LastBundleId);
Assert.Equal(tenantId, _repository.LastTenantId);
Assert.Equal(DateTimeOffset.Parse("2025-11-03T15:04:05Z"), _repository.LastUpdatedAt);
Assert.Equal(result.RootHash, _repository.LastRootHash);
Assert.Equal(2, result.Manifest.Entries.Count);
Assert.True(result.Manifest.Entries.SequenceEqual(
result.Manifest.Entries.OrderBy(entry => entry.CanonicalPath, StringComparer.Ordinal)));
}
[Fact]
public async Task BuildAsync_NormalizesSectionAndPath()
{
var request = new EvidenceBundleBuildRequest(
EvidenceBundleId.FromGuid(Guid.NewGuid()),
TenantId.FromGuid(Guid.NewGuid()),
EvidenceBundleKind.Evaluation,
DateTimeOffset.UtcNow,
new Dictionary<string, string>(),
new List<EvidenceBundleMaterial>
{
new(" Inputs ", "./Config/Env.JSON ", "abc123", 10, "application/json"),
new("OUTPUTS", "\\Logs\\app.log", "def456", 20, "text/plain")
});
var result = await _builder.BuildAsync(request, CancellationToken.None);
Assert.Collection(result.Manifest.Entries,
entry => Assert.Equal("inputs/config/env.json", entry.CanonicalPath),
entry => Assert.Equal("outputs/logs/app.log", entry.CanonicalPath));
}
private sealed class FakeRepository : IEvidenceBundleRepository
{
public EvidenceBundleId LastBundleId { get; private set; }
public TenantId LastTenantId { get; private set; }
public EvidenceBundleStatus LastStatus { get; private set; }
public string? LastRootHash { get; private set; }
public DateTimeOffset LastUpdatedAt { get; private set; }
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task SetBundleAssemblyAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
EvidenceBundleStatus status,
string rootHash,
DateTimeOffset updatedAt,
CancellationToken cancellationToken)
{
LastBundleId = bundleId;
LastTenantId = tenantId;
LastStatus = status;
LastRootHash = rootHash;
LastUpdatedAt = updatedAt;
return Task.CompletedTask;
}
public Task MarkBundleSealedAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, DateTimeOffset sealedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult<EvidenceBundleDetails?>(null);
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult(true);
public Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
=> Task.FromResult(hold);
public Task ExtendBundleRetentionAsync(EvidenceBundleId bundleId, TenantId tenantId, DateTimeOffset? holdExpiresAt, DateTimeOffset processedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpdateStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpdatePortableStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, DateTimeOffset generatedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
}

View File

@@ -0,0 +1,316 @@
using System.Buffers.Binary;
using System.Formats.Tar;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Infrastructure.Services;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidenceBundlePackagingServiceTests
{
private static readonly TenantId TenantId = TenantId.FromGuid(Guid.NewGuid());
private static readonly EvidenceBundleId BundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
private static readonly DateTimeOffset CreatedAt = new(2025, 11, 3, 12, 30, 0, TimeSpan.Zero);
[Fact]
public async Task EnsurePackageAsync_ReturnsCached_WhenPackageExists()
{
var repository = new FakeRepository(CreateSealedBundle(), CreateSignature());
var objectStore = new FakeObjectStore(exists: true);
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
var result = await service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None);
Assert.False(result.Created);
Assert.Equal(repository.Bundle.StorageKey, result.StorageKey);
Assert.Equal(repository.Bundle.RootHash, result.RootHash);
Assert.False(objectStore.Stored);
}
[Fact]
public async Task EnsurePackageAsync_Throws_WhenSignatureMissing()
{
var repository = new FakeRepository(CreateSealedBundle(), signature: null);
var objectStore = new FakeObjectStore(exists: false);
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None));
}
[Fact]
public async Task EnsurePackageAsync_CreatesPackageWithExpectedEntries()
{
var repository = new FakeRepository(
CreateSealedBundle(storageKey: $"tenants/{TenantId.Value:N}/bundles/{BundleId.Value:N}/bundle-old.tgz"),
CreateSignature(includeTimestamp: true));
var objectStore = new FakeObjectStore(exists: false);
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
var result = await service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None);
Assert.True(result.Created);
Assert.True(objectStore.Stored);
var entries = ReadArchiveEntries(objectStore.StoredBytes!);
Assert.Contains(entries.Keys, key => key == "manifest.json");
Assert.Contains(entries.Keys, key => key == "signature.json");
Assert.Contains(entries.Keys, key => key == "bundle.json");
Assert.Contains(entries.Keys, key => key == "checksums.txt");
Assert.Contains(entries.Keys, key => key == "instructions.txt");
var manifestJson = entries["manifest.json"];
using var manifestDoc = JsonDocument.Parse(manifestJson);
Assert.Equal(BundleId.Value.ToString("D"), manifestDoc.RootElement.GetProperty("bundleId").GetString());
var signatureJson = entries["signature.json"];
using var signatureDoc = JsonDocument.Parse(signatureJson);
Assert.Equal("application/vnd.stella.evidence.manifest+json", signatureDoc.RootElement.GetProperty("payloadType").GetString());
Assert.Equal("tsa.default", signatureDoc.RootElement.GetProperty("timestampAuthority").GetString());
Assert.False(string.IsNullOrEmpty(signatureDoc.RootElement.GetProperty("timestampToken").GetString()));
var checksums = entries["checksums.txt"].Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
Assert.Contains(checksums, line => line.Contains(repository.Bundle.RootHash, StringComparison.Ordinal));
var instructions = entries["instructions.txt"];
Assert.Contains("Timestamped At:", instructions, StringComparison.Ordinal);
Assert.Contains("Validate the RFC3161 timestamp token", instructions, StringComparison.Ordinal);
Assert.True(repository.StorageKeyUpdated);
}
[Fact]
public async Task EnsurePackageAsync_ProducesDeterministicGzipHeader()
{
var repository = new FakeRepository(CreateSealedBundle(), CreateSignature());
var objectStore = new FakeObjectStore(exists: false);
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
await service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None);
Assert.True(objectStore.Stored);
var archiveBytes = objectStore.StoredBytes!;
Assert.True(archiveBytes.Length > 10);
Assert.Equal(0x1f, archiveBytes[0]);
Assert.Equal(0x8b, archiveBytes[1]);
var mtime = BinaryPrimitives.ReadInt32LittleEndian(archiveBytes.AsSpan(4, 4));
var expectedSeconds = (int)(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) - DateTimeOffset.UnixEpoch).TotalSeconds;
Assert.Equal(expectedSeconds, mtime);
}
[Fact]
public async Task EnsurePackageAsync_Throws_WhenManifestPayloadInvalid()
{
var signature = CreateSignature() with { Payload = "not-base64" };
var repository = new FakeRepository(CreateSealedBundle(), signature);
var objectStore = new FakeObjectStore(exists: false);
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None));
Assert.Contains("manifest payload", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task EnsurePackageAsync_Throws_WhenManifestPayloadNotJson()
{
var rawPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("not-json"));
var signature = CreateSignature() with { Payload = rawPayload };
var repository = new FakeRepository(CreateSealedBundle(), signature);
var objectStore = new FakeObjectStore(exists: false);
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None));
Assert.Contains("manifest payload", exception.Message, StringComparison.OrdinalIgnoreCase);
}
private static EvidenceBundle CreateSealedBundle(string? storageKey = null)
=> new(
BundleId,
TenantId,
EvidenceBundleKind.Job,
EvidenceBundleStatus.Sealed,
new string('a', 64),
storageKey ?? $"tenants/{TenantId.Value:N}/bundles/{BundleId.Value:N}/bundle.tgz",
CreatedAt,
CreatedAt,
Description: "test bundle",
SealedAt: CreatedAt.AddMinutes(1),
ExpiresAt: null);
private static EvidenceBundleSignature CreateSignature(bool includeTimestamp = false)
{
var manifest = new
{
bundleId = BundleId.Value.ToString("D"),
tenantId = TenantId.Value.ToString("D"),
kind = (int)EvidenceBundleKind.Job,
createdAt = CreatedAt.ToString("O"),
metadata = new Dictionary<string, string> { ["run"] = "nightly" },
entries = new[]
{
new
{
section = "inputs",
canonicalPath = "inputs/config.json",
sha256 = new string('b', 64),
sizeBytes = 128,
mediaType = "application/json",
attributes = new Dictionary<string, string>()
}
}
};
var manifestJson = JsonSerializer.Serialize(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web));
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifestJson));
return new EvidenceBundleSignature(
BundleId,
TenantId,
"application/vnd.stella.evidence.manifest+json",
payload,
Convert.ToBase64String(Encoding.UTF8.GetBytes("signature")),
"key-1",
"ES256",
"default",
CreatedAt.AddMinutes(1),
TimestampedAt: includeTimestamp ? CreatedAt.AddMinutes(2) : null,
TimestampAuthority: includeTimestamp ? "tsa.default" : null,
TimestampToken: includeTimestamp ? Encoding.UTF8.GetBytes("tsa-token") : null);
}
private static Dictionary<string, string> ReadArchiveEntries(byte[] archiveBytes)
{
using var memory = new MemoryStream(archiveBytes);
using var gzip = new GZipStream(memory, CompressionMode.Decompress, leaveOpen: true);
using var reader = new TarReader(gzip);
var entries = new Dictionary<string, string>(StringComparer.Ordinal);
TarEntry? entry;
while ((entry = reader.GetNextEntry()) is not null)
{
if (entry.EntryType != TarEntryType.RegularFile)
{
continue;
}
using var entryStream = new MemoryStream();
entry.DataStream!.CopyTo(entryStream);
var content = Encoding.UTF8.GetString(entryStream.ToArray());
entries[entry.Name] = content;
}
return entries;
}
private sealed class FakeRepository : IEvidenceBundleRepository
{
private EvidenceBundle _bundle;
public FakeRepository(EvidenceBundle bundle, EvidenceBundleSignature? signature)
{
_bundle = bundle;
Signature = signature;
}
public EvidenceBundle Bundle => _bundle;
public EvidenceBundleSignature? Signature { get; }
public bool StorageKeyUpdated { get; private set; }
public bool PortableStorageKeyUpdated { get; private set; }
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task SetBundleAssemblyAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, string rootHash, DateTimeOffset updatedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task MarkBundleSealedAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, DateTimeOffset sealedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult<EvidenceBundleDetails?>(new EvidenceBundleDetails(_bundle, Signature));
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult(true);
public Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
=> Task.FromResult(hold);
public Task ExtendBundleRetentionAsync(EvidenceBundleId bundleId, TenantId tenantId, DateTimeOffset? holdExpiresAt, DateTimeOffset processedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpdateStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, CancellationToken cancellationToken)
{
StorageKeyUpdated = true;
return Task.CompletedTask;
}
public Task UpdatePortableStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, DateTimeOffset generatedAt, CancellationToken cancellationToken)
{
PortableStorageKeyUpdated = true;
_bundle = _bundle with
{
PortableStorageKey = storageKey,
PortableGeneratedAt = generatedAt
};
return Task.CompletedTask;
}
}
private sealed class FakeObjectStore : IEvidenceObjectStore
{
private readonly bool _exists;
private readonly string? _fixedStorageKey;
public FakeObjectStore(bool exists, string? fixedStorageKey = null)
{
_exists = exists;
_fixedStorageKey = fixedStorageKey;
}
public bool Stored { get; private set; }
public byte[]? StoredBytes { get; private set; }
public Task<EvidenceObjectMetadata> StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken)
{
Stored = true;
using var memory = new MemoryStream();
content.CopyTo(memory);
StoredBytes = memory.ToArray();
var storageKey = _fixedStorageKey ?? $"tenants/{options.TenantId.Value:N}/bundles/{options.BundleId.Value:N}/bundle.tgz";
return Task.FromResult(new EvidenceObjectMetadata(
storageKey,
options.ContentType,
StoredBytes.Length,
Convert.ToHexString(SHA256.HashData(StoredBytes)).ToLowerInvariant(),
ETag: null,
CreatedAt: DateTimeOffset.UtcNow));
}
public Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
{
if (StoredBytes is null)
{
throw new FileNotFoundException("Package not created.");
}
return Task.FromResult<Stream>(new MemoryStream(StoredBytes, writable: false));
}
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
=> Task.FromResult(_exists);
}
}

View File

@@ -0,0 +1,408 @@
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Net.Http.Headers;
using System.Reflection;
using System.Runtime.Serialization;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Signing;
using StellaOps.EvidenceLocker.Core.Incident;
using StellaOps.EvidenceLocker.Core.Timeline;
using StellaOps.EvidenceLocker.Core.Storage;
namespace StellaOps.EvidenceLocker.Tests;
internal sealed class EvidenceLockerWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly string _contentRoot;
public EvidenceLockerWebApplicationFactory()
{
_contentRoot = Path.Combine(Path.GetTempPath(), "evidence-locker-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_contentRoot);
File.WriteAllText(Path.Combine(_contentRoot, "appsettings.json"), "{}");
}
public TestEvidenceBundleRepository Repository => Services.GetRequiredService<TestEvidenceBundleRepository>();
public TestEvidenceObjectStore ObjectStore => Services.GetRequiredService<TestEvidenceObjectStore>();
public TestTimelinePublisher TimelinePublisher => Services.GetRequiredService<TestTimelinePublisher>();
private static SigningKeyMaterialOptions GenerateKeyMaterial()
{
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
return new SigningKeyMaterialOptions
{
EcPrivateKeyPem = ecdsa.ExportECPrivateKeyPem(),
EcPublicKeyPem = ecdsa.ExportSubjectPublicKeyInfoPem()
};
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseSetting(WebHostDefaults.ContentRootKey, _contentRoot);
builder.ConfigureAppConfiguration((context, configurationBuilder) =>
{
configurationBuilder.Sources.Clear();
var keyMaterial = GenerateKeyMaterial();
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["EvidenceLocker:Database:ConnectionString"] = "Host=localhost",
["EvidenceLocker:Database:ApplyMigrationsAtStartup"] = "false",
["EvidenceLocker:ObjectStore:Kind"] = "FileSystem",
["EvidenceLocker:ObjectStore:FileSystem:RootPath"] = ".",
["EvidenceLocker:Quotas:MaxMaterialCount"] = "4",
["EvidenceLocker:Quotas:MaxTotalMaterialSizeBytes"] = "1024",
["EvidenceLocker:Quotas:MaxMetadataEntries"] = "4",
["EvidenceLocker:Quotas:MaxMetadataKeyLength"] = "32",
["EvidenceLocker:Quotas:MaxMetadataValueLength"] = "64",
["EvidenceLocker:Signing:Enabled"] = "true",
["EvidenceLocker:Signing:Algorithm"] = "ES256",
["EvidenceLocker:Signing:KeyId"] = "test-key",
["EvidenceLocker:Signing:PayloadType"] = "application/vnd.stella.test-manifest+json",
["EvidenceLocker:Signing:KeyMaterial:EcPrivateKeyPem"] = keyMaterial.EcPrivateKeyPem,
["EvidenceLocker:Signing:KeyMaterial:EcPublicKeyPem"] = keyMaterial.EcPublicKeyPem,
["EvidenceLocker:Signing:Timestamping:Enabled"] = "true",
["EvidenceLocker:Signing:Timestamping:Endpoint"] = "https://tsa.example",
["EvidenceLocker:Signing:Timestamping:HashAlgorithm"] = "SHA256",
["EvidenceLocker:Incident:Enabled"] = "false",
["EvidenceLocker:Incident:RetentionExtensionDays"] = "30",
["EvidenceLocker:Incident:CaptureRequestSnapshot"] = "true",
["Authority:ResourceServer:Authority"] = "https://authority.localtest.me",
["Authority:ResourceServer:Audiences:0"] = "api://evidence-locker",
["Authority:ResourceServer:RequiredTenants:0"] = "tenant-default"
});
});
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IEvidenceBundleRepository>();
services.RemoveAll<IEvidenceTimelinePublisher>();
services.RemoveAll<ITimestampAuthorityClient>();
services.RemoveAll<IEvidenceObjectStore>();
services.RemoveAll<IConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IPostConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IConfigureOptions<JwtBearerOptions>>();
services.RemoveAll<IPostConfigureOptions<JwtBearerOptions>>();
services.AddSingleton<TestEvidenceBundleRepository>();
services.AddSingleton<IEvidenceBundleRepository>(sp => sp.GetRequiredService<TestEvidenceBundleRepository>());
services.AddSingleton<TestTimelinePublisher>();
services.AddSingleton<IEvidenceTimelinePublisher>(sp => sp.GetRequiredService<TestTimelinePublisher>());
services.AddSingleton<ITimestampAuthorityClient, TestTimestampAuthorityClient>();
services.AddSingleton<TestEvidenceObjectStore>();
services.AddSingleton<IEvidenceObjectStore>(sp => sp.GetRequiredService<TestEvidenceObjectStore>());
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = EvidenceLockerTestAuthHandler.SchemeName;
options.DefaultChallengeScheme = EvidenceLockerTestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, EvidenceLockerTestAuthHandler>(EvidenceLockerTestAuthHandler.SchemeName, _ => { })
.AddScheme<AuthenticationSchemeOptions, EvidenceLockerTestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
services.PostConfigure<AuthorizationOptions>(options =>
{
var allowAllPolicy = new AuthorizationPolicyBuilder()
.AddAuthenticationSchemes(EvidenceLockerTestAuthHandler.SchemeName)
.RequireAssertion(_ => true)
.Build();
options.DefaultPolicy = allowAllPolicy;
options.FallbackPolicy = allowAllPolicy;
options.AddPolicy(StellaOpsResourceServerPolicies.EvidenceCreate, allowAllPolicy);
options.AddPolicy(StellaOpsResourceServerPolicies.EvidenceRead, allowAllPolicy);
options.AddPolicy(StellaOpsResourceServerPolicies.EvidenceHold, allowAllPolicy);
});
});
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing && Directory.Exists(_contentRoot))
{
Directory.Delete(_contentRoot, recursive: true);
}
}
}
internal sealed class TestTimestampAuthorityClient : ITimestampAuthorityClient
{
public Task<TimestampResult?> RequestTimestampAsync(ReadOnlyMemory<byte> signature, string hashAlgorithm, CancellationToken cancellationToken)
{
var token = signature.ToArray();
var result = new TimestampResult(DateTimeOffset.UtcNow, "test-tsa", token);
return Task.FromResult<TimestampResult?>(result);
}
}
internal sealed class TestTimelinePublisher : IEvidenceTimelinePublisher
{
public List<string> PublishedEvents { get; } = new();
public List<string> IncidentEvents { get; } = new();
public Task PublishBundleSealedAsync(
EvidenceBundleSignature signature,
EvidenceBundleManifest manifest,
string rootHash,
CancellationToken cancellationToken)
{
PublishedEvents.Add($"bundle:{signature.BundleId.Value:D}:{rootHash}");
return Task.CompletedTask;
}
public Task PublishHoldCreatedAsync(EvidenceHold hold, CancellationToken cancellationToken)
{
PublishedEvents.Add($"hold:{hold.CaseId}");
return Task.CompletedTask;
}
public Task PublishIncidentModeChangedAsync(IncidentModeChange change, CancellationToken cancellationToken)
{
IncidentEvents.Add(change.IsActive ? "enabled" : "disabled");
return Task.CompletedTask;
}
}
internal sealed class TestEvidenceObjectStore : IEvidenceObjectStore
{
private readonly Dictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
private readonly HashSet<string> _preExisting = new(StringComparer.Ordinal);
public IReadOnlyDictionary<string, byte[]> StoredObjects => _objects;
public void SeedExisting(string storageKey) => _preExisting.Add(storageKey);
public Task<EvidenceObjectMetadata> StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken)
{
using var memory = new MemoryStream();
content.CopyTo(memory);
var bytes = memory.ToArray();
var storageKey = $"tenants/{options.TenantId.Value:N}/bundles/{options.BundleId.Value:N}/{options.ArtifactName}";
_objects[storageKey] = bytes;
_preExisting.Add(storageKey);
return Task.FromResult(new EvidenceObjectMetadata(
storageKey,
options.ContentType,
bytes.Length,
Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(),
null,
DateTimeOffset.UtcNow));
}
public Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
{
if (!_objects.TryGetValue(storageKey, out var bytes))
{
throw new FileNotFoundException(storageKey);
}
return Task.FromResult<Stream>(new MemoryStream(bytes, writable: false));
}
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
=> Task.FromResult(_preExisting.Contains(storageKey));
}
internal sealed class TestEvidenceBundleRepository : IEvidenceBundleRepository
{
private readonly List<EvidenceBundleSignature> _signatures = new();
private readonly Dictionary<(Guid BundleId, Guid TenantId), EvidenceBundle> _bundles = new();
public bool HoldConflict { get; set; }
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
{
_bundles[(bundle.Id.Value, bundle.TenantId.Value)] = bundle;
return Task.CompletedTask;
}
public Task SetBundleAssemblyAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, string rootHash, DateTimeOffset updatedAt, CancellationToken cancellationToken)
{
UpdateBundle(bundleId, tenantId, bundle => bundle with
{
Status = status,
RootHash = rootHash,
UpdatedAt = updatedAt
});
return Task.CompletedTask;
}
public Task MarkBundleSealedAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, DateTimeOffset sealedAt, CancellationToken cancellationToken)
{
UpdateBundle(bundleId, tenantId, bundle => bundle with
{
Status = status,
SealedAt = sealedAt,
UpdatedAt = sealedAt
});
return Task.CompletedTask;
}
public Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
{
_signatures.RemoveAll(sig => sig.BundleId == signature.BundleId && sig.TenantId == signature.TenantId);
_signatures.Add(signature);
return Task.CompletedTask;
}
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
{
_bundles.TryGetValue((bundleId.Value, tenantId.Value), out var bundle);
var signature = _signatures.FirstOrDefault(sig => sig.BundleId == bundleId && sig.TenantId == tenantId);
return Task.FromResult<EvidenceBundleDetails?>(bundle is null ? null : new EvidenceBundleDetails(bundle, signature));
}
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult(_bundles.ContainsKey((bundleId.Value, tenantId.Value)));
public Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
{
if (HoldConflict)
{
throw CreateUniqueViolationException();
}
return Task.FromResult(hold);
}
public Task ExtendBundleRetentionAsync(EvidenceBundleId bundleId, TenantId tenantId, DateTimeOffset? holdExpiresAt, DateTimeOffset processedAt, CancellationToken cancellationToken)
{
UpdateBundle(bundleId, tenantId, bundle => bundle with
{
ExpiresAt = holdExpiresAt,
UpdatedAt = processedAt > bundle.UpdatedAt ? processedAt : bundle.UpdatedAt
});
return Task.CompletedTask;
}
public Task UpdateStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, CancellationToken cancellationToken)
{
UpdateBundle(bundleId, tenantId, bundle => bundle with
{
StorageKey = storageKey,
UpdatedAt = DateTimeOffset.UtcNow
});
return Task.CompletedTask;
}
public Task UpdatePortableStorageKeyAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
string storageKey,
DateTimeOffset generatedAt,
CancellationToken cancellationToken)
{
UpdateBundle(bundleId, tenantId, bundle => bundle with
{
PortableStorageKey = storageKey,
PortableGeneratedAt = generatedAt,
UpdatedAt = generatedAt > bundle.UpdatedAt ? generatedAt : bundle.UpdatedAt
});
return Task.CompletedTask;
}
private void UpdateBundle(EvidenceBundleId bundleId, TenantId tenantId, Func<EvidenceBundle, EvidenceBundle> updater)
{
var key = (bundleId.Value, tenantId.Value);
if (_bundles.TryGetValue(key, out var existing))
{
_bundles[key] = updater(existing);
}
}
#pragma warning disable SYSLIB0050
private static PostgresException CreateUniqueViolationException()
{
var exception = (PostgresException)FormatterServices.GetUninitializedObject(typeof(PostgresException));
SetStringField(exception, "<SqlState>k__BackingField", PostgresErrorCodes.UniqueViolation);
SetStringField(exception, "_sqlState", PostgresErrorCodes.UniqueViolation);
return exception;
}
#pragma warning restore SYSLIB0050
private static void SetStringField(object target, string fieldName, string value)
{
var field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic);
field?.SetValue(target, value);
}
}
internal sealed class EvidenceLockerTestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
internal const string SchemeName = "EvidenceLockerTest";
public EvidenceLockerTestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("Authorization", out var rawHeader) ||
!AuthenticationHeaderValue.TryParse(rawHeader, out var header) ||
!string.Equals(header.Scheme, SchemeName, StringComparison.Ordinal))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var claims = new List<Claim>();
var subject = Request.Headers.TryGetValue("X-Test-Subject", out var subjectValue)
? subjectValue.ToString()
: "subject-test";
claims.Add(new Claim(StellaOpsClaimTypes.Subject, subject));
if (Request.Headers.TryGetValue("X-Test-Client", out var clientValue) &&
!string.IsNullOrWhiteSpace(clientValue))
{
claims.Add(new Claim(StellaOpsClaimTypes.ClientId, clientValue.ToString()!));
}
if (Request.Headers.TryGetValue("X-Test-Tenant", out var tenantValue) &&
Guid.TryParse(tenantValue, out var tenantId))
{
claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenantId.ToString("D")));
}
if (Request.Headers.TryGetValue("X-Test-Scopes", out var scopesValue))
{
var scopes = scopesValue
.ToString()
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var scope in scopes)
{
claims.Add(new Claim(StellaOpsClaimTypes.Scope, scope));
}
}
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@@ -0,0 +1,355 @@
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Formats.Tar;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Linq;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.WebService.Contracts;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidenceLockerWebServiceTests
{
[Fact]
public async Task Snapshot_ReturnsSignatureAndEmitsTimeline()
{
using var factory = new EvidenceLockerWebApplicationFactory();
using var client = factory.CreateClient();
var tenantId = Guid.NewGuid().ToString("D");
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
kind = (int)EvidenceBundleKind.Evaluation,
metadata = new Dictionary<string, string>
{
["run"] = "daily",
["orchestratorJobId"] = "job-123"
},
materials = new[]
{
new { section = "inputs", path = "config.json", sha256 = new string('a', 64), sizeBytes = 256L, mediaType = "application/json" }
}
};
var snapshotResponse = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
snapshotResponse.EnsureSuccessStatusCode();
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<EvidenceSnapshotResponseDto>(TestContext.Current.CancellationToken);
Assert.NotNull(snapshot);
Assert.NotEqual(Guid.Empty, snapshot!.BundleId);
Assert.False(string.IsNullOrEmpty(snapshot.RootHash));
Assert.NotNull(snapshot.Signature);
Assert.False(string.IsNullOrEmpty(snapshot.Signature!.Signature));
Assert.NotNull(snapshot.Signature.TimestampToken);
var timelineEvent = Assert.Single(factory.TimelinePublisher.PublishedEvents);
Assert.Contains(snapshot.BundleId.ToString("D"), timelineEvent);
Assert.Contains(snapshot.RootHash, timelineEvent);
var bundle = await client.GetFromJsonAsync<EvidenceBundleResponseDto>($"/evidence/{snapshot.BundleId}", TestContext.Current.CancellationToken);
Assert.NotNull(bundle);
Assert.Equal(snapshot.RootHash, bundle!.RootHash);
Assert.NotNull(bundle.Signature);
Assert.Equal(snapshot.Signature.Signature, bundle.Signature!.Signature);
Assert.Equal(snapshot.Signature.TimestampToken, bundle.Signature.TimestampToken);
}
[Fact]
public async Task Snapshot_WithIncidentModeActive_ExtendsRetentionAndCapturesDebugArtifact()
{
using var baseFactory = new EvidenceLockerWebApplicationFactory();
using var factory = baseFactory.WithWebHostBuilder(
builder => builder.ConfigureAppConfiguration((_, configurationBuilder) =>
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["EvidenceLocker:Incident:Enabled"] = "true",
["EvidenceLocker:Incident:RetentionExtensionDays"] = "60",
["EvidenceLocker:Incident:CaptureRequestSnapshot"] = "true"
})));
using var client = factory.CreateClient();
var optionsMonitor = factory.Services.GetRequiredService<IOptionsMonitor<EvidenceLockerOptions>>();
Assert.True(optionsMonitor.CurrentValue.Incident.Enabled);
Assert.Equal(60, optionsMonitor.CurrentValue.Incident.RetentionExtensionDays);
var tenantId = Guid.NewGuid().ToString("D");
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
kind = (int)EvidenceBundleKind.Job,
metadata = new Dictionary<string, string> { ["run"] = "incident" },
materials = new[]
{
new { section = "inputs", path = "config.json", sha256 = new string('b', 64), sizeBytes = 64L, mediaType = "application/json" }
}
};
var snapshotResponse = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
snapshotResponse.EnsureSuccessStatusCode();
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<EvidenceSnapshotResponseDto>(TestContext.Current.CancellationToken);
Assert.NotNull(snapshot);
var bundle = await client.GetFromJsonAsync<EvidenceBundleResponseDto>($"/evidence/{snapshot!.BundleId}", TestContext.Current.CancellationToken);
Assert.NotNull(bundle);
Assert.NotNull(bundle!.ExpiresAt);
Assert.True(bundle.ExpiresAt > bundle.CreatedAt);
var objectStore = factory.Services.GetRequiredService<TestEvidenceObjectStore>();
var timeline = factory.Services.GetRequiredService<TestTimelinePublisher>();
Assert.Contains(objectStore.StoredObjects.Keys, key => key.Contains("/incident/request-", StringComparison.Ordinal));
Assert.Contains("enabled", timeline.IncidentEvents);
}
[Fact]
public async Task Download_ReturnsPackageStream()
{
using var factory = new EvidenceLockerWebApplicationFactory();
using var client = factory.CreateClient();
var tenantId = Guid.NewGuid().ToString("D");
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
kind = (int)EvidenceBundleKind.Evaluation,
metadata = new Dictionary<string, string> { ["run"] = "nightly" },
materials = new[]
{
new { section = "inputs", path = "config.json", sha256 = new string('a', 64), sizeBytes = 128L, mediaType = "application/json" }
}
};
var snapshotResponse = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
snapshotResponse.EnsureSuccessStatusCode();
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<EvidenceSnapshotResponseDto>(TestContext.Current.CancellationToken);
Assert.NotNull(snapshot);
var downloadResponse = await client.GetAsync($"/evidence/{snapshot!.BundleId}/download", TestContext.Current.CancellationToken);
downloadResponse.EnsureSuccessStatusCode();
Assert.Equal("application/gzip", downloadResponse.Content.Headers.ContentType?.MediaType);
var archiveBytes = await downloadResponse.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
var mtime = BinaryPrimitives.ReadInt32LittleEndian(archiveBytes.AsSpan(4, 4));
var expectedSeconds = (int)(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) - DateTimeOffset.UnixEpoch).TotalSeconds;
Assert.Equal(expectedSeconds, mtime);
var entries = ReadArchiveEntries(archiveBytes);
Assert.Contains("manifest.json", entries.Keys);
Assert.Contains("signature.json", entries.Keys);
Assert.Contains("instructions.txt", entries.Keys);
using var manifestDoc = JsonDocument.Parse(entries["manifest.json"]);
Assert.Equal(snapshot.BundleId.ToString(), manifestDoc.RootElement.GetProperty("bundleId").GetString());
var instructions = entries["instructions.txt"];
Assert.Contains("Evidence Bundle Instructions", instructions, StringComparison.Ordinal);
Assert.Contains("Validate `signature.json`", instructions, StringComparison.Ordinal);
Assert.Contains("Review `checksums.txt`", instructions, StringComparison.Ordinal);
if (instructions.Contains("Timestamped At:", StringComparison.Ordinal))
{
Assert.Contains("Validate the RFC3161 timestamp token", instructions, StringComparison.Ordinal);
}
Assert.NotEmpty(factory.ObjectStore.StoredObjects);
}
[Fact]
public async Task PortableDownload_ReturnsSanitizedBundle()
{
using var factory = new EvidenceLockerWebApplicationFactory();
using var client = factory.CreateClient();
var tenantId = Guid.NewGuid().ToString("D");
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
kind = (int)EvidenceBundleKind.Export,
metadata = new Dictionary<string, string> { ["pipeline"] = "sealed" },
materials = new[]
{
new { section = "inputs", path = "artifact.txt", sha256 = new string('d', 64), sizeBytes = 256L, mediaType = "text/plain" }
}
};
var snapshotResponse = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
snapshotResponse.EnsureSuccessStatusCode();
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<EvidenceSnapshotResponseDto>(TestContext.Current.CancellationToken);
Assert.NotNull(snapshot);
var portableResponse = await client.GetAsync($"/evidence/{snapshot!.BundleId}/portable", TestContext.Current.CancellationToken);
portableResponse.EnsureSuccessStatusCode();
Assert.Equal("application/gzip", portableResponse.Content.Headers.ContentType?.MediaType);
var archiveBytes = await portableResponse.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
var entries = ReadArchiveEntries(archiveBytes);
Assert.Contains("bundle.json", entries.Keys);
Assert.Contains("instructions-portable.txt", entries.Keys);
Assert.Contains("verify-offline.sh", entries.Keys);
using var bundleDoc = JsonDocument.Parse(entries["bundle.json"]);
var bundleRoot = bundleDoc.RootElement;
Assert.False(bundleRoot.TryGetProperty("tenantId", out _));
Assert.False(bundleRoot.TryGetProperty("storageKey", out _));
Assert.True(bundleRoot.TryGetProperty("portableGeneratedAt", out _));
var script = entries["verify-offline.sh"];
Assert.StartsWith("#!/usr/bin/env sh", script, StringComparison.Ordinal);
Assert.Contains("sha256sum", script, StringComparison.Ordinal);
Assert.Contains("stella evidence verify", script, StringComparison.Ordinal);
}
[Fact]
public async Task Snapshot_ReturnsValidationError_WhenQuotaExceeded()
{
using var factory = new EvidenceLockerWebApplicationFactory();
using var client = factory.CreateClient();
var tenantId = Guid.NewGuid().ToString("D");
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
kind = (int)EvidenceBundleKind.Job,
materials = new[]
{
new { section = "inputs", path = "layer0.tar", sha256 = new string('a', 64), sizeBytes = 900L },
new { section = "inputs", path = "layer1.tar", sha256 = new string('b', 64), sizeBytes = 300L }
}
};
var response = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
var responseContent = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.BadRequest, $"Expected 400 but received {(int)response.StatusCode}: {responseContent}");
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(TestContext.Current.CancellationToken);
Assert.NotNull(problem);
Assert.True(problem!.Errors.TryGetValue("message", out var messages));
Assert.Contains(messages, m => m.Contains("exceeds", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Snapshot_ReturnsForbidden_WhenTenantMissing()
{
using var factory = new EvidenceLockerWebApplicationFactory();
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(EvidenceLockerTestAuthHandler.SchemeName);
client.DefaultRequestHeaders.Add("X-Test-Scopes", $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
var payload = new
{
kind = (int)EvidenceBundleKind.Evaluation,
materials = new[]
{
new { section = "inputs", path = "input.txt", sha256 = "abc123", sizeBytes = 1L }
}
};
var response = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
var responseContent = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.Forbidden, $"Expected 403 but received {(int)response.StatusCode}: {responseContent}");
}
[Fact]
public async Task Hold_ReturnsConflict_WhenCaseAlreadyExists()
{
using var factory = new EvidenceLockerWebApplicationFactory();
using var client = factory.CreateClient();
var repository = factory.Repository;
repository.HoldConflict = true;
var tenantId = Guid.NewGuid().ToString("D");
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceHold} {StellaOpsScopes.EvidenceRead}");
var response = await client.PostAsJsonAsync(
"/evidence/hold/case-123",
new
{
reason = "legal-hold"
},
TestContext.Current.CancellationToken);
var responseContent = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.BadRequest, $"Expected 400 but received {(int)response.StatusCode}: {responseContent}");
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(TestContext.Current.CancellationToken);
Assert.NotNull(problem);
Assert.True(problem!.Errors.TryGetValue("message", out var messages));
Assert.Contains(messages, m => m.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0);
}
[Fact]
public async Task Hold_CreatesTimelineEvent()
{
using var factory = new EvidenceLockerWebApplicationFactory();
using var client = factory.CreateClient();
var tenantId = Guid.NewGuid().ToString("D");
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceHold} {StellaOpsScopes.EvidenceRead}");
var response = await client.PostAsJsonAsync(
"/evidence/hold/case-789",
new
{
reason = "retention",
notes = "retain for investigation"
},
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var hold = await response.Content.ReadFromJsonAsync<EvidenceHoldResponseDto>(TestContext.Current.CancellationToken);
Assert.NotNull(hold);
Assert.Contains($"hold:{hold!.CaseId}", factory.TimelinePublisher.PublishedEvents);
}
private static Dictionary<string, string> ReadArchiveEntries(byte[] archiveBytes)
{
using var memory = new MemoryStream(archiveBytes);
using var gzip = new GZipStream(memory, CompressionMode.Decompress, leaveOpen: true);
using var reader = new TarReader(gzip);
var entries = new Dictionary<string, string>(StringComparer.Ordinal);
TarEntry? entry;
while ((entry = reader.GetNextEntry()) is not null)
{
if (entry.EntryType != TarEntryType.RegularFile)
{
continue;
}
using var entryStream = new MemoryStream();
entry.DataStream!.CopyTo(entryStream);
var content = Encoding.UTF8.GetString(entryStream.ToArray());
entries[entry.Name] = content;
}
return entries;
}
private static void ConfigureAuthHeaders(HttpClient client, string tenantId, string scopes)
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(EvidenceLockerTestAuthHandler.SchemeName);
client.DefaultRequestHeaders.Add("X-Test-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-Test-Scopes", scopes);
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
}
}

View File

@@ -0,0 +1,294 @@
using System.Formats.Tar;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Infrastructure.Services;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidencePortableBundleServiceTests
{
private static readonly TenantId TenantId = TenantId.FromGuid(Guid.NewGuid());
private static readonly EvidenceBundleId BundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
private static readonly DateTimeOffset CreatedAt = new(2025, 11, 4, 10, 30, 0, TimeSpan.Zero);
[Fact]
public async Task EnsurePortablePackageAsync_ReturnsCached_WhenObjectExists()
{
var bundle = CreateSealedBundle(
portableStorageKey: "tenants/foo/bundles/bar/portable-bundle-v1.tgz",
portableGeneratedAt: CreatedAt);
var repository = new FakeRepository(bundle, CreateSignature());
var objectStore = new FakeObjectStore(exists: true);
var service = CreateService(repository, objectStore);
var result = await service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None);
Assert.False(result.Created);
Assert.Equal(bundle.RootHash, result.RootHash);
Assert.Equal(bundle.PortableStorageKey, result.StorageKey);
Assert.False(objectStore.Stored);
Assert.False(repository.PortableStorageKeyUpdated);
}
[Fact]
public async Task EnsurePortablePackageAsync_CreatesPortableArchiveWithRedactedMetadata()
{
var repository = new FakeRepository(CreateSealedBundle(), CreateSignature(includeTimestamp: true));
var objectStore = new FakeObjectStore(exists: false, fixedStorageKey: "tenants/foo/bundles/bar/portable-bundle-v1.tgz");
var service = CreateService(repository, objectStore);
var result = await service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None);
Assert.True(result.Created);
Assert.True(objectStore.Stored);
Assert.NotNull(objectStore.StoredBytes);
Assert.True(repository.PortableStorageKeyUpdated);
Assert.NotNull(repository.Bundle.PortableStorageKey);
Assert.NotNull(repository.Bundle.PortableGeneratedAt);
var entries = ReadArchiveEntries(objectStore.StoredBytes!);
Assert.Contains("manifest.json", entries.Keys);
Assert.Contains("signature.json", entries.Keys);
Assert.Contains("bundle.json", entries.Keys);
Assert.Contains("instructions-portable.txt", entries.Keys);
Assert.Contains("verify-offline.sh", entries.Keys);
using var bundleJson = JsonDocument.Parse(entries["bundle.json"]);
var root = bundleJson.RootElement;
Assert.False(root.TryGetProperty("tenantId", out _));
Assert.False(root.TryGetProperty("storageKey", out _));
Assert.False(root.TryGetProperty("description", out _));
Assert.Equal(repository.Bundle.Id.Value.ToString("D"), root.GetProperty("bundleId").GetString());
Assert.Equal(repository.Bundle.RootHash, root.GetProperty("rootHash").GetString());
Assert.True(root.TryGetProperty("portableGeneratedAt", out var generatedAtProperty));
Assert.True(DateTimeOffset.TryParse(generatedAtProperty.GetString(), out _));
var incidentMetadata = root.GetProperty("incidentMetadata");
Assert.Equal(JsonValueKind.Object, incidentMetadata.ValueKind);
Assert.True(incidentMetadata.EnumerateObject().Any(p => p.Name.StartsWith("incident.", StringComparison.Ordinal)));
var instructions = entries["instructions-portable.txt"];
Assert.Contains("Portable Evidence Bundle Instructions", instructions, StringComparison.Ordinal);
Assert.Contains("verify-offline.sh", instructions, StringComparison.Ordinal);
var script = entries["verify-offline.sh"];
Assert.StartsWith("#!/usr/bin/env sh", script, StringComparison.Ordinal);
Assert.Contains("sha256sum", script, StringComparison.Ordinal);
Assert.Contains("stella evidence verify", script, StringComparison.Ordinal);
}
[Fact]
public async Task EnsurePortablePackageAsync_Throws_WhenSignatureMissing()
{
var repository = new FakeRepository(CreateSealedBundle(), signature: null);
var objectStore = new FakeObjectStore(exists: false);
var service = CreateService(repository, objectStore);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None));
}
private static EvidencePortableBundleService CreateService(FakeRepository repository, IEvidenceObjectStore objectStore)
{
var options = Options.Create(new EvidenceLockerOptions
{
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
ObjectStore = new ObjectStoreOptions { Kind = ObjectStoreKind.FileSystem, FileSystem = new FileSystemStoreOptions { RootPath = "." } },
Quotas = new QuotaOptions(),
Signing = new SigningOptions(),
Portable = new PortableOptions()
});
return new EvidencePortableBundleService(
repository,
objectStore,
options,
TimeProvider.System,
NullLogger<EvidencePortableBundleService>.Instance);
}
private static EvidenceBundle CreateSealedBundle(
string? portableStorageKey = null,
DateTimeOffset? portableGeneratedAt = null)
=> new EvidenceBundle(
BundleId,
TenantId,
EvidenceBundleKind.Evaluation,
EvidenceBundleStatus.Sealed,
new string('f', 64),
"tenants/foo/bundles/bar/bundle.tgz",
CreatedAt,
CreatedAt,
Description: "sensitive",
SealedAt: CreatedAt.AddMinutes(5),
ExpiresAt: CreatedAt.AddDays(30),
PortableStorageKey: portableStorageKey,
PortableGeneratedAt: portableGeneratedAt);
private static EvidenceBundleSignature CreateSignature(bool includeTimestamp = false)
{
var manifest = new
{
bundleId = BundleId.Value,
tenantId = TenantId.Value,
kind = (int)EvidenceBundleKind.Evaluation,
createdAt = CreatedAt,
metadata = new Dictionary<string, string>
{
["pipeline"] = "ops",
["incident.mode"] = "enabled",
["incident.changedAt"] = CreatedAt.ToString("O"),
["incident.retentionExtensionDays"] = "60"
},
entries = new[]
{
new
{
section = "inputs",
canonicalPath = "inputs/config.json",
sha256 = new string('a', 64),
sizeBytes = 128L,
mediaType = "application/json",
attributes = new Dictionary<string, string>()
}
}
};
var payload = JsonSerializer.Serialize(manifest);
return new EvidenceBundleSignature(
BundleId,
TenantId,
"application/vnd.stella.evidence.manifest+json",
Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)),
"sig-payload",
"key-id",
"ES256",
"provider",
CreatedAt,
includeTimestamp ? CreatedAt.AddMinutes(1) : null,
includeTimestamp ? "tsa.default" : null,
includeTimestamp ? Encoding.UTF8.GetBytes("tsa-token") : null);
}
private static IReadOnlyDictionary<string, string> ReadArchiveEntries(byte[] archive)
{
using var memory = new MemoryStream(archive);
using var gzip = new GZipStream(memory, CompressionMode.Decompress);
using var tarReader = new TarReader(gzip);
var entries = new Dictionary<string, string>(StringComparer.Ordinal);
TarEntry? entry;
while ((entry = tarReader.GetNextEntry()) is not null)
{
if (entry.EntryType != TarEntryType.RegularFile)
{
continue;
}
using var entryStream = new MemoryStream();
entry.DataStream!.CopyTo(entryStream);
entries[entry.Name] = Encoding.UTF8.GetString(entryStream.ToArray());
}
return entries;
}
private sealed class FakeRepository : IEvidenceBundleRepository
{
private EvidenceBundle _bundle;
public FakeRepository(EvidenceBundle bundle, EvidenceBundleSignature? signature)
{
_bundle = bundle;
Signature = signature;
}
public EvidenceBundle Bundle => _bundle;
public EvidenceBundleSignature? Signature { get; }
public bool PortableStorageKeyUpdated { get; private set; }
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task SetBundleAssemblyAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, string rootHash, DateTimeOffset updatedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task MarkBundleSealedAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, DateTimeOffset sealedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult<EvidenceBundleDetails?>(new EvidenceBundleDetails(_bundle, Signature));
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult(true);
public Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
=> Task.FromResult(hold);
public Task ExtendBundleRetentionAsync(EvidenceBundleId bundleId, TenantId tenantId, DateTimeOffset? holdExpiresAt, DateTimeOffset processedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpdateStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpdatePortableStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, DateTimeOffset generatedAt, CancellationToken cancellationToken)
{
PortableStorageKeyUpdated = true;
_bundle = _bundle with
{
PortableStorageKey = storageKey,
PortableGeneratedAt = generatedAt
};
return Task.CompletedTask;
}
}
private sealed class FakeObjectStore : IEvidenceObjectStore
{
private readonly bool _exists;
private readonly string? _fixedStorageKey;
public FakeObjectStore(bool exists, string? fixedStorageKey = null)
{
_exists = exists;
_fixedStorageKey = fixedStorageKey;
}
public bool Stored { get; private set; }
public byte[]? StoredBytes { get; private set; }
public Task<EvidenceObjectMetadata> StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken)
{
Stored = true;
using var memory = new MemoryStream();
content.CopyTo(memory);
StoredBytes = memory.ToArray();
var storageKey = _fixedStorageKey ?? $"tenants/{options.TenantId.Value:N}/bundles/{options.BundleId.Value:N}/portable-bundle-v1.tgz";
return Task.FromResult(new EvidenceObjectMetadata(
storageKey,
options.ContentType,
StoredBytes.Length,
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(StoredBytes)).ToLowerInvariant(),
ETag: null,
CreatedAt: DateTimeOffset.UtcNow));
}
public Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
=> Task.FromResult<Stream>(new MemoryStream(StoredBytes ?? Array.Empty<byte>()));
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
=> Task.FromResult(_exists);
}
}

View File

@@ -0,0 +1,269 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Signing;
using StellaOps.EvidenceLocker.Infrastructure.Signing;
using Xunit;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidenceSignatureServiceTests
{
private static readonly SigningKeyMaterialOptions TestKeyMaterial = CreateKeyMaterial();
[Fact]
public async Task SignManifestAsync_SignsManifestWithoutTimestamp_WhenTimestampingDisabled()
{
var timestampClient = new FakeTimestampAuthorityClient();
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 11, 3, 10, 0, 0, TimeSpan.Zero));
var service = CreateService(timestampClient, timeProvider);
var manifest = CreateManifest();
var signature = await service.SignManifestAsync(
manifest.BundleId,
manifest.TenantId,
manifest,
CancellationToken.None);
Assert.NotNull(signature);
Assert.Equal("application/vnd.stella.test+json", signature.PayloadType);
Assert.NotNull(signature.Payload);
Assert.NotEmpty(signature.Signature);
Assert.Null(signature.TimestampedAt);
Assert.Null(signature.TimestampAuthority);
Assert.Null(signature.TimestampToken);
Assert.Equal(0, timestampClient.CallCount);
}
[Fact]
public async Task SignManifestAsync_AttachesTimestamp_WhenAuthorityClientSucceeds()
{
var timestampClient = new FakeTimestampAuthorityClient
{
Result = new TimestampResult(
new DateTimeOffset(2025, 11, 3, 10, 0, 5, TimeSpan.Zero),
"CN=Test TSA",
new byte[] { 1, 2, 3 })
};
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 11, 3, 10, 0, 0, TimeSpan.Zero));
var signingOptions = CreateSigningOptions(timestamping: new TimestampingOptions
{
Enabled = true,
Endpoint = "https://tsa.example",
HashAlgorithm = "SHA256"
});
var service = CreateService(timestampClient, timeProvider, signingOptions);
var manifest = CreateManifest();
var signature = await service.SignManifestAsync(
manifest.BundleId,
manifest.TenantId,
manifest,
CancellationToken.None);
Assert.NotNull(signature);
Assert.Equal(timestampClient.Result!.Timestamp, signature.TimestampedAt);
Assert.Equal(timestampClient.Result.Authority, signature.TimestampAuthority);
Assert.Equal(timestampClient.Result.Token, signature.TimestampToken);
Assert.Equal(1, timestampClient.CallCount);
}
[Fact]
public async Task SignManifestAsync_Throws_WhenTimestampRequiredAndClientFails()
{
var timestampClient = new FakeTimestampAuthorityClient
{
Exception = new InvalidOperationException("TSA offline")
};
var signingOptions = CreateSigningOptions(timestamping: new TimestampingOptions
{
Enabled = true,
Endpoint = "https://tsa.example",
HashAlgorithm = "SHA256",
RequireTimestamp = true
});
var service = CreateService(timestampClient, new TestTimeProvider(DateTimeOffset.UtcNow), signingOptions);
var manifest = CreateManifest();
await Assert.ThrowsAsync<InvalidOperationException>(() => service.SignManifestAsync(
manifest.BundleId,
manifest.TenantId,
manifest,
CancellationToken.None));
}
[Fact]
public async Task SignManifestAsync_ProducesDeterministicPayload()
{
var timestampClient = new FakeTimestampAuthorityClient();
var service = CreateService(timestampClient, new TestTimeProvider(DateTimeOffset.UtcNow));
var sharedBundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var sharedTenantId = TenantId.FromGuid(Guid.NewGuid());
var manifestA = CreateManifest(
metadataOrder: new[] { ("zeta", "1"), ("alpha", "2") },
bundleId: sharedBundleId,
tenantId: sharedTenantId);
var manifestB = CreateManifest(
metadataOrder: new[] { ("alpha", "2"), ("zeta", "1") },
bundleId: sharedBundleId,
tenantId: sharedTenantId);
var signatureA = await service.SignManifestAsync(
manifestA.BundleId,
manifestA.TenantId,
manifestA,
CancellationToken.None);
var signatureB = await service.SignManifestAsync(
manifestB.BundleId,
manifestB.TenantId,
manifestB,
CancellationToken.None);
Assert.NotNull(signatureA);
Assert.NotNull(signatureB);
var payloadA = Encoding.UTF8.GetString(Convert.FromBase64String(signatureA!.Payload));
var payloadB = Encoding.UTF8.GetString(Convert.FromBase64String(signatureB!.Payload));
Assert.Equal(payloadA, payloadB);
using var document = JsonDocument.Parse(payloadA);
var metadataElement = document.RootElement.GetProperty("metadata");
using var enumerator = metadataElement.EnumerateObject();
Assert.True(enumerator.MoveNext());
Assert.Equal("alpha", enumerator.Current.Name);
Assert.True(enumerator.MoveNext());
Assert.Equal("zeta", enumerator.Current.Name);
}
private static EvidenceSignatureService CreateService(
ITimestampAuthorityClient timestampAuthorityClient,
TimeProvider timeProvider,
SigningOptions? signingOptions = null)
{
var registry = new CryptoProviderRegistry(new ICryptoProvider[] { new DefaultCryptoProvider() });
var options = Options.Create(new EvidenceLockerOptions
{
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
ObjectStore = new ObjectStoreOptions
{
Kind = ObjectStoreKind.FileSystem,
FileSystem = new FileSystemStoreOptions { RootPath = "." }
},
Quotas = new QuotaOptions(),
Signing = signingOptions ?? CreateSigningOptions()
});
return new EvidenceSignatureService(
registry,
timestampAuthorityClient,
options,
timeProvider,
NullLogger<EvidenceSignatureService>.Instance);
}
private static SigningOptions CreateSigningOptions(TimestampingOptions? timestamping = null)
=> new()
{
Enabled = true,
Algorithm = SignatureAlgorithms.Es256,
KeyId = "test-key",
PayloadType = "application/vnd.stella.test+json",
KeyMaterial = TestKeyMaterial,
Timestamping = timestamping
};
private static SigningKeyMaterialOptions CreateKeyMaterial()
{
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var privatePem = ecdsa.ExportECPrivateKeyPem();
var publicPem = ecdsa.ExportSubjectPublicKeyInfoPem();
return new SigningKeyMaterialOptions
{
EcPrivateKeyPem = privatePem,
EcPublicKeyPem = publicPem
};
}
private static EvidenceBundleManifest CreateManifest(
(string key, string value)[]? metadataOrder = null,
EvidenceBundleId? bundleId = null,
TenantId? tenantId = null)
{
metadataOrder ??= new[] { ("alpha", "1"), ("beta", "2") };
var metadataDictionary = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var (key, value) in metadataOrder)
{
metadataDictionary[key] = value;
}
var metadata = new ReadOnlyDictionary<string, string>(metadataDictionary);
var attributesDictionary = new Dictionary<string, string>(StringComparer.Ordinal)
{
["scope"] = "inputs",
["priority"] = "high"
};
var attributes = new ReadOnlyDictionary<string, string>(attributesDictionary);
var manifestEntry = new EvidenceManifestEntry(
"inputs",
"inputs/config.json",
new string('a', 64),
128,
"application/json",
attributes);
return new EvidenceBundleManifest(
bundleId ?? EvidenceBundleId.FromGuid(Guid.NewGuid()),
tenantId ?? TenantId.FromGuid(Guid.NewGuid()),
EvidenceBundleKind.Evaluation,
new DateTimeOffset(2025, 11, 3, 9, 30, 0, TimeSpan.Zero),
metadata,
new List<EvidenceManifestEntry> { manifestEntry });
}
private sealed class FakeTimestampAuthorityClient : ITimestampAuthorityClient
{
public TimestampResult? Result { get; set; }
public Exception? Exception { get; set; }
public int CallCount { get; private set; }
public Task<TimestampResult?> RequestTimestampAsync(
ReadOnlyMemory<byte> signature,
string hashAlgorithm,
CancellationToken cancellationToken)
{
CallCount++;
if (Exception is not null)
{
throw Exception;
}
return Task.FromResult(Result);
}
}
private sealed class TestTimeProvider(DateTimeOffset fixedUtcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => fixedUtcNow;
}
}

View File

@@ -0,0 +1,502 @@
using System.Reflection;
using System.Runtime.Serialization;
using System.Security.Cryptography;
using System.Linq;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Cryptography;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Signing;
using StellaOps.EvidenceLocker.Core.Incident;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Core.Timeline;
using StellaOps.EvidenceLocker.Infrastructure.Services;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class EvidenceSnapshotServiceTests
{
private static readonly string ValidSha256 = Sha('a');
private static readonly string DefaultRootHash = Sha('f');
private readonly FakeRepository _repository = new();
private readonly FakeBuilder _builder = new();
private readonly FakeSignatureService _signatureService = new();
private readonly FakeTimelinePublisher _timelinePublisher = new();
private readonly TestIncidentState _incidentState = new();
private readonly TestObjectStore _objectStore = new();
private readonly EvidenceSnapshotService _service;
public EvidenceSnapshotServiceTests()
{
var options = Options.Create(new EvidenceLockerOptions
{
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
ObjectStore = new ObjectStoreOptions
{
Kind = ObjectStoreKind.FileSystem,
FileSystem = new FileSystemStoreOptions { RootPath = "." }
},
Quotas = new QuotaOptions
{
MaxMaterialCount = 4,
MaxTotalMaterialSizeBytes = 1_024,
MaxMetadataEntries = 4,
MaxMetadataKeyLength = 32,
MaxMetadataValueLength = 64
},
Signing = new SigningOptions
{
Enabled = false,
Algorithm = SignatureAlgorithms.Es256,
KeyId = "test-key"
},
Incident = new IncidentModeOptions
{
Enabled = false,
RetentionExtensionDays = 30,
CaptureRequestSnapshot = true
}
});
_service = new EvidenceSnapshotService(
_repository,
_builder,
_signatureService,
_timelinePublisher,
_incidentState,
_objectStore,
TimeProvider.System,
options,
NullLogger<EvidenceSnapshotService>.Instance);
}
[Fact]
public async Task CreateSnapshotAsync_PersistsBundleAndBuildsManifest()
{
var request = new EvidenceSnapshotRequest
{
Kind = EvidenceBundleKind.Evaluation,
Metadata = new Dictionary<string, string> { ["run"] = "alpha" },
Materials = new List<EvidenceSnapshotMaterial>
{
new()
{
Section = "inputs",
Path = "config.json",
Sha256 = ValidSha256,
SizeBytes = 128,
MediaType = "application/json"
}
}
};
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var result = await _service.CreateSnapshotAsync(tenantId, request, CancellationToken.None);
Assert.NotEqual(Guid.Empty, result.BundleId);
Assert.Equal(_builder.RootHash, result.RootHash);
Assert.Equal(tenantId, _repository.LastCreateTenant);
Assert.Equal(EvidenceBundleStatus.Pending, _repository.LastCreatedStatus);
Assert.Equal(EvidenceBundleStatus.Assembling, _repository.AssemblyStatus);
Assert.Equal(_builder.RootHash, _repository.AssemblyRootHash);
Assert.True(_builder.Invoked);
Assert.Null(result.Signature);
Assert.False(_timelinePublisher.BundleSealedPublished);
}
[Fact]
public async Task CreateSnapshotAsync_StoresSignature_WhenSignerReturnsEnvelope()
{
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
_signatureService.NextSignature = new EvidenceBundleSignature(
bundleId,
tenantId,
"application/vnd.test",
"payload",
Convert.ToBase64String(new byte[] { 1, 2, 3 }),
"key-1",
SignatureAlgorithms.Es256,
"default",
DateTimeOffset.UtcNow,
null,
null,
null);
_builder.OverrideBundleId = bundleId;
var request = new EvidenceSnapshotRequest
{
Kind = EvidenceBundleKind.Evaluation,
Materials = new List<EvidenceSnapshotMaterial>
{
new() { Section = "inputs", Path = "config.json", Sha256 = ValidSha256, SizeBytes = 128 }
}
};
var result = await _service.CreateSnapshotAsync(tenantId, request, CancellationToken.None);
Assert.NotNull(result.Signature);
Assert.True(_repository.SignatureUpserted);
Assert.True(_timelinePublisher.BundleSealedPublished);
Assert.Equal(_repository.LastCreatedBundleId?.Value ?? Guid.Empty, result.BundleId);
}
[Fact]
public async Task CreateSnapshotAsync_ThrowsWhenMaterialQuotaExceeded()
{
var request = new EvidenceSnapshotRequest
{
Kind = EvidenceBundleKind.Job,
Materials = new List<EvidenceSnapshotMaterial>
{
new() { Section = "a", Path = "1", Sha256 = Sha('a'), SizeBytes = 900 },
new() { Section = "b", Path = "2", Sha256 = Sha('b'), SizeBytes = 300 }
}
};
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_service.CreateSnapshotAsync(TenantId.FromGuid(Guid.NewGuid()), request, CancellationToken.None));
}
[Fact]
public async Task CreateHoldAsync_ReturnsHoldWhenValid()
{
var tenantId = TenantId.FromGuid(Guid.NewGuid());
_repository.NextExistsResult = true;
var holdRequest = new EvidenceHoldRequest
{
BundleId = Guid.NewGuid(),
Reason = "legal",
Notes = "note"
};
var hold = await _service.CreateHoldAsync(tenantId, "case-123", holdRequest, CancellationToken.None);
Assert.Equal("case-123", hold.CaseId);
Assert.True(_repository.RetentionExtended);
Assert.Equal(holdRequest.BundleId, _repository.RetentionBundleId?.Value);
Assert.Null(_repository.RetentionExpiresAt);
Assert.NotNull(_repository.RetentionProcessedAt);
Assert.Equal(tenantId, _repository.RetentionTenant);
Assert.True(_timelinePublisher.HoldPublished);
}
[Fact]
public async Task CreateHoldAsyncThrowsWhenBundleMissing()
{
var tenantId = TenantId.FromGuid(Guid.NewGuid());
_repository.NextExistsResult = false;
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_service.CreateHoldAsync(tenantId, "case-999", new EvidenceHoldRequest
{
BundleId = Guid.NewGuid(),
Reason = "legal"
}, CancellationToken.None));
}
[Fact]
public async Task CreateHoldAsyncThrowsWhenCaseAlreadyExists()
{
_repository.ThrowUniqueViolationForHolds = true;
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_service.CreateHoldAsync(
TenantId.FromGuid(Guid.NewGuid()),
"case-dup",
new EvidenceHoldRequest { Reason = "legal" },
CancellationToken.None));
}
[Fact]
public async Task CreateSnapshotAsync_ExtendsRetentionAndCapturesIncidentArtifacts_WhenIncidentModeActive()
{
_incidentState.SetState(true, retentionExtensionDays: 45, captureSnapshot: true);
Assert.True(_incidentState.Current.IsActive);
Assert.Equal(45, _incidentState.Current.RetentionExtensionDays);
var request = new EvidenceSnapshotRequest
{
Kind = EvidenceBundleKind.Job,
Metadata = new Dictionary<string, string> { ["run"] = "diagnostic" },
Materials = new List<EvidenceSnapshotMaterial>
{
new() { Section = "inputs", Path = "input.txt", Sha256 = ValidSha256, SizeBytes = 32 }
}
};
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var result = await _service.CreateSnapshotAsync(tenantId, request, CancellationToken.None);
Assert.NotNull(_repository.CreatedBundle);
Assert.True(_repository.CreatedBundle!.ExpiresAt.HasValue);
Assert.NotNull(_repository.LastCreatedExpiresAt);
Assert.NotNull(_repository.LastCreatedAt);
Assert.Equal(
_repository.LastCreatedAt!.Value.AddDays(45),
_repository.LastCreatedExpiresAt!.Value,
TimeSpan.FromSeconds(1));
Assert.NotEmpty(_objectStore.StoredArtifacts);
var incidentEntry = result.Manifest.Entries.Single(e => e.Section == "incident");
Assert.True(result.Manifest.Metadata.ContainsKey("incident.mode"));
Assert.Equal("enabled", result.Manifest.Metadata["incident.mode"]);
Assert.StartsWith("incident/request-", incidentEntry.CanonicalPath, StringComparison.Ordinal);
Assert.Equal("application/json", incidentEntry.MediaType);
}
private static string Sha(char fill) => new string(fill, 64);
private sealed class FakeRepository : IEvidenceBundleRepository
{
public TenantId? LastCreateTenant { get; private set; }
public EvidenceBundleStatus? LastCreatedStatus { get; private set; }
public EvidenceBundleId? LastCreatedBundleId { get; private set; }
public bool NextExistsResult { get; set; } = true;
public bool ThrowUniqueViolationForHolds { get; set; }
public bool SignatureUpserted { get; private set; }
public bool RetentionExtended { get; private set; }
public EvidenceBundleId? RetentionBundleId { get; private set; }
public TenantId? RetentionTenant { get; private set; }
public DateTimeOffset? RetentionExpiresAt { get; private set; }
public DateTimeOffset? RetentionProcessedAt { get; private set; }
public string? AssemblyRootHash { get; private set; }
public EvidenceBundleStatus? AssemblyStatus { get; private set; }
public DateTimeOffset? LastCreatedExpiresAt { get; private set; }
public DateTimeOffset? LastCreatedAt { get; private set; }
public EvidenceBundle? CreatedBundle { get; private set; }
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
{
LastCreateTenant = bundle.TenantId;
LastCreatedStatus = bundle.Status;
LastCreatedBundleId = bundle.Id;
LastCreatedExpiresAt = bundle.ExpiresAt;
LastCreatedAt = bundle.CreatedAt;
CreatedBundle = bundle;
return Task.CompletedTask;
}
public Task SetBundleAssemblyAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, string rootHash, DateTimeOffset updatedAt, CancellationToken cancellationToken)
{
AssemblyStatus = status;
AssemblyRootHash = rootHash;
return Task.CompletedTask;
}
public Task MarkBundleSealedAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, DateTimeOffset sealedAt, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
{
SignatureUpserted = true;
return Task.CompletedTask;
}
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult<EvidenceBundleDetails?>(null);
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult(NextExistsResult);
public Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
{
if (ThrowUniqueViolationForHolds)
{
throw CreateUniqueViolationException();
}
return Task.FromResult(hold);
}
public Task ExtendBundleRetentionAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
DateTimeOffset? holdExpiresAt,
DateTimeOffset processedAt,
CancellationToken cancellationToken)
{
RetentionExtended = true;
RetentionBundleId = bundleId;
RetentionTenant = tenantId;
RetentionExpiresAt = holdExpiresAt;
RetentionProcessedAt = processedAt;
return Task.CompletedTask;
}
public Task UpdateStorageKeyAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
string storageKey,
CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task UpdatePortableStorageKeyAsync(
EvidenceBundleId bundleId,
TenantId tenantId,
string storageKey,
DateTimeOffset generatedAt,
CancellationToken cancellationToken)
=> Task.CompletedTask;
#pragma warning disable SYSLIB0050
private static PostgresException CreateUniqueViolationException()
{
var exception = (PostgresException)FormatterServices.GetUninitializedObject(typeof(PostgresException));
SetStringField(exception, "<SqlState>k__BackingField", PostgresErrorCodes.UniqueViolation);
SetStringField(exception, "_sqlState", PostgresErrorCodes.UniqueViolation);
return exception;
}
#pragma warning restore SYSLIB0050
private static void SetStringField(object target, string fieldName, string value)
{
var field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic);
field?.SetValue(target, value);
}
}
private sealed class FakeBuilder : IEvidenceBundleBuilder
{
public string RootHash { get; } = DefaultRootHash;
public bool Invoked { get; private set; }
public EvidenceBundleId? OverrideBundleId { get; set; }
public Task<EvidenceBundleBuildResult> BuildAsync(EvidenceBundleBuildRequest request, CancellationToken cancellationToken)
{
Invoked = true;
var effectiveBundleId = OverrideBundleId ?? request.BundleId;
var manifest = new EvidenceBundleManifest(
effectiveBundleId,
request.TenantId,
request.Kind,
request.CreatedAt,
request.Metadata,
request.Materials.Select(m => new EvidenceManifestEntry(
m.Section,
$"{m.Section}/{m.Path}",
m.Sha256,
m.SizeBytes,
m.MediaType,
m.Attributes ?? new Dictionary<string, string>())).ToList());
return Task.FromResult(new EvidenceBundleBuildResult(RootHash, manifest));
}
}
private sealed class FakeSignatureService : IEvidenceSignatureService
{
public EvidenceBundleSignature? NextSignature { get; set; }
public Task<EvidenceBundleSignature?> SignManifestAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleManifest manifest, CancellationToken cancellationToken)
{
if (NextSignature is null)
{
return Task.FromResult<EvidenceBundleSignature?>(null);
}
return Task.FromResult<EvidenceBundleSignature?>(NextSignature with { BundleId = bundleId, TenantId = tenantId });
}
}
private sealed class FakeTimelinePublisher : IEvidenceTimelinePublisher
{
public bool BundleSealedPublished { get; private set; }
public bool HoldPublished { get; private set; }
public string? LastBundleRoot { get; private set; }
public List<string> IncidentEvents { get; } = new();
public Task PublishBundleSealedAsync(
EvidenceBundleSignature signature,
EvidenceBundleManifest manifest,
string rootHash,
CancellationToken cancellationToken)
{
BundleSealedPublished = true;
LastBundleRoot = rootHash;
return Task.CompletedTask;
}
public Task PublishHoldCreatedAsync(EvidenceHold hold, CancellationToken cancellationToken)
{
HoldPublished = true;
return Task.CompletedTask;
}
public Task PublishIncidentModeChangedAsync(IncidentModeChange change, CancellationToken cancellationToken)
{
IncidentEvents.Add(change.IsActive ? "enabled" : "disabled");
return Task.CompletedTask;
}
}
private sealed class TestIncidentState : IIncidentModeState
{
private IncidentModeSnapshot _snapshot = new(false, DateTimeOffset.UtcNow, 0, false);
public IncidentModeSnapshot Current => _snapshot;
public bool IsActive => _snapshot.IsActive;
public void SetState(bool isActive, int retentionExtensionDays, bool captureSnapshot)
{
_snapshot = new IncidentModeSnapshot(
isActive,
DateTimeOffset.UtcNow,
retentionExtensionDays,
captureSnapshot);
}
}
private sealed class TestObjectStore : IEvidenceObjectStore
{
private readonly Dictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
public List<EvidenceObjectMetadata> StoredArtifacts { get; } = new();
public Task<EvidenceObjectMetadata> StoreAsync(
Stream content,
EvidenceObjectWriteOptions options,
CancellationToken cancellationToken)
{
using var memory = new MemoryStream();
content.CopyTo(memory);
var bytes = memory.ToArray();
var storageKey = $"tenants/{options.TenantId.Value:N}/bundles/{options.BundleId.Value:N}/{options.ArtifactName}";
var sha = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
var metadata = new EvidenceObjectMetadata(
storageKey,
options.ContentType,
bytes.LongLength,
sha,
ETag: null,
CreatedAt: DateTimeOffset.UtcNow);
_objects[storageKey] = bytes;
StoredArtifacts.Add(metadata);
return Task.FromResult(metadata);
}
public Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
{
if (!_objects.TryGetValue(storageKey, out var bytes))
{
throw new FileNotFoundException(storageKey);
}
return Task.FromResult<Stream>(new MemoryStream(bytes, writable: false));
}
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
=> Task.FromResult(_objects.ContainsKey(storageKey));
}
}

View File

@@ -0,0 +1,84 @@
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Infrastructure.Storage;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class FileSystemEvidenceObjectStoreTests : IDisposable
{
private readonly string _rootPath;
public FileSystemEvidenceObjectStoreTests()
{
_rootPath = Path.Combine(Path.GetTempPath(), $"evidence-locker-tests-{Guid.NewGuid():N}");
}
[Fact]
public async Task StoreAsync_EnforcesWriteOnceWhenConfigured()
{
var cancellationToken = TestContext.Current.CancellationToken;
var store = CreateStore(enforceWriteOnce: true);
var options = CreateWriteOptions();
using var first = CreateStream("payload-1");
await store.StoreAsync(first, options, cancellationToken);
using var second = CreateStream("payload-1");
await Assert.ThrowsAsync<InvalidOperationException>(() => store.StoreAsync(second, options, cancellationToken));
}
[Fact]
public async Task StoreAsync_AllowsOverwriteWhenWriteOnceDisabled()
{
var cancellationToken = TestContext.Current.CancellationToken;
var store = CreateStore(enforceWriteOnce: false);
var options = CreateWriteOptions() with { EnforceWriteOnce = false };
using var first = CreateStream("payload-1");
var firstMetadata = await store.StoreAsync(first, options, cancellationToken);
using var second = CreateStream("payload-1");
var secondMetadata = await store.StoreAsync(second, options, cancellationToken);
Assert.Equal(firstMetadata.Sha256, secondMetadata.Sha256);
Assert.True(File.Exists(Path.Combine(_rootPath, secondMetadata.StorageKey.Replace('/', Path.DirectorySeparatorChar))));
}
private FileSystemEvidenceObjectStore CreateStore(bool enforceWriteOnce)
{
var fileSystemOptions = new FileSystemStoreOptions
{
RootPath = _rootPath
};
return new FileSystemEvidenceObjectStore(
fileSystemOptions,
enforceWriteOnce,
NullLogger<FileSystemEvidenceObjectStore>.Instance);
}
private static EvidenceObjectWriteOptions CreateWriteOptions()
{
var tenant = TenantId.FromGuid(Guid.NewGuid());
var bundle = EvidenceBundleId.FromGuid(Guid.NewGuid());
return new EvidenceObjectWriteOptions(
tenant,
bundle,
"artifact.txt",
"text/plain");
}
private static MemoryStream CreateStream(string content)
=> new(Encoding.UTF8.GetBytes(content));
public void Dispose()
{
if (Directory.Exists(_rootPath))
{
Directory.Delete(_rootPath, recursive: true);
}
}
}

View File

@@ -0,0 +1,81 @@
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Signing;
using StellaOps.EvidenceLocker.Infrastructure.Signing;
using Xunit;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class Rfc3161TimestampAuthorityClientTests
{
[Fact]
public async Task RequestTimestampAsync_ReturnsNull_WhenAuthorityFailsAndTimestampOptional()
{
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError));
var client = CreateClient(handler, new TimestampingOptions
{
Enabled = true,
Endpoint = "https://tsa.example",
HashAlgorithm = "SHA256",
RequireTimestamp = false
});
var result = await client.RequestTimestampAsync(new byte[] { 4, 5, 6 }, "SHA256", CancellationToken.None);
Assert.Null(result);
}
[Fact]
public async Task RequestTimestampAsync_Throws_WhenAuthorityFailsAndTimestampRequired()
{
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError));
var client = CreateClient(handler, new TimestampingOptions
{
Enabled = true,
Endpoint = "https://tsa.example",
HashAlgorithm = "SHA256",
RequireTimestamp = true
});
await Assert.ThrowsAsync<InvalidOperationException>(() => client.RequestTimestampAsync(new byte[] { 7, 8 }, "SHA256", CancellationToken.None));
}
private static Rfc3161TimestampAuthorityClient CreateClient(HttpMessageHandler handler, TimestampingOptions timestampingOptions)
{
var httpClient = new HttpClient(handler, disposeHandler: false);
var options = Options.Create(new EvidenceLockerOptions
{
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
ObjectStore = new ObjectStoreOptions
{
Kind = ObjectStoreKind.FileSystem,
FileSystem = new FileSystemStoreOptions { RootPath = "." }
},
Quotas = new QuotaOptions(),
Signing = new SigningOptions
{
Algorithm = SignatureAlgorithms.Es256,
KeyId = "test-key",
Timestamping = timestampingOptions
}
});
return new Rfc3161TimestampAuthorityClient(httpClient, options, NullLogger<Rfc3161TimestampAuthorityClient>.Instance);
}
private sealed class StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder = responder;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(_responder(request));
}
}

View File

@@ -0,0 +1,139 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Amazon;
using Amazon.Runtime;
using Amazon.S3;
using Amazon.S3.Model;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Infrastructure.Storage;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class S3EvidenceObjectStoreTests
{
[Fact]
public async Task StoreAsync_SetsIfNoneMatchAndMetadataWhenEnforcingWriteOnce()
{
var fakeClient = new FakeAmazonS3Client();
using var store = new S3EvidenceObjectStore(
fakeClient,
new AmazonS3StoreOptions
{
BucketName = "evidence",
Region = RegionEndpoint.USEast1.SystemName,
Prefix = "locker"
},
enforceWriteOnce: true,
NullLogger<S3EvidenceObjectStore>.Instance);
var options = new EvidenceObjectWriteOptions(
TenantId.FromGuid(Guid.NewGuid()),
EvidenceBundleId.FromGuid(Guid.NewGuid()),
"bundle-manifest.json",
"application/json",
EnforceWriteOnce: true,
Tags: new Dictionary<string, string>
{
{ "case", "incident-123" }
});
var metadata = await store.StoreAsync(new MemoryStream(Encoding.UTF8.GetBytes("{\"value\":1}")), options, CancellationToken.None);
var request = fakeClient.PutRequests.Single();
Assert.Equal("evidence", request.Bucket);
Assert.StartsWith("locker/tenants/", request.Key, StringComparison.Ordinal);
Assert.Equal("*", request.IfNoneMatch);
var shaEntry = request.Metadata.FirstOrDefault(kvp => kvp.Key.EndsWith("sha256", StringComparison.OrdinalIgnoreCase));
Assert.NotEqual(default(KeyValuePair<string, string>), shaEntry);
Assert.Equal(metadata.Sha256, shaEntry.Value);
var tenantEntry = request.Metadata.FirstOrDefault(kvp => kvp.Key.EndsWith("tenant-id", StringComparison.OrdinalIgnoreCase));
Assert.NotEqual(default(KeyValuePair<string, string>), tenantEntry);
Assert.Equal(options.TenantId.Value.ToString("D"), tenantEntry.Value);
Assert.Contains(request.Tags, tag => tag.Key == "case" && tag.Value == "incident-123");
Assert.Equal("\"etag\"", metadata.ETag);
}
[Fact]
public async Task StoreAsync_DoesNotSetIfNoneMatchWhenWriteOnceDisabled()
{
var fakeClient = new FakeAmazonS3Client();
using var store = new S3EvidenceObjectStore(
fakeClient,
new AmazonS3StoreOptions
{
BucketName = "evidence",
Region = RegionEndpoint.USEast1.SystemName
},
enforceWriteOnce: false,
NullLogger<S3EvidenceObjectStore>.Instance);
var options = new EvidenceObjectWriteOptions(
TenantId.FromGuid(Guid.NewGuid()),
EvidenceBundleId.FromGuid(Guid.NewGuid()),
"artifact.bin",
"application/octet-stream",
EnforceWriteOnce: false);
await store.StoreAsync(new MemoryStream(Encoding.UTF8.GetBytes("payload")), options, CancellationToken.None);
var request = fakeClient.PutRequests.Single();
Assert.Null(request.IfNoneMatch);
}
private sealed class FakeAmazonS3Client : AmazonS3Client
{
public FakeAmazonS3Client()
: base(new AnonymousAWSCredentials(), new AmazonS3Config
{
RegionEndpoint = RegionEndpoint.USEast1,
ForcePathStyle = true,
UseHttp = true,
ServiceURL = "http://localhost"
})
{
}
public List<CapturedPutObjectRequest> PutRequests { get; } = new();
public override Task<PutObjectResponse> PutObjectAsync(PutObjectRequest request, CancellationToken cancellationToken = default)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var key in request.Metadata.Keys)
{
metadata[key] = request.Metadata[key];
}
var tags = request.TagSet?.Select(tag => new KeyValuePair<string, string>(tag.Key, tag.Value)).ToList()
?? new List<KeyValuePair<string, string>>();
var ifNoneMatch = request.Headers?["If-None-Match"];
using var memory = new MemoryStream();
request.InputStream.CopyTo(memory);
PutRequests.Add(new CapturedPutObjectRequest(
request.BucketName,
request.Key,
metadata,
tags,
ifNoneMatch,
memory.ToArray()));
return Task.FromResult(new PutObjectResponse
{
ETag = "\"etag\""
});
}
public sealed record CapturedPutObjectRequest(
string Bucket,
string Key,
IDictionary<string, string> Metadata,
IList<KeyValuePair<string, string>> Tags,
string? IfNoneMatch,
byte[] Payload);
}
}

View File

@@ -1,135 +1,35 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="xunit.v3" Version="3.0.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3"/>
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.EvidenceLocker.Core\StellaOps.EvidenceLocker.Core.csproj"/>
<ProjectReference Include="..\StellaOps.EvidenceLocker.Infrastructure\StellaOps.EvidenceLocker.Infrastructure.csproj"/>
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DotNet.Testcontainers" Version="1.6.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-preview.7.25380.108" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Npgsql" Version="8.0.3" />
<PackageReference Include="xunit.v3" Version="3.0.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.EvidenceLocker.Core\StellaOps.EvidenceLocker.Core.csproj" />
<ProjectReference Include="..\StellaOps.EvidenceLocker.Infrastructure\StellaOps.EvidenceLocker.Infrastructure.csproj" />
<ProjectReference Include="..\StellaOps.EvidenceLocker.WebService\StellaOps.EvidenceLocker.WebService.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,180 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Infrastructure.Timeline;
namespace StellaOps.EvidenceLocker.Tests;
public sealed class TimelineIndexerEvidenceTimelinePublisherTests
{
[Fact]
public async Task PublishBundleSealedAsync_SendsExpectedPayload()
{
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
var manifest = new EvidenceBundleManifest(
bundleId,
tenantId,
EvidenceBundleKind.Job,
DateTimeOffset.Parse("2025-11-03T12:00:00Z"),
new Dictionary<string, string>
{
["project"] = "atlas",
["environment"] = "prod"
},
new List<EvidenceManifestEntry>
{
new(
"inputs",
"inputs/config.json",
new string('a', 64),
128,
"application/json",
new Dictionary<string, string> { ["role"] = "primary" })
});
var signature = new EvidenceBundleSignature(
bundleId,
tenantId,
"application/vnd.stella.manifest+json",
Convert.ToBase64String(new byte[] { 1, 2, 3 }),
Convert.ToBase64String(new byte[] { 4, 5, 6 }),
"key-1",
SignatureAlgorithms.Es256,
"default",
DateTimeOffset.Parse("2025-11-03T12:05:00Z"),
DateTimeOffset.Parse("2025-11-03T12:06:00Z"),
"tsa://test",
new byte[] { 9, 8, 7 });
var handler = new RecordingHttpMessageHandler(HttpStatusCode.Accepted);
var client = new HttpClient(handler);
var publisher = new TimelineIndexerEvidenceTimelinePublisher(
client,
CreateOptions(),
TimeProvider.System,
NullLogger<TimelineIndexerEvidenceTimelinePublisher>.Instance);
await publisher.PublishBundleSealedAsync(signature, manifest, new string('f', 64), CancellationToken.None);
var request = Assert.Single(handler.Requests);
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal("https://timeline.test/events", request.Uri?.ToString());
Assert.NotNull(request.Content);
using var json = JsonDocument.Parse(request.Content!);
var root = json.RootElement;
Assert.Equal("evidence.bundle.sealed", root.GetProperty("kind").GetString());
Assert.Equal(signature.BundleId.Value.ToString("D"), root.GetProperty("attributes").GetProperty("bundleId").GetString());
var bundle = root.GetProperty("bundle");
Assert.Equal(signature.BundleId.Value.ToString("D"), bundle.GetProperty("bundleId").GetString());
Assert.Equal(new string('f', 64), bundle.GetProperty("rootHash").GetString());
var signatureJson = bundle.GetProperty("signature");
Assert.Equal(Convert.ToBase64String(new byte[] { 4, 5, 6 }), signatureJson.GetProperty("signature").GetString());
Assert.Equal(Convert.ToBase64String(new byte[] { 9, 8, 7 }), signatureJson.GetProperty("timestampToken").GetString());
var metadata = bundle.GetProperty("metadata");
Assert.Equal("atlas", metadata.GetProperty("project").GetString());
Assert.Equal("prod", metadata.GetProperty("environment").GetString());
var entry = Assert.Single(bundle.GetProperty("entries").EnumerateArray());
Assert.Equal("inputs", entry.GetProperty("section").GetString());
Assert.Equal("inputs/config.json", entry.GetProperty("canonicalPath").GetString());
Assert.Equal("primary", entry.GetProperty("attributes").GetProperty("role").GetString());
}
[Fact]
public async Task PublishHoldCreatedAsync_ProducesHoldPayload()
{
var tenantId = TenantId.FromGuid(Guid.NewGuid());
var hold = new EvidenceHold(
EvidenceHoldId.FromGuid(Guid.NewGuid()),
tenantId,
EvidenceBundleId.FromGuid(Guid.NewGuid()),
"case-001",
"legal",
DateTimeOffset.Parse("2025-10-31T09:00:00Z"),
DateTimeOffset.Parse("2025-12-31T00:00:00Z"),
ReleasedAt: null,
Notes: "retain until close");
var handler = new RecordingHttpMessageHandler(HttpStatusCode.BadGateway);
var client = new HttpClient(handler);
var publisher = new TimelineIndexerEvidenceTimelinePublisher(
client,
CreateOptions(),
TimeProvider.System,
NullLogger<TimelineIndexerEvidenceTimelinePublisher>.Instance);
await publisher.PublishHoldCreatedAsync(hold, CancellationToken.None);
var request = Assert.Single(handler.Requests);
Assert.Equal(HttpMethod.Post, request.Method);
using var json = JsonDocument.Parse(request.Content!);
var root = json.RootElement;
Assert.Equal("evidence.hold.created", root.GetProperty("kind").GetString());
Assert.Equal(hold.CaseId, root.GetProperty("attributes").GetProperty("caseId").GetString());
var holdJson = root.GetProperty("hold");
Assert.Equal(hold.Id.Value.ToString("D"), holdJson.GetProperty("holdId").GetString());
Assert.Equal(hold.BundleId?.Value.ToString("D"), holdJson.GetProperty("bundleId").GetString());
}
private static IOptions<EvidenceLockerOptions> CreateOptions()
=> Options.Create(new EvidenceLockerOptions
{
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
ObjectStore = new ObjectStoreOptions
{
Kind = ObjectStoreKind.FileSystem,
FileSystem = new FileSystemStoreOptions { RootPath = "." }
},
Quotas = new QuotaOptions(),
Signing = new SigningOptions
{
Enabled = true,
Algorithm = SignatureAlgorithms.Es256,
KeyId = "test-key"
},
Timeline = new TimelineOptions
{
Enabled = true,
Endpoint = "https://timeline.test/events",
Source = "test-source"
}
});
private sealed class RecordingHttpMessageHandler : HttpMessageHandler
{
private readonly HttpStatusCode _statusCode;
public RecordingHttpMessageHandler(HttpStatusCode statusCode)
{
_statusCode = statusCode;
}
public List<RecordedRequest> Requests { get; } = new();
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var content = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
Requests.Add(new RecordedRequest(request.Method, request.RequestUri, content));
return new HttpResponseMessage(_statusCode);
}
}
private sealed record RecordedRequest(HttpMethod Method, Uri? Uri, string? Content);
}

View File

@@ -1,10 +0,0 @@
namespace StellaOps.EvidenceLocker.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -0,0 +1,338 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.EvidenceLocker.Core.Domain;
namespace StellaOps.EvidenceLocker.WebService.Audit;
internal static class EvidenceAuditLogger
{
internal const string LoggerName = "EvidenceLocker.Audit";
private const string OperationSnapshotCreate = "snapshot.create";
private const string OperationBundleRead = "bundle.read";
private const string OperationBundleVerify = "bundle.verify";
private const string OperationHoldCreate = "hold.create";
private const string OperationBundleDownload = "bundle.download";
private const string OperationBundlePortable = "bundle.portable";
public static void LogTenantMissing(ILogger logger, ClaimsPrincipal user, string path)
{
var identity = ExtractIdentity(user);
logger.LogWarning(
"Evidence audit operation={Operation} outcome=tenant_missing path={Path} subject={Subject} clientId={ClientId} scopes={Scopes}",
"request",
path,
identity.Subject,
identity.ClientId,
identity.Scopes);
}
public static void LogSnapshotCreated(
ILogger logger,
ClaimsPrincipal user,
TenantId tenantId,
EvidenceBundleKind kind,
Guid bundleId,
int materialCount,
long totalBytes)
{
var identity = ExtractIdentity(user);
logger.LogInformation(
"Evidence audit operation={Operation} outcome=success tenant={TenantId} bundle={BundleId} kind={Kind} materials={MaterialCount} totalBytes={TotalBytes} subject={Subject} clientId={ClientId} scopes={Scopes}",
OperationSnapshotCreate,
tenantId.Value,
bundleId,
kind,
materialCount,
totalBytes,
identity.Subject,
identity.ClientId,
identity.Scopes);
}
public static void LogSnapshotRejected(
ILogger logger,
ClaimsPrincipal user,
TenantId tenantId,
EvidenceBundleKind kind,
string reason)
{
var identity = ExtractIdentity(user);
logger.LogWarning(
"Evidence audit operation={Operation} outcome=validation_failed tenant={TenantId} kind={Kind} reason=\"{Reason}\" subject={Subject} clientId={ClientId} scopes={Scopes}",
OperationSnapshotCreate,
tenantId.Value,
kind,
reason,
identity.Subject,
identity.ClientId,
identity.Scopes);
}
public static void LogBundleNotFound(
ILogger logger,
ClaimsPrincipal user,
TenantId tenantId,
Guid bundleId)
{
var identity = ExtractIdentity(user);
logger.LogWarning(
"Evidence audit operation={Operation} outcome=not_found tenant={TenantId} bundle={BundleId} subject={Subject} clientId={ClientId} scopes={Scopes}",
OperationBundleRead,
tenantId.Value,
bundleId,
identity.Subject,
identity.ClientId,
identity.Scopes);
}
public static void LogBundleRetrieved(
ILogger logger,
ClaimsPrincipal user,
TenantId tenantId,
EvidenceBundle bundle)
{
var identity = ExtractIdentity(user);
logger.LogInformation(
"Evidence audit operation={Operation} outcome=success tenant={TenantId} bundle={BundleId} status={Status} kind={Kind} subject={Subject} clientId={ClientId} scopes={Scopes}",
OperationBundleRead,
tenantId.Value,
bundle.Id.Value,
bundle.Status,
bundle.Kind,
identity.Subject,
identity.ClientId,
identity.Scopes);
}
public static void LogVerificationResult(
ILogger logger,
ClaimsPrincipal user,
TenantId tenantId,
Guid bundleId,
string expectedRoot,
bool trusted)
{
var identity = ExtractIdentity(user);
logger.LogInformation(
"Evidence audit operation={Operation} outcome={Outcome} tenant={TenantId} bundle={BundleId} expectedRoot={ExpectedRoot} subject={Subject} clientId={ClientId} scopes={Scopes}",
OperationBundleVerify,
trusted ? "trusted" : "mismatch",
tenantId.Value,
bundleId,
expectedRoot,
identity.Subject,
identity.ClientId,
identity.Scopes);
}
public static void LogHoldCreated(
ILogger logger,
ClaimsPrincipal user,
TenantId tenantId,
EvidenceHold hold)
{
var identity = ExtractIdentity(user);
logger.LogInformation(
"Evidence audit operation={Operation} outcome=success tenant={TenantId} hold={HoldId} caseId={CaseId} bundle={BundleId} subject={Subject} clientId={ClientId} scopes={Scopes}",
OperationHoldCreate,
tenantId.Value,
hold.Id.Value,
hold.CaseId,
hold.BundleId?.Value,
identity.Subject,
identity.ClientId,
identity.Scopes);
}
public static void LogHoldBundleMissing(
ILogger logger,
ClaimsPrincipal user,
TenantId tenantId,
string caseId,
Guid bundleId)
{
var identity = ExtractIdentity(user);
logger.LogWarning(
"Evidence audit operation={Operation} outcome=bundle_missing tenant={TenantId} caseId={CaseId} bundle={BundleId} subject={Subject} clientId={ClientId} scopes={Scopes}",
OperationHoldCreate,
tenantId.Value,
caseId,
bundleId,
identity.Subject,
identity.ClientId,
identity.Scopes);
}
public static void LogHoldConflict(
ILogger logger,
ClaimsPrincipal user,
TenantId tenantId,
string caseId)
{
var identity = ExtractIdentity(user);
logger.LogWarning(
"Evidence audit operation={Operation} outcome=conflict tenant={TenantId} caseId={CaseId} subject={Subject} clientId={ClientId} scopes={Scopes}",
OperationHoldCreate,
tenantId.Value,
caseId,
identity.Subject,
identity.ClientId,
identity.Scopes);
}
public static void LogHoldValidationFailure(
ILogger logger,
ClaimsPrincipal user,
TenantId tenantId,
string caseId,
string reason)
{
var identity = ExtractIdentity(user);
logger.LogWarning(
"Evidence audit operation={Operation} outcome=validation_failed tenant={TenantId} caseId={CaseId} reason=\"{Reason}\" subject={Subject} clientId={ClientId} scopes={Scopes}",
OperationHoldCreate,
tenantId.Value,
caseId,
reason,
identity.Subject,
identity.ClientId,
identity.Scopes);
}
public static void LogBundleDownload(
ILogger logger,
ClaimsPrincipal user,
TenantId tenantId,
Guid bundleId,
string storageKey,
bool created)
{
var identity = ExtractIdentity(user);
logger.LogInformation(
"Evidence audit operation={Operation} outcome=success tenant={TenantId} bundle={BundleId} storageKey={StorageKey} cached={Cached} subject={Subject} clientId={ClientId} scopes={Scopes}",
OperationBundleDownload,
tenantId.Value,
bundleId,
storageKey,
created ? "false" : "true",
identity.Subject,
identity.ClientId,
identity.Scopes);
}
public static void LogBundleDownloadFailure(
ILogger logger,
ClaimsPrincipal user,
TenantId tenantId,
Guid bundleId,
string reason)
{
var identity = ExtractIdentity(user);
logger.LogWarning(
"Evidence audit operation={Operation} outcome=failure tenant={TenantId} bundle={BundleId} reason=\"{Reason}\" subject={Subject} clientId={ClientId} scopes={Scopes}",
OperationBundleDownload,
tenantId.Value,
bundleId,
reason,
identity.Subject,
identity.ClientId,
identity.Scopes);
}
public static void LogPortableDownload(
ILogger logger,
ClaimsPrincipal user,
TenantId tenantId,
Guid bundleId,
string storageKey,
bool created)
{
var identity = ExtractIdentity(user);
logger.LogInformation(
"Evidence audit operation={Operation} outcome=success tenant={TenantId} bundle={BundleId} storageKey={StorageKey} cached={Cached} subject={Subject} clientId={ClientId} scopes={Scopes}",
OperationBundlePortable,
tenantId.Value,
bundleId,
storageKey,
created ? "false" : "true",
identity.Subject,
identity.ClientId,
identity.Scopes);
}
public static void LogPortableDownloadFailure(
ILogger logger,
ClaimsPrincipal user,
TenantId tenantId,
Guid bundleId,
string reason)
{
var identity = ExtractIdentity(user);
logger.LogWarning(
"Evidence audit operation={Operation} outcome=failure tenant={TenantId} bundle={BundleId} reason=\"{Reason}\" subject={Subject} clientId={ClientId} scopes={Scopes}",
OperationBundlePortable,
tenantId.Value,
bundleId,
reason,
identity.Subject,
identity.ClientId,
identity.Scopes);
}
private static AuditIdentity ExtractIdentity(ClaimsPrincipal? user)
{
if (user is null)
{
return new AuditIdentity("(anonymous)", "(none)", "(none)");
}
var subject = user.FindFirst(StellaOpsClaimTypes.Subject)?.Value;
var clientId = user.FindFirst(StellaOpsClaimTypes.ClientId)?.Value;
var scopes = ExtractScopes(user);
return new AuditIdentity(
string.IsNullOrWhiteSpace(subject) ? "(anonymous)" : subject,
string.IsNullOrWhiteSpace(clientId) ? "(none)" : clientId,
scopes.Length == 0 ? "(none)" : string.Join(',', scopes));
}
private static string[] ExtractScopes(ClaimsPrincipal principal)
{
var values = new HashSet<string>(StringComparer.Ordinal);
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
{
if (!string.IsNullOrWhiteSpace(claim.Value))
{
values.Add(claim.Value);
}
}
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
var normalized = StellaOpsScopes.Normalize(part);
if (!string.IsNullOrEmpty(normalized))
{
values.Add(normalized);
}
}
}
return values.Count == 0 ? Array.Empty<string>() : values.ToArray();
}
private readonly record struct AuditIdentity(string Subject, string ClientId, string Scopes);
}

View File

@@ -0,0 +1,152 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Infrastructure.Services;
namespace StellaOps.EvidenceLocker.WebService.Contracts;
public sealed record EvidenceSnapshotRequestDto
{
[Required]
public EvidenceBundleKind Kind { get; init; }
public string? Description { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
[Required]
public List<EvidenceSnapshotMaterialDto> Materials { get; init; } = new();
}
public sealed record EvidenceSnapshotMaterialDto
{
public string? Section { get; init; }
public string? Path { get; init; }
[Required]
public string Sha256 { get; init; } = string.Empty;
[Range(0, long.MaxValue)]
public long SizeBytes { get; init; }
public string? MediaType { get; init; }
public Dictionary<string, string>? Attributes { get; init; }
}
public sealed record EvidenceSnapshotResponseDto(Guid BundleId, string RootHash, EvidenceBundleSignatureDto? Signature);
public sealed record EvidenceBundleResponseDto(
Guid BundleId,
EvidenceBundleKind Kind,
EvidenceBundleStatus Status,
string RootHash,
string StorageKey,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
string? Description,
DateTimeOffset? SealedAt,
DateTimeOffset? ExpiresAt,
EvidenceBundleSignatureDto? Signature);
public sealed record EvidenceBundleSignatureDto(
string PayloadType,
string Payload,
string Signature,
string? KeyId,
string Algorithm,
string Provider,
DateTimeOffset SignedAt,
DateTimeOffset? TimestampedAt,
string? TimestampAuthority,
string? TimestampToken);
public sealed record EvidenceVerifyRequestDto
{
[Required]
public Guid BundleId { get; init; }
[Required]
public string RootHash { get; init; } = string.Empty;
}
public sealed record EvidenceVerifyResponseDto(bool Trusted);
public sealed record EvidenceHoldRequestDto
{
public Guid? BundleId { get; init; }
[Required]
public string Reason { get; init; } = string.Empty;
public DateTimeOffset? ExpiresAt { get; init; }
public string? Notes { get; init; }
}
public sealed record EvidenceHoldResponseDto(
Guid HoldId,
Guid? BundleId,
string CaseId,
string Reason,
DateTimeOffset CreatedAt,
DateTimeOffset? ExpiresAt,
DateTimeOffset? ReleasedAt,
string? Notes);
public sealed record ErrorResponse(string Code, string Message);
public static class EvidenceContractMapper
{
public static EvidenceSnapshotRequest ToDomain(this EvidenceSnapshotRequestDto dto)
=> new()
{
Kind = dto.Kind,
Description = dto.Description,
Metadata = dto.Metadata ?? new Dictionary<string, string>(),
Materials = dto.Materials
.Select(m => new EvidenceSnapshotMaterial
{
Section = m.Section,
Path = m.Path,
Sha256 = m.Sha256,
SizeBytes = m.SizeBytes,
MediaType = m.MediaType,
Attributes = m.Attributes ?? new Dictionary<string, string>()
})
.ToList()
};
public static EvidenceHoldRequest ToDomain(this EvidenceHoldRequestDto dto)
=> new()
{
BundleId = dto.BundleId,
Reason = dto.Reason,
ExpiresAt = dto.ExpiresAt,
Notes = dto.Notes
};
public static EvidenceBundleSignatureDto? ToDto(this EvidenceBundleSignature? signature)
{
if (signature is null)
{
return null;
}
return new EvidenceBundleSignatureDto(
signature.PayloadType,
signature.Payload,
signature.Signature,
signature.KeyId,
signature.Algorithm,
signature.Provider,
signature.SignedAt,
signature.TimestampedAt,
signature.TimestampAuthority,
signature.TimestampToken is null ? null : Convert.ToBase64String(signature.TimestampToken));
}
}

View File

@@ -1,15 +1,33 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Infrastructure.DependencyInjection;
using StellaOps.EvidenceLocker.Infrastructure.Services;
using StellaOps.EvidenceLocker.WebService.Audit;
using StellaOps.EvidenceLocker.WebService.Contracts;
using StellaOps.EvidenceLocker.WebService.Security;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEvidenceLockerInfrastructure(builder.Configuration);
builder.Services.AddScoped<EvidenceSnapshotService>();
builder.Services.AddHealthChecks();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configure: options =>
{
options.RequiredScopes.Clear();
});
configure: options => options.RequiredScopes.Clear());
builder.Services.AddAuthorization(options =>
{
@@ -34,13 +52,282 @@ app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/evidence/{id}", (string id) => Results.Ok(new { id, status = "available" }))
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead);
app.MapHealthChecks("/health/ready");
app.MapPost("/evidence", () => Results.Accepted("/evidence", new { status = "queued" }))
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceCreate);
app.MapPost("/evidence/snapshot",
async (HttpContext context, ClaimsPrincipal user, EvidenceSnapshotRequestDto request, EvidenceSnapshotService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
{
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
app.MapPost("/evidence/{id}/hold", (string id) => Results.Accepted($"/evidence/{id}/hold", new { id, status = "on-hold" }))
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceHold);
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
{
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/snapshot");
return ForbidTenant();
}
try
{
var result = await service.CreateSnapshotAsync(tenantId, request.ToDomain(), cancellationToken);
var materialCount = request.Materials.Count;
var totalSize = request.Materials.Sum(material => material.SizeBytes);
EvidenceAuditLogger.LogSnapshotCreated(logger, user, tenantId, request.Kind, result.BundleId, materialCount, totalSize);
var dto = new EvidenceSnapshotResponseDto(
result.BundleId,
result.RootHash,
result.Signature.ToDto());
return Results.Created($"/evidence/{result.BundleId}", dto);
}
catch (InvalidOperationException ex)
{
EvidenceAuditLogger.LogSnapshotRejected(logger, user, tenantId, request.Kind, ex.Message);
return ValidationProblem(ex.Message);
}
})
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceHold)
.Produces<EvidenceSnapshotResponseDto>(StatusCodes.Status201Created)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
.WithName("CreateEvidenceSnapshot")
.WithTags("Evidence")
.WithSummary("Create a new evidence snapshot for the tenant.");
app.MapGet("/evidence/{bundleId:guid}",
async (HttpContext context, ClaimsPrincipal user, Guid bundleId, EvidenceSnapshotService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
{
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
{
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/{bundleId}");
return ForbidTenant();
}
var details = await service.GetBundleAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken);
if (details is null)
{
EvidenceAuditLogger.LogBundleNotFound(logger, user, tenantId, bundleId);
return Results.NotFound(new ErrorResponse("not_found", "Evidence bundle not found."));
}
EvidenceAuditLogger.LogBundleRetrieved(logger, user, tenantId, details.Bundle);
var dto = new EvidenceBundleResponseDto(
details.Bundle.Id.Value,
details.Bundle.Kind,
details.Bundle.Status,
details.Bundle.RootHash,
details.Bundle.StorageKey,
details.Bundle.CreatedAt,
details.Bundle.UpdatedAt,
details.Bundle.Description,
details.Bundle.SealedAt,
details.Bundle.ExpiresAt,
details.Signature.ToDto());
return Results.Ok(dto);
})
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead)
.Produces<EvidenceBundleResponseDto>(StatusCodes.Status200OK)
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
.Produces<ErrorResponse>(StatusCodes.Status404NotFound)
.WithName("GetEvidenceBundle")
.WithTags("Evidence");
app.MapGet("/evidence/{bundleId:guid}/download",
async (HttpContext context,
ClaimsPrincipal user,
Guid bundleId,
EvidenceSnapshotService snapshotService,
EvidenceBundlePackagingService packagingService,
IEvidenceObjectStore objectStore,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) =>
{
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
{
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/{bundleId}/download");
return ForbidTenant();
}
var bundle = await snapshotService.GetBundleAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken);
if (bundle is null)
{
EvidenceAuditLogger.LogBundleNotFound(logger, user, tenantId, bundleId);
return Results.NotFound(new ErrorResponse("not_found", "Evidence bundle not found."));
}
try
{
var package = await packagingService.EnsurePackageAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken);
EvidenceAuditLogger.LogBundleDownload(logger, user, tenantId, bundleId, package.StorageKey, package.Created);
var packageStream = await objectStore.OpenReadAsync(package.StorageKey, cancellationToken).ConfigureAwait(false);
return Results.File(
packageStream,
contentType: "application/gzip",
fileDownloadName: $"evidence-bundle-{bundleId:D}.tgz");
}
catch (InvalidOperationException ex)
{
EvidenceAuditLogger.LogBundleDownloadFailure(logger, user, tenantId, bundleId, ex.Message);
return ValidationProblem(ex.Message);
}
})
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead)
.Produces(StatusCodes.Status200OK)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
.Produces<ErrorResponse>(StatusCodes.Status404NotFound)
.WithName("DownloadEvidenceBundle")
.WithTags("Evidence");
app.MapGet("/evidence/{bundleId:guid}/portable",
async (HttpContext context,
ClaimsPrincipal user,
Guid bundleId,
EvidenceSnapshotService snapshotService,
EvidencePortableBundleService portableService,
IEvidenceObjectStore objectStore,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) =>
{
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
{
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/{bundleId}/portable");
return ForbidTenant();
}
var bundle = await snapshotService.GetBundleAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken);
if (bundle is null)
{
EvidenceAuditLogger.LogBundleNotFound(logger, user, tenantId, bundleId);
return Results.NotFound(new ErrorResponse("not_found", "Evidence bundle not found."));
}
try
{
var package = await portableService.EnsurePortablePackageAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken);
EvidenceAuditLogger.LogPortableDownload(logger, user, tenantId, bundleId, package.StorageKey, package.Created);
var packageStream = await objectStore.OpenReadAsync(package.StorageKey, cancellationToken).ConfigureAwait(false);
return Results.File(
packageStream,
contentType: "application/gzip",
fileDownloadName: $"portable-evidence-bundle-{bundleId:D}.tgz");
}
catch (InvalidOperationException ex)
{
EvidenceAuditLogger.LogPortableDownloadFailure(logger, user, tenantId, bundleId, ex.Message);
return ValidationProblem(ex.Message);
}
})
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead)
.Produces(StatusCodes.Status200OK)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
.Produces<ErrorResponse>(StatusCodes.Status404NotFound)
.WithName("DownloadPortableEvidenceBundle")
.WithTags("Evidence")
.WithSummary("Download a sealed, portable evidence bundle for sealed or air-gapped distribution.");
app.MapPost("/evidence/verify",
async (HttpContext context, ClaimsPrincipal user, EvidenceVerifyRequestDto request, EvidenceSnapshotService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
{
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
{
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/verify");
return ForbidTenant();
}
var trusted = await service.VerifyAsync(tenantId, EvidenceBundleId.FromGuid(request.BundleId), request.RootHash, cancellationToken);
EvidenceAuditLogger.LogVerificationResult(logger, user, tenantId, request.BundleId, request.RootHash, trusted);
return Results.Ok(new EvidenceVerifyResponseDto(trusted));
})
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead)
.Produces<EvidenceVerifyResponseDto>(StatusCodes.Status200OK)
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
.WithName("VerifyEvidenceBundle")
.WithTags("Evidence");
app.MapPost("/evidence/hold/{caseId}",
async (HttpContext context, ClaimsPrincipal user, string caseId, EvidenceHoldRequestDto request, EvidenceSnapshotService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
{
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
if (string.IsNullOrWhiteSpace(caseId))
{
return ValidationProblem("Case identifier is required.");
}
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
{
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/hold/{caseId}");
return ForbidTenant();
}
try
{
var hold = await service.CreateHoldAsync(tenantId, caseId, request.ToDomain(), cancellationToken);
EvidenceAuditLogger.LogHoldCreated(logger, user, tenantId, hold);
var dto = new EvidenceHoldResponseDto(
hold.Id.Value,
hold.BundleId?.Value,
hold.CaseId,
hold.Reason,
hold.CreatedAt,
hold.ExpiresAt,
hold.ReleasedAt,
hold.Notes);
return Results.Created($"/evidence/hold/{hold.Id.Value}", dto);
}
catch (InvalidOperationException ex)
{
if (ex.Message.Contains("does not exist", StringComparison.OrdinalIgnoreCase) && request.BundleId is Guid referencedBundle)
{
EvidenceAuditLogger.LogHoldBundleMissing(logger, user, tenantId, caseId, referencedBundle);
}
else if (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
{
EvidenceAuditLogger.LogHoldConflict(logger, user, tenantId, caseId);
}
else
{
EvidenceAuditLogger.LogHoldValidationFailure(logger, user, tenantId, caseId, ex.Message);
}
return ValidationProblem(ex.Message);
}
catch (ArgumentException ex)
{
EvidenceAuditLogger.LogHoldValidationFailure(logger, user, tenantId, caseId, ex.Message);
return ValidationProblem(ex.Message);
}
})
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceCreate)
.Produces<EvidenceHoldResponseDto>(StatusCodes.Status201Created)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
.WithName("CreateEvidenceHold")
.WithTags("Evidence")
.WithSummary("Create a legal hold for the specified case identifier.");
app.Run();
static IResult ForbidTenant() => Results.Forbid();
static IResult ValidationProblem(string message)
=> Results.ValidationProblem(new Dictionary<string, string[]>
{
["message"] = new[] { message }
});

View File

@@ -0,0 +1,27 @@
using System;
using System.Security.Claims;
using StellaOps.Auth.Abstractions;
using StellaOps.EvidenceLocker.Core.Domain;
namespace StellaOps.EvidenceLocker.WebService.Security;
internal static class TenantResolution
{
public static bool TryResolveTenant(ClaimsPrincipal user, out TenantId tenantId)
{
tenantId = default;
var tenantValue = user?.FindFirstValue(StellaOpsClaimTypes.Tenant);
if (string.IsNullOrWhiteSpace(tenantValue))
{
return false;
}
if (!Guid.TryParse(tenantValue, out var guid))
{
return false;
}
tenantId = TenantId.FromGuid(guid);
return true;
}
}

View File

@@ -1,8 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
{
Logging: {
LogLevel: {
Default: Information,
Microsoft.AspNetCore: Warning
}
},
EvidenceLocker: {
Database: {
ConnectionString: Host=localhost

View File

@@ -16,5 +16,6 @@
]
}
},
AllowedHosts: *
}
EvidenceLocker: {
Database: {
ConnectionString: Host=localhost

View File

@@ -1,7 +1,10 @@
using StellaOps.EvidenceLocker.Worker;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();
using StellaOps.EvidenceLocker.Infrastructure.DependencyInjection;
using StellaOps.EvidenceLocker.Worker;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddEvidenceLockerInfrastructure(builder.Configuration);
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
await host.RunAsync();

View File

@@ -1,16 +1,26 @@
namespace StellaOps.EvidenceLocker.Worker;
public class Worker(ILogger<Worker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
}
await Task.Delay(1000, stoppingToken);
}
}
}
using StellaOps.EvidenceLocker.Infrastructure.Db;
namespace StellaOps.EvidenceLocker.Worker;
public sealed class Worker(ILogger<Worker> logger, EvidenceLockerDataSource dataSource) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await using var connection = await dataSource.OpenConnectionAsync(stoppingToken);
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation("Evidence Locker worker verified connectivity to database '{Database}'", connection.Database);
}
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
logger.LogError(ex, "Evidence Locker worker failed to verify database connectivity.");
throw;
}
await Task.Delay(Timeout.Infinite, stoppingToken);
}
}

View File

@@ -1,8 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}
{
Logging: {
LogLevel: {
Default: Information,
Microsoft.Hosting.Lifetime: Information
}
},
EvidenceLocker: {
Database: {
ConnectionString: Host=localhost

View File

@@ -1,8 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}
{
Logging: {
LogLevel: {
Default: Information,
Microsoft.Hosting.Lifetime: Information
}
},
EvidenceLocker: {
Database: {
ConnectionString: Host=localhost

View File

@@ -1,24 +1,29 @@
# Evidence Locker Task Board — Epic 15: Observability & Forensics
## Sprint 53 Evidence Bundle Foundations
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| EVID-OBS-53-001 | TODO | Evidence Locker Guild | TELEMETRY-OBS-50-001, DEVOPS-OBS-50-003 | Bootstrap `StellaOps.Evidence.Locker` service with Postgres schema for `evidence_bundles`, `evidence_artifacts`, `evidence_holds`, tenant RLS, and object-store abstraction (WORM optional). | Service builds/tests; migrations deterministic; storage abstraction has local filesystem + S3 drivers; compliance checklist recorded. |
| EVID-OBS-53-002 | TODO | Evidence Locker Guild, Orchestrator Guild | EVID-OBS-53-001, ORCH-OBS-53-001 | Implement bundle builders for evaluation/job/export snapshots collecting inputs, outputs, env digests, run metadata. Generate Merkle tree + manifest skeletons and persist root hash. | Builders cover three bundle types; integration tests verify deterministic manifests; root hash stored; docs stubbed. |
| EVID-OBS-53-003 | TODO | Evidence Locker Guild, Security Guild | EVID-OBS-53-002 | Expose REST APIs (`POST /evidence/snapshot`, `GET /evidence/:id`, `POST /evidence/verify`, `POST /evidence/hold/:case_id`) with audit logging, tenant enforcement, and size quotas. | APIs documented via OpenAPI; tests cover RBAC/legal hold; size quota rejection returns structured error; audit logs validated. |
## Sprint 54 Provenance Integration
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| EVID-OBS-54-001 | TODO | Evidence Locker Guild, Provenance Guild | EVID-OBS-53-003, PROV-OBS-53-002 | Attach DSSE signing and RFC3161 timestamping to bundle manifests; validate against Provenance verification library. Wire legal hold retention extension and chain-of-custody events for Timeline Indexer. | Bundles signed; verification tests pass; timeline events emitted; timestamp optional but documented; retention updates recorded. |
| EVID-OBS-54-002 | TODO | Evidence Locker Guild, DevEx/CLI Guild | EVID-OBS-54-001, CLI-FORENSICS-54-001 | Provide bundle download/export packaging (tgz) with checksum manifest, offline verification instructions, and sample fixture for CLI tests. | Packaging script deterministic; CLI verifies sample; offline instructions documented; checksum cross-check done. |
## Sprint 55 Incident Mode & Retention
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| EVID-OBS-55-001 | TODO | Evidence Locker Guild, DevOps Guild | EVID-OBS-54-001, DEVOPS-OBS-55-001 | Implement incident mode hooks increasing retention window, capturing additional debug artefacts, and emitting activation/deactivation events to Timeline Indexer + Notifier. | Incident mode extends retention per config; activation events emitted; tests cover revert to baseline; runbook updated. |
## Sprint 60 Sealed Mode Portability
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| EVID-OBS-60-001 | TODO | Evidence Locker Guild | EVID-OBS-55-001, AIRGAP-CTL-56-002 | Deliver portable evidence export flow for sealed environments: generate sealed bundles with checksum manifest, redacted metadata, and offline verification script. Document air-gapped import/verify procedures. | Portable bundle tooling implemented; checksum/verify script passes; sealed-mode docs updated; tests cover tamper + re-import scenarios. |
# Evidence Locker Task Board — Epic 15: Observability & Forensics
## Sprint 53 Evidence Bundle Foundations
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| EVID-OBS-53-001 | DONE (2025-11-03) | Evidence Locker Guild | TELEMETRY-OBS-50-001, DEVOPS-OBS-50-003 | Bootstrap `StellaOps.Evidence.Locker` service with Postgres schema for `evidence_bundles`, `evidence_artifacts`, `evidence_holds`, tenant RLS, and object-store abstraction (WORM optional). | Service builds/tests; migrations deterministic; storage abstraction has local filesystem + S3 drivers; compliance checklist recorded. |
| EVID-OBS-53-002 | DONE (2025-11-03) | Evidence Locker Guild, Orchestrator Guild | EVID-OBS-53-001, ORCH-OBS-53-001 | Implement bundle builders for evaluation/job/export snapshots collecting inputs, outputs, env digests, run metadata. Generate Merkle tree + manifest skeletons and persist root hash. | Builders cover three bundle types; integration tests verify deterministic manifests; root hash stored; docs stubbed. |
| EVID-OBS-53-003 | DONE (2025-11-03) | Evidence Locker Guild, Security Guild | EVID-OBS-53-002 | Expose REST APIs (`POST /evidence/snapshot`, `GET /evidence/:id`, `POST /evidence/verify`, `POST /evidence/hold/:case_id`) with audit logging, tenant enforcement, and size quotas. | APIs documented via OpenAPI; tests cover RBAC/legal hold; size quota rejection returns structured error; audit logs validated. |
## Sprint 54 Provenance Integration
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| EVID-OBS-54-001 | DONE (2025-11-04) | Evidence Locker Guild, Provenance Guild | EVID-OBS-53-003, PROV-OBS-53-002 | Attach DSSE signing and RFC3161 timestamping to bundle manifests; validate against Provenance verification library. Wire legal hold retention extension and chain-of-custody events for Timeline Indexer. | Bundles signed; verification tests pass; timeline events emitted; timestamp optional but documented; retention updates recorded. |
| EVID-OBS-54-002 | DONE (2025-11-04) | Evidence Locker Guild, DevEx/CLI Guild | EVID-OBS-54-001, CLI-FORENSICS-54-001 | Provide bundle download/export packaging (tgz) with checksum manifest, offline verification instructions, and sample fixture for CLI tests. | Packaging script deterministic; CLI verifies sample; offline instructions documented; checksum cross-check done. |
## Sprint 55 Incident Mode & Retention
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| EVID-OBS-55-001 | DONE (2025-11-04) | Evidence Locker Guild, DevOps Guild | EVID-OBS-54-001, DEVOPS-OBS-55-001 | Implement incident mode hooks increasing retention window, capturing additional debug artefacts, and emitting activation/deactivation events to Timeline Indexer + Notifier. | Incident mode extends retention per config; activation events emitted; tests cover revert to baseline; runbook updated. |
## Sprint 187 Replay Enablement
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| EVID-REPLAY-187-001 | TODO | Evidence Locker Guild, Ops Guild | REPLAY-CORE-185-001, SCAN-REPLAY-186-001 | Implement replay bundle ingestion/retention APIs, enforce CAS-backed storage, and update `docs/modules/evidence-locker/architecture.md` referencing `docs/replay/DETERMINISTIC_REPLAY.md` Sections 2 & 8. | Replay bundles stored with retention policies; verification tests pass; documentation merged. |
## Sprint 60 Sealed Mode Portability
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| EVID-OBS-60-001 | DONE (2025-11-04) | Evidence Locker Guild | EVID-OBS-55-001, AIRGAP-CTL-56-002 | Deliver portable evidence export flow for sealed environments: generate sealed bundles with checksum manifest, redacted metadata, and offline verification script. Document air-gapped import/verify procedures. | Portable bundle tooling implemented; checksum/verify script passes; sealed-mode docs updated; tests cover tamper + re-import scenarios. |