512 lines
17 KiB
C#
512 lines
17 KiB
C#
// -----------------------------------------------------------------------------
|
|
// ProvCommandGroup.cs
|
|
// Sprint: SPRINT_8200_0001_0002 (Provcache Invalidation & Air-Gap)
|
|
// Tasks: PROV-8200-135 to PROV-8200-143 - CLI commands for provcache operations.
|
|
// Description: CLI commands for minimal proof export, import, and verification.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.CommandLine;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using StellaOps.Cli.Extensions;
|
|
using StellaOps.Provcache;
|
|
|
|
namespace StellaOps.Cli.Commands;
|
|
|
|
/// <summary>
|
|
/// Command group for Provcache operations.
|
|
/// Implements minimal proof export/import for air-gap scenarios.
|
|
/// </summary>
|
|
public static class ProvCommandGroup
|
|
{
|
|
/// <summary>
|
|
/// Build the prov command tree.
|
|
/// </summary>
|
|
public static Command BuildProvCommand(
|
|
IServiceProvider services,
|
|
Option<bool> verboseOption,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var provCommand = new Command("prov", "Provenance cache operations for air-gap scenarios");
|
|
|
|
provCommand.Add(BuildExportCommand(services, verboseOption, cancellationToken));
|
|
provCommand.Add(BuildImportCommand(services, verboseOption, cancellationToken));
|
|
provCommand.Add(BuildVerifyCommand(services, verboseOption, cancellationToken));
|
|
|
|
return provCommand;
|
|
}
|
|
|
|
private static Command BuildExportCommand(
|
|
IServiceProvider services,
|
|
Option<bool> verboseOption,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var verikeyOption = new Option<string>("--verikey", "-k")
|
|
{
|
|
Description = "The VeriKey (sha256:...) identifying the cache entry to export",
|
|
Required = true
|
|
};
|
|
|
|
var densityOption = new Option<string>("--density", "-d")
|
|
{
|
|
Description = "Evidence density level: lite (digest only), standard (+ first N chunks), strict (all chunks)"
|
|
};
|
|
densityOption.SetDefaultValue("standard");
|
|
densityOption.FromAmong("lite", "standard", "strict");
|
|
|
|
var chunksOption = new Option<int>("--chunks", "-c")
|
|
{
|
|
Description = "Number of chunks to include for standard density (default: 3)"
|
|
};
|
|
chunksOption.SetDefaultValue(3);
|
|
|
|
var outputOption = new Option<string>("--output", "-o")
|
|
{
|
|
Description = "Output file path for the bundle (default: proof-<verikey>.json)",
|
|
Required = true
|
|
};
|
|
|
|
var signOption = new Option<bool>("--sign", "-s")
|
|
{
|
|
Description = "Sign the exported bundle"
|
|
};
|
|
|
|
var signerOption = new Option<string?>("--signer")
|
|
{
|
|
Description = "Signer key ID to use (if --sign is specified)"
|
|
};
|
|
|
|
var command = new Command("export", "Export a minimal proof bundle for air-gapped transfer")
|
|
{
|
|
verikeyOption,
|
|
densityOption,
|
|
chunksOption,
|
|
outputOption,
|
|
signOption,
|
|
signerOption,
|
|
verboseOption
|
|
};
|
|
|
|
command.SetAction(async (parseResult, ct) =>
|
|
{
|
|
var verikey = parseResult.GetValue(verikeyOption) ?? string.Empty;
|
|
var densityStr = parseResult.GetValue(densityOption) ?? "standard";
|
|
var chunks = parseResult.GetValue(chunksOption);
|
|
var output = parseResult.GetValue(outputOption) ?? string.Empty;
|
|
var sign = parseResult.GetValue(signOption);
|
|
var signer = parseResult.GetValue(signerOption);
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
return await HandleExportAsync(
|
|
services,
|
|
verikey,
|
|
densityStr,
|
|
chunks,
|
|
output,
|
|
sign,
|
|
signer,
|
|
verbose,
|
|
ct);
|
|
});
|
|
|
|
return command;
|
|
}
|
|
|
|
private static Command BuildImportCommand(
|
|
IServiceProvider services,
|
|
Option<bool> verboseOption,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var inputArg = new Argument<string>("input")
|
|
{
|
|
Description = "Path to the proof bundle file"
|
|
};
|
|
|
|
var lazyFetchOption = new Option<bool>("--lazy-fetch")
|
|
{
|
|
Description = "Enable lazy chunk fetching for missing chunks"
|
|
};
|
|
|
|
var backendOption = new Option<string?>("--backend")
|
|
{
|
|
Description = "Backend URL for lazy fetch (e.g., https://stellaops.example.com)"
|
|
};
|
|
|
|
var chunksDirOption = new Option<string?>("--chunks-dir")
|
|
{
|
|
Description = "Local directory containing chunk files for offline import"
|
|
};
|
|
|
|
var outputOption = new Option<string>("--output", "-o")
|
|
{
|
|
Description = "Output format: text, json"
|
|
};
|
|
outputOption.SetDefaultValue("text");
|
|
outputOption.FromAmong("text", "json");
|
|
|
|
var command = new Command("import", "Import a minimal proof bundle")
|
|
{
|
|
inputArg,
|
|
lazyFetchOption,
|
|
backendOption,
|
|
chunksDirOption,
|
|
outputOption,
|
|
verboseOption
|
|
};
|
|
|
|
command.SetAction(async (parseResult, ct) =>
|
|
{
|
|
var input = parseResult.GetValue(inputArg) ?? string.Empty;
|
|
var lazyFetch = parseResult.GetValue(lazyFetchOption);
|
|
var backend = parseResult.GetValue(backendOption);
|
|
var chunksDir = parseResult.GetValue(chunksDirOption);
|
|
var output = parseResult.GetValue(outputOption) ?? "text";
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
return await HandleImportAsync(
|
|
services,
|
|
input,
|
|
lazyFetch,
|
|
backend,
|
|
chunksDir,
|
|
output,
|
|
verbose,
|
|
ct);
|
|
});
|
|
|
|
return command;
|
|
}
|
|
|
|
private static Command BuildVerifyCommand(
|
|
IServiceProvider services,
|
|
Option<bool> verboseOption,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var inputArg = new Argument<string>("input")
|
|
{
|
|
Description = "Path to the proof bundle file to verify"
|
|
};
|
|
|
|
var signerCertOption = new Option<string?>("--signer-cert")
|
|
{
|
|
Description = "Path to signer certificate for signature verification"
|
|
};
|
|
|
|
var outputOption = new Option<string>("--output", "-o")
|
|
{
|
|
Description = "Output format: text, json"
|
|
};
|
|
outputOption.SetDefaultValue("text");
|
|
outputOption.FromAmong("text", "json");
|
|
|
|
var command = new Command("verify", "Verify a proof bundle without importing")
|
|
{
|
|
inputArg,
|
|
signerCertOption,
|
|
outputOption,
|
|
verboseOption
|
|
};
|
|
|
|
command.SetAction(async (parseResult, ct) =>
|
|
{
|
|
var input = parseResult.GetValue(inputArg) ?? string.Empty;
|
|
var signerCert = parseResult.GetValue(signerCertOption);
|
|
var output = parseResult.GetValue(outputOption) ?? "text";
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
|
|
return await HandleVerifyAsync(
|
|
services,
|
|
input,
|
|
signerCert,
|
|
output,
|
|
verbose,
|
|
ct);
|
|
});
|
|
|
|
return command;
|
|
}
|
|
|
|
#region Handlers
|
|
|
|
private static async Task<int> HandleExportAsync(
|
|
IServiceProvider services,
|
|
string verikey,
|
|
string densityStr,
|
|
int chunks,
|
|
string output,
|
|
bool sign,
|
|
string? signer,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var loggerFactory = services.GetService<ILoggerFactory>();
|
|
var logger = loggerFactory?.CreateLogger("ProvCommands");
|
|
|
|
if (verbose)
|
|
{
|
|
logger?.LogInformation("Exporting proof bundle for {VeriKey} with density {Density}",
|
|
verikey, densityStr);
|
|
}
|
|
|
|
var density = densityStr.ToLowerInvariant() switch
|
|
{
|
|
"lite" => ProofDensity.Lite,
|
|
"standard" => ProofDensity.Standard,
|
|
"strict" => ProofDensity.Strict,
|
|
_ => ProofDensity.Standard
|
|
};
|
|
|
|
try
|
|
{
|
|
var exporter = services.GetService<IMinimalProofExporter>();
|
|
if (exporter is null)
|
|
{
|
|
Console.Error.WriteLine("Error: Provcache services not configured.");
|
|
return 1;
|
|
}
|
|
|
|
var options = new MinimalProofExportOptions
|
|
{
|
|
Density = density,
|
|
StandardDensityChunkCount = chunks,
|
|
Sign = sign,
|
|
SigningKeyId = signer,
|
|
ExportedBy = Environment.MachineName
|
|
};
|
|
|
|
Console.WriteLine($"Exporting proof bundle: {verikey}");
|
|
Console.WriteLine($" Density: {density}");
|
|
Console.WriteLine($" Output: {output}");
|
|
|
|
using var fileStream = File.Create(output);
|
|
await exporter.ExportToStreamAsync(verikey, options, fileStream, cancellationToken);
|
|
|
|
var fileInfo = new FileInfo(output);
|
|
Console.WriteLine($" Size: {fileInfo.Length:N0} bytes");
|
|
Console.WriteLine("[green]Export complete.[/]");
|
|
|
|
return 0;
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
Console.Error.WriteLine($"Error: {ex.Message}");
|
|
return 1;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"Export failed: {ex.Message}");
|
|
if (verbose)
|
|
{
|
|
Console.Error.WriteLine(ex.ToString());
|
|
}
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
private static async Task<int> HandleImportAsync(
|
|
IServiceProvider services,
|
|
string input,
|
|
bool lazyFetch,
|
|
string? backend,
|
|
string? chunksDir,
|
|
string output,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var loggerFactory = services.GetService<ILoggerFactory>();
|
|
var logger = loggerFactory?.CreateLogger("ProvCommands");
|
|
|
|
if (!File.Exists(input))
|
|
{
|
|
Console.Error.WriteLine($"Error: File not found: {input}");
|
|
return 1;
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
logger?.LogInformation("Importing proof bundle from {Input}", input);
|
|
}
|
|
|
|
try
|
|
{
|
|
var exporter = services.GetService<IMinimalProofExporter>();
|
|
if (exporter is null)
|
|
{
|
|
Console.Error.WriteLine("Error: Provcache services not configured.");
|
|
return 1;
|
|
}
|
|
|
|
Console.WriteLine($"Importing proof bundle: {input}");
|
|
|
|
using var fileStream = File.OpenRead(input);
|
|
var result = await exporter.ImportFromStreamAsync(fileStream, cancellationToken);
|
|
|
|
if (output == "json")
|
|
{
|
|
var json = System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
|
|
});
|
|
Console.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine($" Success: {result.Success}");
|
|
Console.WriteLine($" Chunks imported: {result.ChunksImported}");
|
|
Console.WriteLine($" Chunks pending: {result.ChunksPending}");
|
|
Console.WriteLine($" Merkle valid: {result.Verification.MerkleRootValid}");
|
|
Console.WriteLine($" Digest valid: {result.Verification.DigestValid}");
|
|
Console.WriteLine($" Chunks valid: {result.Verification.ChunksValid}");
|
|
|
|
if (result.Verification.SignatureValid.HasValue)
|
|
{
|
|
Console.WriteLine($" Signature valid: {result.Verification.SignatureValid.Value}");
|
|
}
|
|
|
|
if (result.Warnings.Count > 0)
|
|
{
|
|
Console.WriteLine(" Warnings:");
|
|
foreach (var warning in result.Warnings)
|
|
{
|
|
Console.WriteLine($" - {warning}");
|
|
}
|
|
}
|
|
|
|
if (result.ChunksPending > 0 && lazyFetch)
|
|
{
|
|
Console.WriteLine($"\n Lazy fetch enabled: {result.ChunksPending} chunks can be fetched on demand.");
|
|
if (!string.IsNullOrEmpty(backend))
|
|
{
|
|
Console.WriteLine($" Backend: {backend}");
|
|
}
|
|
if (!string.IsNullOrEmpty(chunksDir))
|
|
{
|
|
Console.WriteLine($" Chunks dir: {chunksDir}");
|
|
}
|
|
}
|
|
}
|
|
|
|
return result.Success ? 0 : 1;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"Import failed: {ex.Message}");
|
|
if (verbose)
|
|
{
|
|
Console.Error.WriteLine(ex.ToString());
|
|
}
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
private static async Task<int> HandleVerifyAsync(
|
|
IServiceProvider services,
|
|
string input,
|
|
string? signerCert,
|
|
string output,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var loggerFactory = services.GetService<ILoggerFactory>();
|
|
var logger = loggerFactory?.CreateLogger("ProvCommands");
|
|
|
|
if (!File.Exists(input))
|
|
{
|
|
Console.Error.WriteLine($"Error: File not found: {input}");
|
|
return 1;
|
|
}
|
|
|
|
if (verbose)
|
|
{
|
|
logger?.LogInformation("Verifying proof bundle: {Input}", input);
|
|
}
|
|
|
|
try
|
|
{
|
|
var exporter = services.GetService<IMinimalProofExporter>();
|
|
if (exporter is null)
|
|
{
|
|
Console.Error.WriteLine("Error: Provcache services not configured.");
|
|
return 1;
|
|
}
|
|
|
|
Console.WriteLine($"Verifying proof bundle: {input}");
|
|
|
|
var jsonBytes = await File.ReadAllBytesAsync(input, cancellationToken);
|
|
var bundle = System.Text.Json.JsonSerializer.Deserialize<MinimalProofBundle>(jsonBytes,
|
|
new System.Text.Json.JsonSerializerOptions
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
|
|
});
|
|
|
|
if (bundle is null)
|
|
{
|
|
Console.Error.WriteLine("Error: Failed to parse bundle file.");
|
|
return 1;
|
|
}
|
|
|
|
var verification = await exporter.VerifyAsync(bundle, cancellationToken);
|
|
|
|
if (output == "json")
|
|
{
|
|
var json = System.Text.Json.JsonSerializer.Serialize(verification, new System.Text.Json.JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
|
|
});
|
|
Console.WriteLine(json);
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine($" Digest valid: {verification.DigestValid}");
|
|
Console.WriteLine($" Merkle root valid: {verification.MerkleRootValid}");
|
|
Console.WriteLine($" Chunks valid: {verification.ChunksValid}");
|
|
|
|
if (verification.SignatureValid.HasValue)
|
|
{
|
|
Console.WriteLine($" Signature valid: {verification.SignatureValid.Value}");
|
|
}
|
|
|
|
if (verification.FailedChunkIndices.Count > 0)
|
|
{
|
|
Console.WriteLine($" Failed chunks: {string.Join(", ", verification.FailedChunkIndices)}");
|
|
}
|
|
|
|
var overall = verification.DigestValid &&
|
|
verification.MerkleRootValid &&
|
|
verification.ChunksValid &&
|
|
(verification.SignatureValid ?? true);
|
|
|
|
Console.WriteLine();
|
|
if (overall)
|
|
{
|
|
Console.WriteLine("[green]Verification PASSED[/]");
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine("[red]Verification FAILED[/]");
|
|
}
|
|
}
|
|
|
|
var success = verification.DigestValid &&
|
|
verification.MerkleRootValid &&
|
|
verification.ChunksValid;
|
|
|
|
return success ? 0 : 1;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"Verification failed: {ex.Message}");
|
|
if (verbose)
|
|
{
|
|
Console.Error.WriteLine(ex.ToString());
|
|
}
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|