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 _logger; private readonly PortableOptions _options; private readonly TimeProvider _timeProvider; public EvidencePortableBundleService( IEvidenceBundleRepository repository, IEvidenceObjectStore objectStore, IOptions options, TimeProvider timeProvider, ILogger 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 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 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, _options)); 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(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(); var entryCount = entries.Length; var totalSize = entries.Sum(e => e.SizeBytes); IReadOnlyDictionary? 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(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, PortableOptions options) { 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 ' 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 \" >&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(); 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, // Determinism: fixed uid/gid/owner/group per bundle-packaging.md Uid = 0, Gid = 0, UserName = string.Empty, GroupName = string.Empty }; 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 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 PortableBundleMetadataDocument( Guid BundleId, EvidenceBundleKind Kind, string RootHash, DateTimeOffset CreatedAt, DateTimeOffset? SealedAt, DateTimeOffset? ExpiresAt, bool Timestamped, DateTimeOffset? TimestampedAt, DateTimeOffset PortableGeneratedAt, int EntryCount, long TotalSizeBytes, IReadOnlyDictionary? IncidentMetadata); }