288 lines
10 KiB
C#
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
|