Files
git.stella-ops.org/src/Cli/__Libraries/StellaOps.Cli.Plugins.Timestamp/EvidenceCliCommands.cs
2026-01-20 00:45:38 +02:00

288 lines
10 KiB
C#

// -----------------------------------------------------------------------------
// EvidenceCliCommands.cs
// Sprint: SPRINT_20260119_010 Attestor TST Integration
// Task: ATT-005 - CLI Commands
// Description: CLI commands for evidence storage operations.
// -----------------------------------------------------------------------------
using System.CommandLine;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Configuration;
namespace StellaOps.Cli.Plugins.Timestamp;
/// <summary>
/// CLI commands for evidence storage operations.
/// </summary>
public static class EvidenceCliCommands
{
/// <summary>
/// stella evidence store --artifact <file.dsse> --tst <file.tst> --rekor-bundle <file.json>
/// --tsa-chain <chain.pem> --ocsp <ocsp.der>
/// </summary>
public static Command BuildStoreCommand(
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption)
{
var artifactOption = new Option<FileInfo>("--artifact", "DSSE envelope or artifact file")
{
IsRequired = true
};
artifactOption.AddAlias("-a");
var tstOption = new Option<FileInfo?>("--tst", "Timestamp token file");
tstOption.AddAlias("-t");
var rekorOption = new Option<FileInfo?>("--rekor-bundle", "Rekor bundle JSON file");
rekorOption.AddAlias("-r");
var chainOption = new Option<FileInfo?>("--tsa-chain", "TSA certificate chain PEM file");
chainOption.AddAlias("-c");
var ocspOption = new Option<FileInfo?>("--ocsp", "Stapled OCSP response file");
var crlOption = new Option<FileInfo?>("--crl", "CRL snapshot file");
var cmd = new Command("store", "Store timestamp and attestation evidence.")
{
artifactOption,
tstOption,
rekorOption,
chainOption,
ocspOption,
crlOption,
verboseOption
};
cmd.SetHandler(async (context) =>
{
var artifact = context.ParseResult.GetValueForOption(artifactOption)!;
var tst = context.ParseResult.GetValueForOption(tstOption);
var rekor = context.ParseResult.GetValueForOption(rekorOption);
var chain = context.ParseResult.GetValueForOption(chainOption);
var ocsp = context.ParseResult.GetValueForOption(ocspOption);
var crl = context.ParseResult.GetValueForOption(crlOption);
var verbose = context.ParseResult.GetValueForOption(verboseOption);
var logger = services.GetRequiredService<ILogger<TimestampCliCommandModule>>();
try
{
if (!artifact.Exists)
{
Console.Error.WriteLine($"Error: Artifact file not found: {artifact.FullName}");
context.ExitCode = 1;
return;
}
// Compute artifact digest
await using var stream = artifact.OpenRead();
var hash = await System.Security.Cryptography.SHA256.HashDataAsync(stream, context.GetCancellationToken());
var artifactDigest = $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
Console.WriteLine($"Artifact: {artifact.Name}");
Console.WriteLine($"Digest: {artifactDigest}");
var evidenceStore = services.GetService<IEvidenceStore>();
if (evidenceStore is null)
{
Console.Error.WriteLine("Error: Evidence store not available.");
context.ExitCode = 1;
return;
}
var storeRequest = new EvidenceStoreRequest
{
ArtifactDigest = artifactDigest
};
// Load optional files
if (tst is not null && tst.Exists)
{
storeRequest.TimestampToken = await File.ReadAllBytesAsync(tst.FullName, context.GetCancellationToken());
Console.WriteLine($"TST: {tst.Name} ({storeRequest.TimestampToken.Length} bytes)");
}
if (rekor is not null && rekor.Exists)
{
storeRequest.RekorBundle = await File.ReadAllTextAsync(rekor.FullName, context.GetCancellationToken());
Console.WriteLine($"Rekor bundle: {rekor.Name}");
}
if (chain is not null && chain.Exists)
{
storeRequest.TsaChainPem = await File.ReadAllTextAsync(chain.FullName, context.GetCancellationToken());
Console.WriteLine($"TSA chain: {chain.Name}");
}
if (ocsp is not null && ocsp.Exists)
{
storeRequest.OcspResponse = await File.ReadAllBytesAsync(ocsp.FullName, context.GetCancellationToken());
Console.WriteLine($"OCSP response: {ocsp.Name} ({storeRequest.OcspResponse.Length} bytes)");
}
if (crl is not null && crl.Exists)
{
storeRequest.CrlSnapshot = await File.ReadAllBytesAsync(crl.FullName, context.GetCancellationToken());
Console.WriteLine($"CRL snapshot: {crl.Name} ({storeRequest.CrlSnapshot.Length} bytes)");
}
var result = await evidenceStore.StoreAsync(storeRequest, context.GetCancellationToken());
Console.WriteLine();
Console.WriteLine($"Evidence stored successfully.");
Console.WriteLine($"Evidence ID: {result.EvidenceId}");
if (verbose)
{
Console.WriteLine($"Stored at: {result.StoredAt:O}");
if (result.TimestampId.HasValue)
{
Console.WriteLine($"Timestamp evidence ID: {result.TimestampId}");
}
}
context.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to store evidence");
Console.Error.WriteLine($"Error: {ex.Message}");
context.ExitCode = 1;
}
});
return cmd;
}
/// <summary>
/// stella evidence export --artifact <digest> --out <directory>
/// </summary>
public static Command BuildExportCommand(
IServiceProvider services,
Option<bool> verboseOption)
{
var artifactOption = new Option<string>("--artifact", "Artifact digest to export evidence for")
{
IsRequired = true
};
artifactOption.AddAlias("-a");
var outOption = new Option<DirectoryInfo>("--out", "Output directory for evidence bundle")
{
IsRequired = true
};
outOption.AddAlias("-o");
var formatOption = new Option<string>("--format", () => "bundle", "Export format: bundle, json, or individual");
var cmd = new Command("export", "Export evidence for an artifact.")
{
artifactOption,
outOption,
formatOption,
verboseOption
};
cmd.SetHandler(async (context) =>
{
var artifact = context.ParseResult.GetValueForOption(artifactOption)!;
var outDir = context.ParseResult.GetValueForOption(outOption)!;
var format = context.ParseResult.GetValueForOption(formatOption);
var verbose = context.ParseResult.GetValueForOption(verboseOption);
var logger = services.GetRequiredService<ILogger<TimestampCliCommandModule>>();
try
{
var evidenceStore = services.GetService<IEvidenceStore>();
if (evidenceStore is null)
{
Console.Error.WriteLine("Error: Evidence store not available.");
context.ExitCode = 1;
return;
}
if (!outDir.Exists)
{
outDir.Create();
}
var result = await evidenceStore.ExportAsync(
artifact,
outDir.FullName,
format,
context.GetCancellationToken());
Console.WriteLine($"Evidence exported to: {outDir.FullName}");
Console.WriteLine($"Files exported: {result.FileCount}");
if (verbose)
{
foreach (var file in result.Files)
{
Console.WriteLine($" {file}");
}
}
context.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to export evidence");
Console.Error.WriteLine($"Error: {ex.Message}");
context.ExitCode = 1;
}
});
return cmd;
}
}
#region Service Interfaces
/// <summary>
/// Evidence storage service.
/// </summary>
public interface IEvidenceStore
{
Task<EvidenceStoreResult> StoreAsync(EvidenceStoreRequest request, CancellationToken ct);
Task<EvidenceExportResult> ExportAsync(string artifactDigest, string outputPath, string format, CancellationToken ct);
}
/// <summary>
/// Evidence store request.
/// </summary>
public sealed record EvidenceStoreRequest
{
public required string ArtifactDigest { get; init; }
public byte[]? TimestampToken { get; set; }
public string? RekorBundle { get; set; }
public string? TsaChainPem { get; set; }
public byte[]? OcspResponse { get; set; }
public byte[]? CrlSnapshot { get; set; }
}
/// <summary>
/// Evidence store result.
/// </summary>
public sealed record EvidenceStoreResult
{
public required Guid EvidenceId { get; init; }
public Guid? TimestampId { get; init; }
public required DateTimeOffset StoredAt { get; init; }
}
/// <summary>
/// Evidence export result.
/// </summary>
public sealed record EvidenceExportResult
{
public required int FileCount { get; init; }
public required IReadOnlyList<string> Files { get; init; }
}
#endregion