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 _logger; public EvidenceBundlePackagingService( IEvidenceBundleRepository repository, IEvidenceObjectStore objectStore, ILogger 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 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(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(); 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 `)."); 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 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? Metadata, ManifestEntryDocument[]? Entries); private sealed record ManifestEntryDocument( string Section, string CanonicalPath, string Sha256, long SizeBytes, string? MediaType, IDictionary? 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);