This commit is contained in:
StellaOps Bot
2025-12-07 22:49:53 +02:00
parent 11597679ed
commit 7c24ed96ee
204 changed files with 23313 additions and 1430 deletions

View File

@@ -0,0 +1,533 @@
using System.Formats.Tar;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Verifier for attestation bundles exported from the Export Center.
/// Per EXPORT-ATTEST-75-001.
/// </summary>
internal sealed class AttestationBundleVerifier : IAttestationBundleVerifier
{
private const string DsseEnvelopeFileName = "attestation.dsse.json";
private const string StatementFileName = "statement.json";
private const string TransparencyFileName = "transparency.ndjson";
private const string MetadataFileName = "metadata.json";
private const string ChecksumsFileName = "checksums.txt";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private readonly ILogger<AttestationBundleVerifier> _logger;
public AttestationBundleVerifier(ILogger<AttestationBundleVerifier> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<AttestationBundleVerifyResult> VerifyAsync(
AttestationBundleVerifyOptions options,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(options.FilePath);
_logger.LogDebug("Verifying attestation bundle at {FilePath}, offline={Offline}",
options.FilePath, options.Offline);
// Step 1: Check bundle exists
if (!File.Exists(options.FilePath))
{
return CreateFailedResult(
AttestationBundleExitCodes.FileNotFound,
"Bundle file not found",
options.FilePath);
}
// Step 2: Verify SHA-256 against .sha256 file if present
var sha256Path = options.FilePath + ".sha256";
if (File.Exists(sha256Path))
{
var checksumResult = await VerifyBundleChecksumAsync(options.FilePath, sha256Path, cancellationToken)
.ConfigureAwait(false);
if (!checksumResult.IsValid)
{
return CreateFailedResult(
AttestationBundleExitCodes.ChecksumMismatch,
"SHA-256 checksum mismatch",
options.FilePath,
$"Expected: {checksumResult.ExpectedHash}, Computed: {checksumResult.ActualHash}");
}
}
else
{
_logger.LogDebug("No co-located .sha256 file found for external checksum verification");
}
// Step 3: Extract and parse bundle contents
BundleContents contents;
try
{
contents = await ExtractBundleContentsAsync(options.FilePath, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is InvalidDataException or JsonException or IOException)
{
_logger.LogError(ex, "Failed to extract bundle contents");
return CreateFailedResult(
AttestationBundleExitCodes.FormatError,
"Failed to extract bundle contents",
options.FilePath,
ex.Message);
}
// Step 4: Verify internal checksums from checksums.txt
if (contents.ChecksumsText is not null)
{
var internalCheckResult = VerifyInternalChecksums(contents);
if (!internalCheckResult.Success)
{
return CreateFailedResult(
AttestationBundleExitCodes.ChecksumMismatch,
"Internal checksum verification failed",
options.FilePath,
internalCheckResult.ErrorMessage);
}
}
// Step 5: Verify DSSE signature
var signatureValid = VerifyDsseSignature(contents, options.Offline, out var signatureError);
if (!signatureValid && !string.IsNullOrEmpty(signatureError))
{
return CreateFailedResult(
AttestationBundleExitCodes.SignatureFailure,
"DSSE signature verification failed",
options.FilePath,
signatureError);
}
// Step 6: Check transparency entries (only if not offline and verifyTransparency is true)
if (!options.Offline && options.VerifyTransparency)
{
if (string.IsNullOrWhiteSpace(contents.TransparencyNdjson))
{
return CreateFailedResult(
AttestationBundleExitCodes.MissingTransparency,
"Transparency log entry missing",
options.FilePath,
"Bundle requires transparency.ndjson when not in offline mode");
}
}
// Step 7: Build success result
var metadata = contents.Metadata;
var subjects = ExtractSubjects(contents);
return new AttestationBundleVerifyResult(
Success: true,
Status: "verified",
ExportId: metadata?.ExportId,
AttestationId: metadata?.AttestationId,
RootHash: FormatRootHash(metadata?.RootHash),
Subjects: subjects,
PredicateType: ExtractPredicateType(contents),
StatementVersion: metadata?.StatementVersion,
BundlePath: options.FilePath,
ExitCode: AttestationBundleExitCodes.Success);
}
public async Task<AttestationBundleImportResult> ImportAsync(
AttestationBundleImportOptions options,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(options.FilePath);
_logger.LogDebug("Importing attestation bundle from {FilePath}", options.FilePath);
// First verify the bundle
var verifyOptions = new AttestationBundleVerifyOptions(
options.FilePath,
options.Offline,
options.VerifyTransparency,
options.TrustRootPath);
var verifyResult = await VerifyAsync(verifyOptions, cancellationToken).ConfigureAwait(false);
if (!verifyResult.Success)
{
return new AttestationBundleImportResult(
Success: false,
Status: "verification_failed",
AttestationId: verifyResult.AttestationId,
TenantId: null,
Namespace: options.Namespace,
RootHash: verifyResult.RootHash,
ErrorMessage: verifyResult.ErrorMessage,
ExitCode: verifyResult.ExitCode);
}
// Extract metadata for import
BundleContents contents;
try
{
contents = await ExtractBundleContentsAsync(options.FilePath, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
return new AttestationBundleImportResult(
Success: false,
Status: "extraction_failed",
AttestationId: null,
TenantId: null,
Namespace: options.Namespace,
RootHash: null,
ErrorMessage: ex.Message,
ExitCode: AttestationBundleExitCodes.ImportFailed);
}
var metadata = contents.Metadata;
var tenantId = options.Tenant ?? metadata?.TenantId;
// Import is a local-only operation for air-gap scenarios
// The actual import to backend would happen via separate API call
_logger.LogInformation("Attestation bundle imported: {AttestationId} for tenant {TenantId}",
metadata?.AttestationId, tenantId);
return new AttestationBundleImportResult(
Success: true,
Status: "imported",
AttestationId: metadata?.AttestationId,
TenantId: tenantId,
Namespace: options.Namespace,
RootHash: FormatRootHash(metadata?.RootHash),
ExitCode: AttestationBundleExitCodes.Success);
}
private async Task<(bool IsValid, string? ExpectedHash, string? ActualHash)> VerifyBundleChecksumAsync(
string bundlePath,
string sha256Path,
CancellationToken cancellationToken)
{
// Read expected hash from .sha256 file
var content = await File.ReadAllTextAsync(sha256Path, cancellationToken).ConfigureAwait(false);
var expectedHash = content.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim()?.ToLowerInvariant();
if (string.IsNullOrEmpty(expectedHash))
{
return (false, null, null);
}
// Compute actual hash
await using var stream = File.OpenRead(bundlePath);
var hashBytes = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
var actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
return (string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase), expectedHash, actualHash);
}
private async Task<BundleContents> ExtractBundleContentsAsync(
string bundlePath,
CancellationToken cancellationToken)
{
var contents = new BundleContents();
await using var fileStream = File.OpenRead(bundlePath);
await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress);
using var tarReader = new TarReader(gzipStream);
TarEntry? entry;
while ((entry = await tarReader.GetNextEntryAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) is not null)
{
if (entry.EntryType != TarEntryType.RegularFile || entry.DataStream is null)
{
continue;
}
using var memoryStream = new MemoryStream();
await entry.DataStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
var data = memoryStream.ToArray();
var text = System.Text.Encoding.UTF8.GetString(data);
switch (entry.Name)
{
case DsseEnvelopeFileName:
contents.DsseEnvelopeJson = text;
contents.DsseEnvelopeBytes = data;
contents.DsseEnvelope = JsonSerializer.Deserialize<DsseEnvelope>(text, SerializerOptions);
break;
case StatementFileName:
contents.StatementJson = text;
contents.StatementBytes = data;
contents.Statement = JsonSerializer.Deserialize<InTotoStatement>(text, SerializerOptions);
break;
case TransparencyFileName:
contents.TransparencyNdjson = text;
contents.TransparencyBytes = data;
break;
case MetadataFileName:
contents.MetadataJson = text;
contents.MetadataBytes = data;
contents.Metadata = JsonSerializer.Deserialize<AttestationBundleMetadata>(text, SerializerOptions);
break;
case ChecksumsFileName:
contents.ChecksumsText = text;
break;
}
}
return contents;
}
private (bool Success, string? ErrorMessage) VerifyInternalChecksums(BundleContents contents)
{
if (string.IsNullOrWhiteSpace(contents.ChecksumsText))
{
return (true, null);
}
var lines = contents.ChecksumsText.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
// Skip comments
if (line.TrimStart().StartsWith('#'))
{
continue;
}
// Parse "hash filename" format
var parts = line.Split(new[] { ' ' }, 2, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2)
{
continue;
}
var expectedHash = parts[0].Trim().ToLowerInvariant();
var fileName = parts[1].Trim();
byte[]? fileBytes = fileName switch
{
DsseEnvelopeFileName => contents.DsseEnvelopeBytes,
StatementFileName => contents.StatementBytes,
TransparencyFileName => contents.TransparencyBytes,
MetadataFileName => contents.MetadataBytes,
_ => null
};
if (fileBytes is null)
{
// File not found in bundle - could be optional
if (fileName == TransparencyFileName)
{
continue; // transparency.ndjson is optional
}
return (false, $"File '{fileName}' referenced in checksums but not found in bundle");
}
var actualHash = Convert.ToHexString(SHA256.HashData(fileBytes)).ToLowerInvariant();
if (!string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase))
{
return (false, $"Checksum mismatch for '{fileName}': expected {expectedHash}, got {actualHash}");
}
}
return (true, null);
}
private bool VerifyDsseSignature(BundleContents contents, bool offline, out string? error)
{
error = null;
if (contents.DsseEnvelope is null || string.IsNullOrEmpty(contents.DsseEnvelope.Payload))
{
error = "DSSE envelope not found or has no payload";
return false;
}
// Verify payload matches statement
if (contents.StatementJson is not null)
{
try
{
var payloadBytes = Convert.FromBase64String(contents.DsseEnvelope.Payload);
var payloadJson = System.Text.Encoding.UTF8.GetString(payloadBytes);
// Compare parsed JSON to handle whitespace differences
using var statementDoc = JsonDocument.Parse(contents.StatementJson);
using var payloadDoc = JsonDocument.Parse(payloadJson);
// Check _type field matches
var statementType = statementDoc.RootElement.TryGetProperty("_type", out var sType)
? sType.GetString()
: null;
var payloadType = payloadDoc.RootElement.TryGetProperty("_type", out var pType)
? pType.GetString()
: null;
if (!string.Equals(statementType, payloadType, StringComparison.Ordinal))
{
error = "DSSE payload does not match statement _type";
return false;
}
}
catch (FormatException ex)
{
error = $"Invalid DSSE payload encoding: {ex.Message}";
return false;
}
catch (JsonException ex)
{
error = $"Invalid DSSE payload JSON: {ex.Message}";
return false;
}
}
// In offline mode, we don't verify the actual cryptographic signature
// (would require access to signing keys/certificates)
if (offline)
{
_logger.LogDebug("Offline mode: skipping cryptographic signature verification");
return true;
}
// Check that signatures exist
if (contents.DsseEnvelope.Signatures is null || contents.DsseEnvelope.Signatures.Count == 0)
{
error = "DSSE envelope has no signatures";
return false;
}
// Online signature verification would require access to trust roots
// For now, we trust the signature if payload matches and signatures exist
return true;
}
private static IReadOnlyList<string>? ExtractSubjects(BundleContents contents)
{
if (contents.Statement?.Subject is null || contents.Statement.Subject.Count == 0)
{
// Fall back to metadata subjects
if (contents.Metadata?.SubjectDigests is not null)
{
return contents.Metadata.SubjectDigests
.Select(s => $"{s.Name}@{s.Algorithm}:{s.Digest}")
.ToList();
}
return null;
}
return contents.Statement.Subject
.Select(s =>
{
var digest = s.Digest?.FirstOrDefault();
return digest.HasValue
? $"{s.Name}@{digest.Value.Key}:{digest.Value.Value}"
: s.Name ?? "unknown";
})
.ToList();
}
private static string? ExtractPredicateType(BundleContents contents)
{
return contents.Statement?.PredicateType ?? contents.DsseEnvelope?.PayloadType;
}
private static string? FormatRootHash(string? rootHash)
{
if (string.IsNullOrWhiteSpace(rootHash))
{
return null;
}
return rootHash.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
? rootHash
: $"sha256:{rootHash}";
}
private static AttestationBundleVerifyResult CreateFailedResult(
int exitCode,
string message,
string bundlePath,
string? detail = null)
=> new(
Success: false,
Status: "failed",
ExportId: null,
AttestationId: null,
RootHash: null,
Subjects: null,
PredicateType: null,
StatementVersion: null,
BundlePath: bundlePath,
ErrorMessage: detail ?? message,
ExitCode: exitCode);
private sealed class BundleContents
{
public string? DsseEnvelopeJson { get; set; }
public byte[]? DsseEnvelopeBytes { get; set; }
public DsseEnvelope? DsseEnvelope { get; set; }
public string? StatementJson { get; set; }
public byte[]? StatementBytes { get; set; }
public InTotoStatement? Statement { get; set; }
public string? TransparencyNdjson { get; set; }
public byte[]? TransparencyBytes { get; set; }
public string? MetadataJson { get; set; }
public byte[]? MetadataBytes { get; set; }
public AttestationBundleMetadata? Metadata { get; set; }
public string? ChecksumsText { get; set; }
}
private sealed class DsseEnvelope
{
public string? PayloadType { get; set; }
public string? Payload { get; set; }
public IReadOnlyList<DsseSignature>? Signatures { get; set; }
}
private sealed class DsseSignature
{
public string? KeyId { get; set; }
public string? Sig { get; set; }
}
private sealed class InTotoStatement
{
public string? Type { get; set; }
public string? PredicateType { get; set; }
public IReadOnlyList<InTotoSubject>? Subject { get; set; }
}
private sealed class InTotoSubject
{
public string? Name { get; set; }
public Dictionary<string, string>? Digest { get; set; }
}
private sealed record AttestationBundleMetadata(
string? Version,
string? ExportId,
string? AttestationId,
string? TenantId,
DateTimeOffset? CreatedAtUtc,
string? RootHash,
string? SourceUri,
string? StatementVersion,
IReadOnlyList<AttestationSubjectDigest>? SubjectDigests);
private sealed record AttestationSubjectDigest(
string? Name,
string? Digest,
string? Algorithm);
}

