421 lines
17 KiB
C#
421 lines
17 KiB
C#
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 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<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,
|
|
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 <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,
|
|
// 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<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);
|
|
}
|