View File

@@ -0,0 +1,380 @@
using System.Formats.Tar;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.Cli.Services;
/// <summary>
/// Verifier for EvidenceLocker sealed bundles used in DevPortal offline verification.
/// Per DVOFF-64-002.
/// </summary>
internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private readonly ILogger<DevPortalBundleVerifier> _logger;
public DevPortalBundleVerifier(ILogger<DevPortalBundleVerifier> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<DevPortalBundleVerificationResult> VerifyBundleAsync(
string bundlePath,
bool offline,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
_logger.LogDebug("Verifying DevPortal bundle at {BundlePath}, offline={Offline}", bundlePath, offline);
// Step 1: Check bundle exists
if (!File.Exists(bundlePath))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.Unexpected,
"Bundle file not found",
bundlePath);
}
// Step 2: Validate SHA-256 against .sha256 file if present
var sha256Path = bundlePath + ".sha256";
if (File.Exists(sha256Path))
{
var checksumResult = await VerifyBundleChecksumAsync(bundlePath, sha256Path, cancellationToken)
.ConfigureAwait(false);
if (!checksumResult.IsValid)
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.ChecksumMismatch,
"SHA-256 checksum mismatch",
$"Expected: {checksumResult.ExpectedHash}, Computed: {checksumResult.ActualHash}");
}
}
else
{
_logger.LogDebug("No .sha256 file found, skipping checksum verification");
}
// Step 3: Extract and parse bundle contents
BundleContents contents;
try
{
contents = await ExtractBundleContentsAsync(bundlePath, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is InvalidDataException or JsonException or IOException)
{
_logger.LogError(ex, "Failed to extract bundle contents");
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.Unexpected,
"Failed to extract bundle contents",
ex.Message);
}
// Step 4: Verify DSSE signature
var signatureValid = VerifyDsseSignature(contents, offline, out var signatureError);
if (!signatureValid && !string.IsNullOrEmpty(signatureError))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"DSSE signature verification failed",
signatureError);
}
// Step 5: Verify TSA (only if not offline)
if (!offline && contents.Signature is not null)
{
if (string.IsNullOrEmpty(contents.Signature.TimestampAuthority) ||
string.IsNullOrEmpty(contents.Signature.TimestampToken))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.TsaMissing,
"RFC3161 timestamp missing",
"Bundle requires timestamping when not in offline mode");
}
}
// Step 6: Build success result
return new DevPortalBundleVerificationResult
{
Status = "verified",
BundleId = contents.Manifest?.BundleId ?? contents.BundleMetadata?.BundleId,
RootHash = contents.BundleMetadata?.RootHash is not null
? $"sha256:{contents.BundleMetadata.RootHash}"
: null,
Entries = contents.Manifest?.Entries?.Count ?? 0,
CreatedAt = contents.Manifest?.CreatedAt ?? contents.BundleMetadata?.CreatedAt,
Portable = contents.BundleMetadata?.PortableGeneratedAt is not null,
ExitCode = DevPortalVerifyExitCode.Success
};
}
private async Task<(bool IsValid, string? ExpectedHash, string? ActualHash)> VerifyBundleChecksumAsync(
string bundlePath,
string sha256Path,
CancellationToken cancellationToken)
{
// Read expected hash from .sha256 file
var content = await File.ReadAllTextAsync(sha256Path, cancellationToken).ConfigureAwait(false);
var expectedHash = content.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim()?.ToLowerInvariant();
if (string.IsNullOrEmpty(expectedHash))
{
return (false, null, null);
}
// Compute actual hash
await using var stream = File.OpenRead(bundlePath);
var hashBytes = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
var actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
return (string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase), expectedHash, actualHash);
}
private async Task<BundleContents> ExtractBundleContentsAsync(
string bundlePath,
CancellationToken cancellationToken)
{
var contents = new BundleContents();
await using var fileStream = File.OpenRead(bundlePath);
await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress);
using var tarReader = new TarReader(gzipStream);
TarEntry? entry;
while ((entry = await tarReader.GetNextEntryAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) is not null)
{
if (entry.EntryType != TarEntryType.RegularFile || entry.DataStream is null)
{
continue;
}
using var memoryStream = new MemoryStream();
await entry.DataStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
var json = System.Text.Encoding.UTF8.GetString(memoryStream.ToArray());
switch (entry.Name)
{
case "manifest.json":
contents.ManifestJson = json;
contents.Manifest = JsonSerializer.Deserialize<BundleManifest>(json, SerializerOptions);
break;
case "signature.json":
contents.SignatureJson = json;
contents.Signature = JsonSerializer.Deserialize<BundleSignature>(json, SerializerOptions);
break;
case "bundle.json":
contents.BundleMetadataJson = json;
contents.BundleMetadata = JsonSerializer.Deserialize<BundleMetadataDocument>(json, SerializerOptions);
break;
case "checksums.txt":
contents.ChecksumsText = json;
break;
}
}
return contents;
}
private bool VerifyDsseSignature(BundleContents contents, bool offline, out string? error)
{
error = null;
if (contents.Signature is null || string.IsNullOrEmpty(contents.Signature.Payload))
{
error = "Signature not found in bundle";
return false;
}
// Verify payload matches manifest
if (contents.ManifestJson is not null)
{
try
{
var payloadBytes = Convert.FromBase64String(contents.Signature.Payload);
var payloadJson = System.Text.Encoding.UTF8.GetString(payloadBytes);
// Compare parsed JSON to handle whitespace differences
using var manifestDoc = JsonDocument.Parse(contents.ManifestJson);
using var payloadDoc = JsonDocument.Parse(payloadJson);
var manifestBundleId = manifestDoc.RootElement.TryGetProperty("bundleId", out var mId)
? mId.GetString()
: null;
var payloadBundleId = payloadDoc.RootElement.TryGetProperty("bundleId", out var pId)
? pId.GetString()
: null;
if (!string.Equals(manifestBundleId, payloadBundleId, StringComparison.OrdinalIgnoreCase))
{
error = "Signature payload does not match manifest bundleId";
return false;
}
}
catch (FormatException ex)
{
error = $"Invalid signature payload encoding: {ex.Message}";
return false;
}
catch (JsonException ex)
{
error = $"Invalid signature payload JSON: {ex.Message}";
return false;
}
}
// In offline mode, we don't verify the actual cryptographic signature
// (would require access to signing keys/certificates)
if (offline)
{
_logger.LogDebug("Offline mode: skipping cryptographic signature verification");
return true;
}
// Online signature verification would go here
// For now, we trust the signature if payload matches
return true;
}
private sealed class BundleContents
{
public string? ManifestJson { get; set; }
public BundleManifest? Manifest { get; set; }
public string? SignatureJson { get; set; }
public BundleSignature? Signature { get; set; }
public string? BundleMetadataJson { get; set; }
public BundleMetadataDocument? BundleMetadata { get; set; }
public string? ChecksumsText { get; set; }
}
private sealed class BundleManifest
{
public string? BundleId { get; set; }
public string? TenantId { get; set; }
public int Kind { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
public Dictionary<string, string>? Metadata { get; set; }
public List<BundleManifestEntry>? Entries { get; set; }
}
private sealed class BundleManifestEntry
{
public string? Section { get; set; }
public string? CanonicalPath { get; set; }
public string? Sha256 { get; set; }
public long SizeBytes { get; set; }
public string? MediaType { get; set; }
}
private sealed class BundleSignature
{
public string? PayloadType { get; set; }
public string? Payload { get; set; }
public string? Signature { get; set; }
public string? KeyId { get; set; }
public string? Algorithm { get; set; }
public string? Provider { get; set; }
public DateTimeOffset? SignedAt { get; set; }
public DateTimeOffset? TimestampedAt { get; set; }
public string? TimestampAuthority { get; set; }
public string? TimestampToken { get; set; }
}
private sealed class BundleMetadataDocument
{
public string? BundleId { get; set; }
public string? TenantId { get; set; }
public int Kind { get; set; }
public int Status { get; set; }
public string? RootHash { get; set; }
public string? StorageKey { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
public DateTimeOffset? SealedAt { get; set; }
public DateTimeOffset? PortableGeneratedAt { get; set; }
}
}
/// <summary>
/// Exit codes for DevPortal bundle verification per DVOFF-64-002.
/// </summary>
public enum DevPortalVerifyExitCode
{
/// <summary>Verification successful.</summary>
Success = 0,
/// <summary>SHA-256 checksum mismatch.</summary>
ChecksumMismatch = 2,
/// <summary>DSSE signature verification failed.</summary>
SignatureFailure = 3,
/// <summary>RFC3161 timestamp missing (when not offline).</summary>
TsaMissing = 4,
/// <summary>Unexpected error.</summary>
Unexpected = 5
}
/// <summary>
/// Result of DevPortal bundle verification.
/// </summary>
public sealed class DevPortalBundleVerificationResult
{
public string Status { get; set; } = "failed";
public string? BundleId { get; set; }
public string? RootHash { get; set; }
public int Entries { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
public bool Portable { get; set; }
public DevPortalVerifyExitCode ExitCode { get; set; } = DevPortalVerifyExitCode.Unexpected;
public string? ErrorMessage { get; set; }
public string? ErrorDetail { get; set; }
public static DevPortalBundleVerificationResult Failed(
DevPortalVerifyExitCode exitCode,
string message,
string? detail = null)
=> new()
{
Status = "failed",
ExitCode = exitCode,
ErrorMessage = message,
ErrorDetail = detail
};
public string ToJson()
{
var options = new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
// Build output with sorted keys
var output = new SortedDictionary<string, object?>(StringComparer.Ordinal);
if (BundleId is not null)
output["bundleId"] = BundleId;
if (CreatedAt.HasValue)
output["createdAt"] = CreatedAt.Value.ToString("O");
output["entries"] = Entries;
if (ErrorDetail is not null)
output["errorDetail"] = ErrorDetail;
if (ErrorMessage is not null)
output["errorMessage"] = ErrorMessage;
output["portable"] = Portable;
if (RootHash is not null)
output["rootHash"] = RootHash;
output["status"] = Status;
return JsonSerializer.Serialize(output, options);
}
}

View File

@@ -0,0 +1,29 @@
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Interface for attestation bundle verification.
/// </summary>
public interface IAttestationBundleVerifier
{
/// <summary>
/// Verifies an attestation bundle exported from the Export Center.
/// </summary>
/// <param name="options">Verification options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result with status and exit code.</returns>
Task<AttestationBundleVerifyResult> VerifyAsync(
AttestationBundleVerifyOptions options,
CancellationToken cancellationToken);
/// <summary>
/// Imports an attestation bundle into the local system.
/// </summary>
/// <param name="options">Import options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Import result with status and exit code.</returns>
Task<AttestationBundleImportResult> ImportAsync(
AttestationBundleImportOptions options,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Cli.Services;
/// <summary>
/// Interface for DevPortal bundle verification.
/// </summary>
public interface IDevPortalBundleVerifier
{
/// <summary>
/// Verifies a DevPortal/EvidenceLocker sealed bundle.
/// </summary>
/// <param name="bundlePath">Path to the bundle .tgz file.</param>
/// <param name="offline">If true, skip TSA verification and online checks.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result with status and exit code.</returns>
Task<DevPortalBundleVerificationResult> VerifyBundleAsync(
string bundlePath,
bool offline,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,126 @@
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
/// <summary>
/// Options for attestation bundle verification.
/// </summary>
public sealed record AttestationBundleVerifyOptions(
string FilePath,
bool Offline = false,
bool VerifyTransparency = true,
string? TrustRootPath = null);
/// <summary>
/// Options for attestation bundle import.
/// </summary>
public sealed record AttestationBundleImportOptions(
string FilePath,
string? Tenant = null,
string? Namespace = null,
bool Offline = false,
bool VerifyTransparency = true,
string? TrustRootPath = null);
/// <summary>
/// Result of attestation bundle verification.
/// </summary>
public sealed record AttestationBundleVerifyResult(
bool Success,
string Status,
string? ExportId,
string? AttestationId,
string? RootHash,
IReadOnlyList<string>? Subjects,
string? PredicateType,
string? StatementVersion,
string BundlePath,
string? ErrorMessage = null,
int ExitCode = 0);
/// <summary>
/// Result of attestation bundle import.
/// </summary>
public sealed record AttestationBundleImportResult(
bool Success,
string Status,
string? AttestationId,
string? TenantId,
string? Namespace,
string? RootHash,
string? ErrorMessage = null,
int ExitCode = 0);
/// <summary>
/// JSON output for attestation bundle verify command.
/// </summary>
public sealed record AttestationBundleVerifyJson(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("exportId")] string? ExportId,
[property: JsonPropertyName("attestationId")] string? AttestationId,
[property: JsonPropertyName("rootHash")] string? RootHash,
[property: JsonPropertyName("subjects")] IReadOnlyList<string>? Subjects,
[property: JsonPropertyName("predicateType")] string? PredicateType,
[property: JsonPropertyName("bundlePath")] string BundlePath);
/// <summary>
/// JSON output for attestation bundle import command.
/// </summary>
public sealed record AttestationBundleImportJson(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("attestationId")] string? AttestationId,
[property: JsonPropertyName("tenantId")] string? TenantId,
[property: JsonPropertyName("namespace")] string? Namespace,
[property: JsonPropertyName("rootHash")] string? RootHash);
/// <summary>
/// Exit codes for attestation bundle commands.
/// </summary>
public static class AttestationBundleExitCodes
{
/// <summary>Success.</summary>
public const int Success = 0;
/// <summary>General failure.</summary>
public const int GeneralFailure = 1;
/// <summary>Checksum mismatch.</summary>
public const int ChecksumMismatch = 2;
/// <summary>DSSE signature verification failure.</summary>
public const int SignatureFailure = 3;
/// <summary>Missing required TSA/CT log entry.</summary>
public const int MissingTransparency = 4;
/// <summary>Archive or file format error.</summary>
public const int FormatError = 5;
/// <summary>File not found.</summary>
public const int FileNotFound = 6;
/// <summary>Import failed.</summary>
public const int ImportFailed = 7;
}
/// <summary>
/// Metadata parsed from an attestation bundle.
/// </summary>
internal sealed record AttestationBundleMetadata(
string? Version,
string? ExportId,
string? AttestationId,
string? TenantId,
DateTimeOffset? CreatedAtUtc,
string? RootHash,
string? SourceUri,
string? StatementVersion,
IReadOnlyList<AttestationBundleSubjectDigest>? SubjectDigests);
/// <summary>
/// Subject digest from attestation bundle metadata.
/// </summary>
internal sealed record AttestationBundleSubjectDigest(
string? Name,
string? Digest,
string? Algorithm);