license switch agpl -> busl1, sprints work, new product advisories
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_4100_0006_0005 - Admin Utility Integration
|
||||
|
||||
using System.CommandLine;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_4100_0006_0005 - Admin Utility Integration
|
||||
|
||||
using System.Net.Http.Json;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
using System.CommandLine;
|
||||
using StellaOps.Agent.Core.Bootstrap;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
using System.CommandLine;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
using System.CommandLine;
|
||||
|
||||
|
||||
25
src/Cli/StellaOps.Cli/Commands/BundleCommandGroup.cs
Normal file
25
src/Cli/StellaOps.Cli/Commands/BundleCommandGroup.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleCommandGroup.cs
|
||||
// Sprint: SPRINT_20260120_029_AirGap_offline_bundle_contract
|
||||
// Task: TASK-029-003 - Signed verification report generation
|
||||
// Description: Domain-level bundle command group for offline evidence bundles.
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.CommandLine;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
public static class BundleCommandGroup
|
||||
{
|
||||
public static Command BuildBundleCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundle = new Command("bundle", "Offline evidence bundle operations.");
|
||||
bundle.Add(BundleVerifyCommand.BuildVerifyBundleEnhancedCommand(
|
||||
services,
|
||||
verboseOption,
|
||||
cancellationToken));
|
||||
return bundle;
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,16 @@
|
||||
using System.CommandLine;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Core.Predicates;
|
||||
using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.Serialization;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
@@ -29,7 +35,7 @@ public static class BundleVerifyCommand
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the 'verify --bundle' enhanced command.
|
||||
/// Builds the 'bundle verify' enhanced command.
|
||||
/// </summary>
|
||||
public static Command BuildVerifyBundleEnhancedCommand(
|
||||
IServiceProvider services,
|
||||
@@ -65,10 +71,20 @@ public static class BundleVerifyCommand
|
||||
|
||||
var strictOption = new Option<bool>("--strict")
|
||||
{
|
||||
Description = "Fail on any warning (missing optional artifacts)"
|
||||
Description = "Fail on any warning (missing optional artifacts)"
|
||||
};
|
||||
|
||||
var command = new Command("bundle-verify", "Verify offline evidence bundle with full cryptographic verification")
|
||||
var signerOption = new Option<string?>("--signer")
|
||||
{
|
||||
Description = "Path to signing key (PEM) for DSSE verification report"
|
||||
};
|
||||
|
||||
var signerCertOption = new Option<string?>("--signer-cert")
|
||||
{
|
||||
Description = "Path to signer certificate PEM (optional; embedded in report metadata)"
|
||||
};
|
||||
|
||||
var command = new Command("verify", "Verify offline evidence bundle with full cryptographic verification")
|
||||
{
|
||||
bundleOption,
|
||||
trustRootOption,
|
||||
@@ -76,6 +92,8 @@ public static class BundleVerifyCommand
|
||||
offlineOption,
|
||||
outputOption,
|
||||
strictOption,
|
||||
signerOption,
|
||||
signerCertOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
@@ -87,6 +105,8 @@ public static class BundleVerifyCommand
|
||||
var offline = parseResult.GetValue(offlineOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var strict = parseResult.GetValue(strictOption);
|
||||
var signer = parseResult.GetValue(signerOption);
|
||||
var signerCert = parseResult.GetValue(signerCertOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleVerifyBundleAsync(
|
||||
@@ -97,6 +117,8 @@ public static class BundleVerifyCommand
|
||||
offline,
|
||||
output,
|
||||
strict,
|
||||
signer,
|
||||
signerCert,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
@@ -112,6 +134,8 @@ public static class BundleVerifyCommand
|
||||
bool offline,
|
||||
string outputFormat,
|
||||
bool strict,
|
||||
string? signerKeyPath,
|
||||
string? signerCertPath,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
@@ -125,6 +149,9 @@ public static class BundleVerifyCommand
|
||||
Offline = offline
|
||||
};
|
||||
|
||||
string? bundleDir = null;
|
||||
BundleManifestDto? manifest = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (outputFormat != "json")
|
||||
@@ -136,18 +163,29 @@ public static class BundleVerifyCommand
|
||||
}
|
||||
|
||||
// Step 1: Extract/read bundle
|
||||
var bundleDir = await ExtractBundleAsync(bundlePath, ct);
|
||||
bundleDir = await ExtractBundleAsync(bundlePath, ct);
|
||||
|
||||
// Step 2: Parse manifest
|
||||
var manifestPath = Path.Combine(bundleDir, "manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
result.Checks.Add(new VerificationCheck("manifest", false, "manifest.json not found"));
|
||||
return OutputResult(result, outputFormat, strict);
|
||||
return await FinalizeResultAsync(
|
||||
result,
|
||||
manifest,
|
||||
bundleDir,
|
||||
trustRoot,
|
||||
rekorCheckpoint,
|
||||
offline,
|
||||
outputFormat,
|
||||
strict,
|
||||
signerKeyPath,
|
||||
signerCertPath,
|
||||
ct);
|
||||
}
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
var manifest = JsonSerializer.Deserialize<BundleManifestDto>(manifestJson, JsonOptions);
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
manifest = JsonSerializer.Deserialize<BundleManifestDto>(manifestJson, JsonOptions);
|
||||
result.Checks.Add(new VerificationCheck("manifest", true, "manifest.json parsed successfully"));
|
||||
result.SchemaVersion = manifest?.SchemaVersion;
|
||||
result.Image = manifest?.Bundle?.Image;
|
||||
@@ -185,11 +223,18 @@ public static class BundleVerifyCommand
|
||||
Console.WriteLine($"Step 5: Payload Types {(payloadsPassed ? "✓" : "⚠")}");
|
||||
}
|
||||
|
||||
result.CompletedAt = DateTimeOffset.UtcNow;
|
||||
result.OverallStatus = result.Checks.All(c => c.Passed) ? "PASSED" :
|
||||
result.Checks.Any(c => !c.Passed && c.Severity == "error") ? "FAILED" : "PASSED_WITH_WARNINGS";
|
||||
|
||||
return OutputResult(result, outputFormat, strict);
|
||||
return await FinalizeResultAsync(
|
||||
result,
|
||||
manifest,
|
||||
bundleDir,
|
||||
trustRoot,
|
||||
rekorCheckpoint,
|
||||
offline,
|
||||
outputFormat,
|
||||
strict,
|
||||
signerKeyPath,
|
||||
signerCertPath,
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -446,6 +491,377 @@ public static class BundleVerifyCommand
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task<int> FinalizeResultAsync(
|
||||
VerificationResult result,
|
||||
BundleManifestDto? manifest,
|
||||
string bundleDir,
|
||||
string? trustRoot,
|
||||
string? rekorCheckpoint,
|
||||
bool offline,
|
||||
string outputFormat,
|
||||
bool strict,
|
||||
string? signerKeyPath,
|
||||
string? signerCertPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
result.CompletedAt ??= DateTimeOffset.UtcNow;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(signerKeyPath))
|
||||
{
|
||||
var outcome = await TryWriteSignedReportAsync(
|
||||
result,
|
||||
manifest,
|
||||
bundleDir,
|
||||
trustRoot,
|
||||
rekorCheckpoint,
|
||||
offline,
|
||||
signerKeyPath,
|
||||
signerCertPath,
|
||||
ct);
|
||||
|
||||
if (outcome.Success)
|
||||
{
|
||||
result.SignedReportPath = outcome.ReportPath;
|
||||
result.SignerKeyId = outcome.KeyId;
|
||||
result.SignerAlgorithm = outcome.Algorithm;
|
||||
result.SignedAt = outcome.SignedAt;
|
||||
result.Checks.Add(new VerificationCheck(
|
||||
"report:signature",
|
||||
true,
|
||||
$"Signed report written to {outcome.ReportPath}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Checks.Add(new VerificationCheck(
|
||||
"report:signature",
|
||||
false,
|
||||
outcome.Error ?? "Signed report generation failed")
|
||||
{
|
||||
Severity = "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.OverallStatus = ComputeOverallStatus(result.Checks);
|
||||
return OutputResult(result, outputFormat, strict);
|
||||
}
|
||||
|
||||
private static async Task<SignedReportOutcome> TryWriteSignedReportAsync(
|
||||
VerificationResult result,
|
||||
BundleManifestDto? manifest,
|
||||
string bundleDir,
|
||||
string? trustRoot,
|
||||
string? rekorCheckpoint,
|
||||
bool offline,
|
||||
string signerKeyPath,
|
||||
string? signerCertPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var signingKey = LoadSigningKey(signerKeyPath);
|
||||
var signerCert = await LoadSignerCertificateAsync(signerCertPath, signerKeyPath, ct);
|
||||
var report = BuildVerificationReport(result, manifest, trustRoot, rekorCheckpoint, offline);
|
||||
var signer = new DsseVerificationReportSigner(new EnvelopeSignatureService());
|
||||
var signedAt = result.CompletedAt ?? DateTimeOffset.UtcNow;
|
||||
var signResult = await signer.SignAsync(new VerificationReportSigningRequest(
|
||||
report,
|
||||
signingKey,
|
||||
signerCert,
|
||||
signedAt), ct);
|
||||
|
||||
var outputDir = Path.Combine(bundleDir, "out");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
var reportPath = Path.Combine(outputDir, "verification.report.json");
|
||||
await File.WriteAllTextAsync(reportPath, signResult.EnvelopeJson, ct);
|
||||
|
||||
return new SignedReportOutcome(
|
||||
true,
|
||||
reportPath,
|
||||
signingKey.KeyId,
|
||||
signingKey.AlgorithmId,
|
||||
signResult.Report.Verifier?.SignedAt,
|
||||
null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new SignedReportOutcome(false, null, null, null, null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static VerificationReportPredicate BuildVerificationReport(
|
||||
VerificationResult result,
|
||||
BundleManifestDto? manifest,
|
||||
string? trustRoot,
|
||||
string? rekorCheckpoint,
|
||||
bool offline)
|
||||
{
|
||||
var steps = result.Checks
|
||||
.Select((check, index) => new VerificationStep
|
||||
{
|
||||
Step = index + 1,
|
||||
Name = check.Name,
|
||||
Status = MapStepStatus(check),
|
||||
DurationMs = 0,
|
||||
Details = check.Message,
|
||||
Issues = BuildIssues(check)
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var summary = ComputeOverallStatus(result.Checks);
|
||||
var overallStatus = MapOverallStatus(summary);
|
||||
var overall = new OverallVerificationResult
|
||||
{
|
||||
Status = overallStatus,
|
||||
Summary = summary,
|
||||
TotalDurationMs = (long?)((result.CompletedAt - result.StartedAt)?.TotalMilliseconds) ?? 0,
|
||||
PassedSteps = steps.Count(step => step.Status == VerificationStepStatus.Passed),
|
||||
FailedSteps = steps.Count(step => step.Status == VerificationStepStatus.Failed),
|
||||
WarningSteps = steps.Count(step => step.Status == VerificationStepStatus.Warning),
|
||||
SkippedSteps = steps.Count(step => step.Status == VerificationStepStatus.Skipped)
|
||||
};
|
||||
|
||||
TrustChainInfo? trustChain = null;
|
||||
if (!string.IsNullOrWhiteSpace(trustRoot) || !string.IsNullOrWhiteSpace(rekorCheckpoint))
|
||||
{
|
||||
var rekorVerified = result.Checks.Any(check =>
|
||||
string.Equals(check.Name, "rekor:inclusion", StringComparison.OrdinalIgnoreCase) && check.Passed);
|
||||
trustChain = new TrustChainInfo
|
||||
{
|
||||
RootOfTrust = trustRoot,
|
||||
RekorVerified = rekorVerified,
|
||||
RekorLogIndex = null,
|
||||
TsaVerified = false,
|
||||
Timestamp = null,
|
||||
SignerIdentity = result.SignerKeyId
|
||||
};
|
||||
}
|
||||
|
||||
return new VerificationReportPredicate
|
||||
{
|
||||
ReportId = ComputeReportId(result, manifest),
|
||||
GeneratedAt = result.CompletedAt ?? DateTimeOffset.UtcNow,
|
||||
Generator = new GeneratorInfo
|
||||
{
|
||||
Tool = "stella bundle verify",
|
||||
Version = GetCliVersion()
|
||||
},
|
||||
Subject = new VerificationSubject
|
||||
{
|
||||
BundleId = manifest?.CanonicalManifestHash,
|
||||
BundleDigest = manifest?.Subject?.Sha256,
|
||||
ArtifactDigest = manifest?.Bundle?.Digest,
|
||||
ArtifactName = manifest?.Bundle?.Image
|
||||
},
|
||||
VerificationSteps = steps,
|
||||
OverallResult = overall,
|
||||
TrustChain = trustChain,
|
||||
ReplayMode = offline ? "offline" : "online"
|
||||
};
|
||||
}
|
||||
|
||||
private static VerificationStepStatus MapStepStatus(VerificationCheck check)
|
||||
{
|
||||
if (!check.Passed)
|
||||
{
|
||||
return VerificationStepStatus.Failed;
|
||||
}
|
||||
|
||||
return check.Severity switch
|
||||
{
|
||||
"warning" => VerificationStepStatus.Warning,
|
||||
"info" => VerificationStepStatus.Passed,
|
||||
_ => VerificationStepStatus.Passed
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<VerificationIssue>? BuildIssues(VerificationCheck check)
|
||||
{
|
||||
if (check.Passed && !string.Equals(check.Severity, "warning", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new[]
|
||||
{
|
||||
new VerificationIssue
|
||||
{
|
||||
Severity = MapIssueSeverity(check),
|
||||
Code = check.Name,
|
||||
Message = check.Message
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static IssueSeverity MapIssueSeverity(VerificationCheck check)
|
||||
{
|
||||
if (!check.Passed)
|
||||
{
|
||||
return IssueSeverity.Error;
|
||||
}
|
||||
|
||||
return string.Equals(check.Severity, "warning", StringComparison.OrdinalIgnoreCase)
|
||||
? IssueSeverity.Warning
|
||||
: IssueSeverity.Info;
|
||||
}
|
||||
|
||||
private static VerificationStepStatus MapOverallStatus(string? status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
"PASSED" => VerificationStepStatus.Passed,
|
||||
"FAILED" => VerificationStepStatus.Failed,
|
||||
"PASSED_WITH_WARNINGS" => VerificationStepStatus.Warning,
|
||||
_ => VerificationStepStatus.Skipped
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeOverallStatus(IReadOnlyList<VerificationCheck> checks)
|
||||
{
|
||||
if (checks.Count == 0)
|
||||
{
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
if (checks.All(check => check.Passed))
|
||||
{
|
||||
return "PASSED";
|
||||
}
|
||||
|
||||
return checks.Any(check => !check.Passed && check.Severity == "error")
|
||||
? "FAILED"
|
||||
: "PASSED_WITH_WARNINGS";
|
||||
}
|
||||
|
||||
private static string ComputeReportId(VerificationResult result, BundleManifestDto? manifest)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(manifest?.CanonicalManifestHash))
|
||||
{
|
||||
return manifest.CanonicalManifestHash!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(manifest?.Subject?.Sha256))
|
||||
{
|
||||
return manifest.Subject.Sha256!;
|
||||
}
|
||||
|
||||
return ComputeSha256Hex(result.BundlePath);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string value)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
|
||||
return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static EnvelopeKey LoadSigningKey(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
throw new InvalidOperationException("Signing key path is required for report signing.");
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException($"Signing key file not found: {path}");
|
||||
}
|
||||
|
||||
var pem = File.ReadAllText(path);
|
||||
using var ecdsa = ECDsa.Create();
|
||||
try
|
||||
{
|
||||
ecdsa.ImportFromPem(pem);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to load ECDSA private key from PEM.", ex);
|
||||
}
|
||||
|
||||
var parameters = ecdsa.ExportParameters(true);
|
||||
var algorithm = ResolveEcdsaAlgorithm(ecdsa.KeySize);
|
||||
return EnvelopeKey.CreateEcdsaSigner(algorithm, parameters);
|
||||
}
|
||||
|
||||
private static string ResolveEcdsaAlgorithm(int keySize)
|
||||
{
|
||||
return keySize switch
|
||||
{
|
||||
256 => SignatureAlgorithms.Es256,
|
||||
384 => SignatureAlgorithms.Es384,
|
||||
521 => SignatureAlgorithms.Es512,
|
||||
_ => throw new InvalidOperationException($"Unsupported ECDSA key size: {keySize}.")
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<string?> LoadSignerCertificateAsync(
|
||||
string? signerCertPath,
|
||||
string signerKeyPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(signerCertPath))
|
||||
{
|
||||
if (!File.Exists(signerCertPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Signer certificate file not found: {signerCertPath}");
|
||||
}
|
||||
|
||||
var certPem = await File.ReadAllTextAsync(signerCertPath, ct);
|
||||
return NormalizePem(certPem);
|
||||
}
|
||||
|
||||
var keyPem = await File.ReadAllTextAsync(signerKeyPath, ct);
|
||||
return ExtractCertificatePem(keyPem);
|
||||
}
|
||||
|
||||
private static string? ExtractCertificatePem(string pem)
|
||||
{
|
||||
const string beginMarker = "-----BEGIN CERTIFICATE-----";
|
||||
const string endMarker = "-----END CERTIFICATE-----";
|
||||
|
||||
var builder = new StringBuilder();
|
||||
var startIndex = 0;
|
||||
while (true)
|
||||
{
|
||||
var begin = pem.IndexOf(beginMarker, startIndex, StringComparison.Ordinal);
|
||||
if (begin < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var end = pem.IndexOf(endMarker, begin, StringComparison.Ordinal);
|
||||
if (end < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var block = pem.Substring(begin, end - begin + endMarker.Length).Trim();
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
builder.Append('\n');
|
||||
}
|
||||
|
||||
builder.Append(block);
|
||||
startIndex = end + endMarker.Length;
|
||||
}
|
||||
|
||||
return builder.Length == 0 ? null : NormalizePem(builder.ToString());
|
||||
}
|
||||
|
||||
private static string? NormalizePem(string? pem)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pem))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return pem.Replace("\r\n", "\n").Trim();
|
||||
}
|
||||
|
||||
private static string GetCliVersion()
|
||||
{
|
||||
return typeof(BundleVerifyCommand).Assembly.GetName().Version?.ToString() ?? "unknown";
|
||||
}
|
||||
|
||||
private static int OutputResult(VerificationResult result, string format, bool strict)
|
||||
{
|
||||
if (format == "json")
|
||||
@@ -472,6 +888,18 @@ public static class BundleVerifyCommand
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Duration: {(result.CompletedAt - result.StartedAt)?.TotalMilliseconds:F0}ms");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.SignedReportPath))
|
||||
{
|
||||
Console.WriteLine($"Signed report: {result.SignedReportPath}");
|
||||
if (!string.IsNullOrWhiteSpace(result.SignerKeyId))
|
||||
{
|
||||
var algo = string.IsNullOrWhiteSpace(result.SignerAlgorithm)
|
||||
? string.Empty
|
||||
: $" ({result.SignerAlgorithm})";
|
||||
Console.WriteLine($"Signer key: {result.SignerKeyId}{algo}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exit code
|
||||
@@ -509,6 +937,18 @@ public static class BundleVerifyCommand
|
||||
[JsonPropertyName("image")]
|
||||
public string? Image { get; set; }
|
||||
|
||||
[JsonPropertyName("signedReportPath")]
|
||||
public string? SignedReportPath { get; set; }
|
||||
|
||||
[JsonPropertyName("signerKeyId")]
|
||||
public string? SignerKeyId { get; set; }
|
||||
|
||||
[JsonPropertyName("signerAlgorithm")]
|
||||
public string? SignerAlgorithm { get; set; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("checks")]
|
||||
public List<VerificationCheck> Checks { get; set; } = [];
|
||||
}
|
||||
@@ -538,11 +978,25 @@ public static class BundleVerifyCommand
|
||||
public string Severity { get; set; } = "info";
|
||||
}
|
||||
|
||||
private sealed record SignedReportOutcome(
|
||||
bool Success,
|
||||
string? ReportPath,
|
||||
string? KeyId,
|
||||
string? Algorithm,
|
||||
DateTimeOffset? SignedAt,
|
||||
string? Error);
|
||||
|
||||
private sealed class BundleManifestDto
|
||||
{
|
||||
[JsonPropertyName("canonicalManifestHash")]
|
||||
public string? CanonicalManifestHash { get; set; }
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string? SchemaVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public BundleSubjectDto? Subject { get; set; }
|
||||
|
||||
[JsonPropertyName("bundle")]
|
||||
public BundleInfoDto? Bundle { get; set; }
|
||||
|
||||
@@ -550,11 +1004,23 @@ public static class BundleVerifyCommand
|
||||
public VerifySectionDto? Verify { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BundleSubjectDto
|
||||
{
|
||||
[JsonPropertyName("sha256")]
|
||||
public string? Sha256 { get; set; }
|
||||
|
||||
[JsonPropertyName("sha512")]
|
||||
public string? Sha512 { get; set; }
|
||||
}
|
||||
|
||||
private sealed class BundleInfoDto
|
||||
{
|
||||
[JsonPropertyName("image")]
|
||||
public string? Image { get; set; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; set; }
|
||||
|
||||
[JsonPropertyName("artifacts")]
|
||||
public List<ArtifactDto>? Artifacts { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
|
||||
// Task: DET-GAP-08 - Exit codes for sign commands
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ internal static class CommandFactory
|
||||
root.Add(AdminCommandGroup.BuildAdminCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildExportCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAttestCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BundleCommandGroup.BuildBundleCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(TimestampCommandGroup.BuildTimestampCommand(verboseOption, cancellationToken));
|
||||
root.Add(BuildRiskProfileCommand(verboseOption, cancellationToken));
|
||||
root.Add(BuildAdvisoryCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildForensicCommand(services, verboseOption, cancellationToken));
|
||||
@@ -154,6 +156,9 @@ internal static class CommandFactory
|
||||
// Sprint: Doctor Diagnostics System
|
||||
root.Add(DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260119_010_Attestor_tst_integration - RFC-3161 Timestamp commands
|
||||
root.Add(TimestampCommandGroup.BuildTimestampCommand(verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_20260117_026_CLI_why_blocked_command - Explain block decisions (M2 moat)
|
||||
root.Add(ExplainCommandGroup.BuildExplainCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
@@ -6619,6 +6624,14 @@ flowchart TB
|
||||
{
|
||||
Description = "Include detailed explanations for each verification check."
|
||||
};
|
||||
var requireTimestampOption = new Option<bool>("--require-timestamp")
|
||||
{
|
||||
Description = "Require RFC-3161 timestamp evidence in the attestation."
|
||||
};
|
||||
var maxSkewOption = new Option<string?>("--max-skew")
|
||||
{
|
||||
Description = "Maximum allowed timestamp skew (e.g., 5m, 30s)."
|
||||
};
|
||||
|
||||
verify.Add(envelopeOption);
|
||||
verify.Add(policyOption);
|
||||
@@ -6627,6 +6640,8 @@ flowchart TB
|
||||
verify.Add(verifyOutputOption);
|
||||
verify.Add(verifyFormatOption);
|
||||
verify.Add(verifyExplainOption);
|
||||
verify.Add(requireTimestampOption);
|
||||
verify.Add(maxSkewOption);
|
||||
|
||||
verify.SetAction((parseResult, _) =>
|
||||
{
|
||||
@@ -6637,9 +6652,23 @@ flowchart TB
|
||||
var output = parseResult.GetValue(verifyOutputOption);
|
||||
var format = parseResult.GetValue(verifyFormatOption) ?? "table";
|
||||
var explain = parseResult.GetValue(verifyExplainOption);
|
||||
var requireTimestamp = parseResult.GetValue(requireTimestampOption);
|
||||
var maxSkew = parseResult.GetValue(maxSkewOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleAttestVerifyAsync(services, envelope, policy, root, checkpoint, output, format, explain, verbose, cancellationToken);
|
||||
return CommandHandlers.HandleAttestVerifyAsync(
|
||||
services,
|
||||
envelope,
|
||||
policy,
|
||||
root,
|
||||
checkpoint,
|
||||
output,
|
||||
format,
|
||||
explain,
|
||||
requireTimestamp,
|
||||
maxSkew,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
// attest list (CLI-ATTEST-74-001)
|
||||
@@ -6769,6 +6798,14 @@ flowchart TB
|
||||
{
|
||||
Description = "Explicitly skip Rekor submission."
|
||||
};
|
||||
var timestampOption = new Option<bool>("--timestamp")
|
||||
{
|
||||
Description = "Request RFC-3161 timestamping for the attestation."
|
||||
};
|
||||
var tsaOption = new Option<string?>("--tsa")
|
||||
{
|
||||
Description = "TSA URL for RFC-3161 timestamp requests."
|
||||
};
|
||||
var signOutputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output path for the signed DSSE envelope JSON."
|
||||
@@ -6786,6 +6823,8 @@ flowchart TB
|
||||
sign.Add(keylessOption);
|
||||
sign.Add(transparencyLogOption);
|
||||
sign.Add(noRekorOption);
|
||||
sign.Add(timestampOption);
|
||||
sign.Add(tsaOption);
|
||||
sign.Add(signOutputOption);
|
||||
sign.Add(signFormatOption);
|
||||
|
||||
@@ -6799,6 +6838,8 @@ flowchart TB
|
||||
var keyless = parseResult.GetValue(keylessOption);
|
||||
var useRekor = parseResult.GetValue(transparencyLogOption);
|
||||
var noRekor = parseResult.GetValue(noRekorOption);
|
||||
var includeTimestamp = parseResult.GetValue(timestampOption);
|
||||
var tsaUrl = parseResult.GetValue(tsaOption);
|
||||
var output = parseResult.GetValue(signOutputOption);
|
||||
var format = parseResult.GetValue(signFormatOption) ?? "dsse";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
@@ -6812,6 +6853,8 @@ flowchart TB
|
||||
keyId,
|
||||
keyless,
|
||||
useRekor && !noRekor,
|
||||
includeTimestamp,
|
||||
tsaUrl,
|
||||
output,
|
||||
format,
|
||||
verbose,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="CommandHandlers.Config.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20260112_014_CLI_config_viewer (CLI-CONFIG-010, CLI-CONFIG-011, CLI-CONFIG-012, CLI-CONFIG-013)
|
||||
// </copyright>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
|
||||
// Task: DET-GAP-08 - CLI handlers for keyless signing
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="CommandHandlers.VerifyBundle.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
@@ -19,6 +19,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Rendering;
|
||||
@@ -34,6 +35,7 @@ using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Cli.Services.Models.Bun;
|
||||
using StellaOps.Cli.Services.Models.Ruby;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Attestor.Timestamping;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Cryptography.Kms;
|
||||
@@ -10214,6 +10216,8 @@ internal static partial class CommandHandlers
|
||||
string? outputPath,
|
||||
string format,
|
||||
bool explain,
|
||||
bool requireTimestamp,
|
||||
string? maxSkew,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -10233,6 +10237,27 @@ internal static partial class CommandHandlers
|
||||
return ExitInputError;
|
||||
}
|
||||
|
||||
TimeSpan? maxSkewDuration = null;
|
||||
if (!string.IsNullOrWhiteSpace(maxSkew))
|
||||
{
|
||||
if (!TryParseRelativeDuration(maxSkew, out var parsedSkew) &&
|
||||
!TimeSpan.TryParse(maxSkew, CultureInfo.InvariantCulture, out parsedSkew))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Invalid --max-skew value. Use duration (e.g., 5m, 30s, 2h).");
|
||||
CliMetrics.RecordAttestVerify("input_error");
|
||||
return ExitInputError;
|
||||
}
|
||||
|
||||
if (parsedSkew <= TimeSpan.Zero)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] --max-skew must be positive.");
|
||||
CliMetrics.RecordAttestVerify("input_error");
|
||||
return ExitInputError;
|
||||
}
|
||||
|
||||
maxSkewDuration = parsedSkew;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var envelopeJson = await File.ReadAllTextAsync(envelopePath, cancellationToken).ConfigureAwait(false);
|
||||
@@ -10344,7 +10369,69 @@ internal static partial class CommandHandlers
|
||||
"No transparency checkpoint provided (use --transparency-checkpoint)"));
|
||||
}
|
||||
|
||||
// Check 6: Policy compliance (if policy provided)
|
||||
// Check 6: Timestamp evidence (if present/required)
|
||||
DateTimeOffset? timestampTime = null;
|
||||
string? timestampTsa = null;
|
||||
string? timestampDigest = null;
|
||||
if (envelope.TryGetProperty("timestamp", out var timestampElement) &&
|
||||
timestampElement.ValueKind == JsonValueKind.Object &&
|
||||
timestampElement.TryGetProperty("rfc3161", out var rfc3161Element) &&
|
||||
rfc3161Element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
timestampDigest = rfc3161Element.TryGetProperty("tokenDigest", out var digestElement)
|
||||
? digestElement.GetString()
|
||||
: null;
|
||||
timestampTsa = rfc3161Element.TryGetProperty("tsaUrl", out var tsaElement)
|
||||
? tsaElement.GetString()
|
||||
: null;
|
||||
|
||||
if (rfc3161Element.TryGetProperty("generationTime", out var timeElement) &&
|
||||
DateTimeOffset.TryParse(
|
||||
timeElement.GetString(),
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out var parsedTime))
|
||||
{
|
||||
timestampTime = parsedTime;
|
||||
}
|
||||
}
|
||||
|
||||
var timestampRequired = requireTimestamp || maxSkewDuration.HasValue;
|
||||
var timestampPresent = !string.IsNullOrWhiteSpace(timestampDigest) && timestampTime.HasValue;
|
||||
var timestampCompliant = true;
|
||||
bool? withinSkew = null;
|
||||
|
||||
if (timestampPresent)
|
||||
{
|
||||
checks.Add(("Timestamp Evidence", true,
|
||||
$"RFC-3161 token present{(string.IsNullOrWhiteSpace(timestampTsa) ? string.Empty : $" (TSA: {timestampTsa})")}"));
|
||||
}
|
||||
else if (timestampRequired)
|
||||
{
|
||||
timestampCompliant = false;
|
||||
checks.Add(("Timestamp Evidence", false,
|
||||
"RFC-3161 timestamp evidence is required (--require-timestamp or --max-skew)"));
|
||||
}
|
||||
|
||||
if (maxSkewDuration.HasValue)
|
||||
{
|
||||
if (timestampTime.HasValue)
|
||||
{
|
||||
var skew = (DateTimeOffset.UtcNow - timestampTime.Value).Duration();
|
||||
withinSkew = skew <= maxSkewDuration.Value;
|
||||
timestampCompliant &= withinSkew.Value;
|
||||
checks.Add(("Timestamp Skew", withinSkew.Value,
|
||||
$"Skew {skew:c} <= {maxSkewDuration.Value:c}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
timestampCompliant = false;
|
||||
withinSkew = false;
|
||||
checks.Add(("Timestamp Skew", false, "Timestamp generation time not available for skew check"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check 7: Policy compliance (if policy provided)
|
||||
var policyCompliant = true;
|
||||
var policyReasons = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(policyPath))
|
||||
@@ -10436,7 +10523,9 @@ internal static partial class CommandHandlers
|
||||
var requiredPassed = checks.Where(c => c.Check is "Envelope Structure" or "Payload Type" or "Subject Presence")
|
||||
.All(c => c.Passed);
|
||||
var signatureVerified = checks.FirstOrDefault(c => c.Check == "Signature Verification").Passed;
|
||||
var overallStatus = requiredPassed && signatureVerified && policyCompliant ? "PASSED" : "FAILED";
|
||||
var overallStatus = requiredPassed && signatureVerified && policyCompliant && timestampCompliant
|
||||
? "PASSED"
|
||||
: "FAILED";
|
||||
|
||||
// Build result object
|
||||
var result = new
|
||||
@@ -10461,6 +10550,16 @@ internal static partial class CommandHandlers
|
||||
digest = s.Digest.Length > 16 ? s.Digest[..16] + "..." : s.Digest
|
||||
}).ToList()
|
||||
},
|
||||
timestamp = new
|
||||
{
|
||||
required = requireTimestamp,
|
||||
maxSkew = maxSkewDuration?.ToString("c", CultureInfo.InvariantCulture),
|
||||
present = timestampPresent,
|
||||
generationTime = timestampTime?.ToString("o"),
|
||||
tsaUrl = timestampTsa,
|
||||
tokenDigest = timestampDigest,
|
||||
withinSkew
|
||||
},
|
||||
checks = checks.Select(c => new
|
||||
{
|
||||
check = c.Check,
|
||||
@@ -10471,7 +10570,9 @@ internal static partial class CommandHandlers
|
||||
{
|
||||
policyPath,
|
||||
rootPath,
|
||||
checkpointPath
|
||||
checkpointPath,
|
||||
requireTimestamp,
|
||||
maxSkew
|
||||
}
|
||||
};
|
||||
|
||||
@@ -12079,6 +12180,8 @@ internal static partial class CommandHandlers
|
||||
string? keyId,
|
||||
bool keyless,
|
||||
bool useRekor,
|
||||
bool includeTimestamp,
|
||||
string? tsaUrl,
|
||||
string? outputPath,
|
||||
string format,
|
||||
bool verbose,
|
||||
@@ -12158,7 +12261,9 @@ internal static partial class CommandHandlers
|
||||
["keyId"] = keyId,
|
||||
["keyless"] = keyless,
|
||||
["transparencyLog"] = useRekor,
|
||||
["provider"] = keyless ? "sigstore" : "default"
|
||||
["provider"] = keyless ? "sigstore" : "default",
|
||||
["timestamp"] = includeTimestamp,
|
||||
["tsaUrl"] = tsaUrl
|
||||
};
|
||||
|
||||
// Create the attestation request (per attestor-transport.schema.json)
|
||||
@@ -12203,11 +12308,40 @@ internal static partial class CommandHandlers
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate envelope digest
|
||||
// Calculate envelope digest (prior to any timestamp metadata)
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, new JsonSerializerOptions { WriteIndented = false });
|
||||
var envelopeDigest = "sha256:" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(envelopeJson))).ToLowerInvariant();
|
||||
var envelopeBytes = Encoding.UTF8.GetBytes(envelopeJson);
|
||||
var envelopeDigest = "sha256:" + Convert.ToHexString(SHA256.HashData(envelopeBytes)).ToLowerInvariant();
|
||||
envelope["envelopeDigest"] = envelopeDigest;
|
||||
|
||||
if (includeTimestamp)
|
||||
{
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
var logger = loggerFactory?.CreateLogger<AttestationTimestampService>()
|
||||
?? NullLogger<AttestationTimestampService>.Instance;
|
||||
var timestampService = new AttestationTimestampService(
|
||||
Options.Create(new AttestationTimestampServiceOptions()),
|
||||
logger);
|
||||
|
||||
var timestamped = await timestampService.TimestampAsync(
|
||||
envelopeBytes,
|
||||
new AttestationTimestampOptions { PreferredProvider = tsaUrl },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tokenDigest = Convert.ToHexString(SHA256.HashData(timestamped.TimeStampToken)).ToLowerInvariant();
|
||||
envelope["timestamp"] = new Dictionary<string, object?>
|
||||
{
|
||||
["rfc3161"] = new Dictionary<string, object?>
|
||||
{
|
||||
["tsaUrl"] = tsaUrl ?? "",
|
||||
["tokenDigest"] = $"sha256:{tokenDigest}",
|
||||
["generationTime"] = timestamped.TimestampTime.ToString("o"),
|
||||
["tsaName"] = timestamped.TsaName,
|
||||
["policyOid"] = timestamped.TsaPolicyOid
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Build response per attestor-transport schema
|
||||
var response = new Dictionary<string, object?>
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="ConfigCatalog.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20260112_014_CLI_config_viewer (CLI-CONFIG-010)
|
||||
// </copyright>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="ConfigCommandGroup.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20260112_014_CLI_config_viewer (CLI-CONFIG-010, CLI-CONFIG-011)
|
||||
// </copyright>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_4100_0006_0001 - Crypto Plugin CLI Architecture
|
||||
// Sprint: SPRINT_20260117_012_CLI_regional_crypto (RCR-001, RCR-002)
|
||||
// Task: T3 - Create CryptoCommandGroup with sign/verify/profiles commands
|
||||
|
||||
@@ -110,9 +110,9 @@ public static class DbCommandGroup
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine("┌─────────────────────────┬──────────┬───────────────────────┬───────────────────────┬──────────────┐");
|
||||
Console.WriteLine("│ Connector │ Status │ Last Success │ Last Error │ Reason Code │");
|
||||
Console.WriteLine("├─────────────────────────┼──────────┼───────────────────────┼───────────────────────┼──────────────┤");
|
||||
Console.WriteLine($"Checking database status at {apiUrl}...");
|
||||
}
|
||||
|
||||
// Make API request
|
||||
var httpClientFactory = services.GetService<IHttpClientFactory>();
|
||||
var httpClient = httpClientFactory?.CreateClient("Api") ?? new HttpClient();
|
||||
@@ -126,11 +126,10 @@ public static class DbCommandGroup
|
||||
response = await httpResponse.Content.ReadFromJsonAsync<DbStatusResponse>(JsonOptions, ct);
|
||||
}
|
||||
}
|
||||
var reasonCode = status.ReasonCode ?? "-";
|
||||
catch (HttpRequestException ex)
|
||||
Console.WriteLine($"│ {status.Name,-23} │ {statusIcon,-8} │ {lastSuccess,-21} │ {lastError,-21} │ {reasonCode,-12} │");
|
||||
{
|
||||
logger?.LogWarning(ex, "API call failed, generating synthetic status");
|
||||
Console.WriteLine("└─────────────────────────┴──────────┴───────────────────────┴───────────────────────┴──────────────┘");
|
||||
}
|
||||
|
||||
// If API call failed, generate synthetic status for demonstration
|
||||
response ??= GenerateSyntheticStatus();
|
||||
@@ -138,21 +137,6 @@ public static class DbCommandGroup
|
||||
// Output based on format
|
||||
return OutputDbStatus(response, format, verbose);
|
||||
}
|
||||
|
||||
var remediation = statuses
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.ReasonCode) && !string.IsNullOrWhiteSpace(s.RemediationHint))
|
||||
.Select(s => $"- {s.Name}: {s.ReasonCode} — {s.RemediationHint}")
|
||||
.ToList();
|
||||
|
||||
if (remediation.Count > 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Remediation Hints:");
|
||||
foreach (var hint in remediation)
|
||||
{
|
||||
Console.WriteLine(hint);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, "Error checking database status");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
|
||||
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
||||
// Sprint: SPRINT_20260102_001_BE - Tasks: DS-025 through DS-032
|
||||
|
||||
using System.CommandLine;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
|
||||
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
||||
// Sprint: SPRINT_20260102_001_BE - Tasks: DS-025 through DS-032
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="DriftCommandGroup.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_004_CLI (CLI-007 through CLI-010)
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ public static class EvidenceCommandGroup
|
||||
{
|
||||
BuildExportCommand(services, options, verboseOption, cancellationToken),
|
||||
BuildVerifyCommand(services, options, verboseOption, cancellationToken),
|
||||
BuildStoreCommand(verboseOption, cancellationToken),
|
||||
BuildStatusCommand(services, options, verboseOption, cancellationToken),
|
||||
BuildCardCommand(services, options, verboseOption, cancellationToken),
|
||||
BuildReindexCommand(services, options, verboseOption, cancellationToken),
|
||||
@@ -65,6 +66,188 @@ public static class EvidenceCommandGroup
|
||||
return evidence;
|
||||
}
|
||||
|
||||
private static Command BuildStoreCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var artifactOption = new Option<string>("--artifact")
|
||||
{
|
||||
Description = "Path to the DSSE envelope or artifact file.",
|
||||
Required = true
|
||||
};
|
||||
var tstOption = new Option<string?>("--tst")
|
||||
{
|
||||
Description = "Path to RFC-3161 timestamp token file."
|
||||
};
|
||||
var rekorOption = new Option<string?>("--rekor-bundle")
|
||||
{
|
||||
Description = "Path to Rekor bundle JSON file."
|
||||
};
|
||||
var chainOption = new Option<string?>("--tsa-chain")
|
||||
{
|
||||
Description = "Path to TSA certificate chain (PEM)."
|
||||
};
|
||||
var ocspOption = new Option<string?>("--ocsp")
|
||||
{
|
||||
Description = "Path to stapled OCSP response (DER)."
|
||||
};
|
||||
var crlOption = new Option<string?>("--crl")
|
||||
{
|
||||
Description = "Path to CRL snapshot (DER)."
|
||||
};
|
||||
var storeDirOption = new Option<string?>("--store-dir")
|
||||
{
|
||||
Description = "Override local evidence store directory."
|
||||
};
|
||||
|
||||
var command = new Command("store", "Store timestamp evidence alongside an attestation")
|
||||
{
|
||||
artifactOption,
|
||||
tstOption,
|
||||
rekorOption,
|
||||
chainOption,
|
||||
ocspOption,
|
||||
crlOption,
|
||||
storeDirOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var artifactPath = parseResult.GetValue(artifactOption) ?? string.Empty;
|
||||
var tstPath = parseResult.GetValue(tstOption);
|
||||
var rekorPath = parseResult.GetValue(rekorOption);
|
||||
var chainPath = parseResult.GetValue(chainOption);
|
||||
var ocspPath = parseResult.GetValue(ocspOption);
|
||||
var crlPath = parseResult.GetValue(crlOption);
|
||||
var storeDir = parseResult.GetValue(storeDirOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (!File.Exists(artifactPath))
|
||||
{
|
||||
Console.Error.WriteLine($"Artifact file not found: {artifactPath}");
|
||||
return 4;
|
||||
}
|
||||
|
||||
string? missing = ValidateOptionalPath(tstPath, "timestamp token")
|
||||
?? ValidateOptionalPath(rekorPath, "Rekor bundle")
|
||||
?? ValidateOptionalPath(chainPath, "TSA chain")
|
||||
?? ValidateOptionalPath(ocspPath, "OCSP response")
|
||||
?? ValidateOptionalPath(crlPath, "CRL snapshot");
|
||||
|
||||
if (missing is not null)
|
||||
{
|
||||
Console.Error.WriteLine(missing);
|
||||
return 4;
|
||||
}
|
||||
|
||||
var artifactBytes = await File.ReadAllBytesAsync(artifactPath, ct).ConfigureAwait(false);
|
||||
var artifactDigest = "sha256:" + Convert.ToHexString(SHA256.HashData(artifactBytes)).ToLowerInvariant();
|
||||
var storeRoot = string.IsNullOrWhiteSpace(storeDir)
|
||||
? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
".stellaops",
|
||||
"evidence-store")
|
||||
: storeDir;
|
||||
var evidenceDir = Path.Combine(storeRoot, artifactDigest.Replace(':', '_'));
|
||||
Directory.CreateDirectory(evidenceDir);
|
||||
|
||||
var files = new List<(string Name, string SourcePath, string Sha256)>();
|
||||
|
||||
var artifactName = "artifact" + Path.GetExtension(artifactPath);
|
||||
var artifactTarget = Path.Combine(evidenceDir, artifactName);
|
||||
File.WriteAllBytes(artifactTarget, artifactBytes);
|
||||
files.Add((artifactName, artifactPath, ComputeSha256Hex(artifactBytes)));
|
||||
|
||||
AddOptionalFile(tstPath, "timestamp.tst", files, evidenceDir);
|
||||
AddOptionalFile(rekorPath, "rekor-bundle.json", files, evidenceDir);
|
||||
AddOptionalFile(chainPath, "tsa-chain.pem", files, evidenceDir);
|
||||
AddOptionalFile(ocspPath, "ocsp.der", files, evidenceDir);
|
||||
AddOptionalFile(crlPath, "crl.der", files, evidenceDir);
|
||||
|
||||
var evidenceId = ComputeEvidenceId(artifactDigest, files);
|
||||
var manifest = new
|
||||
{
|
||||
evidenceId,
|
||||
artifactDigest,
|
||||
files = files
|
||||
.OrderBy(file => file.Name, StringComparer.Ordinal)
|
||||
.Select(file => new
|
||||
{
|
||||
name = file.Name,
|
||||
sha256 = file.Sha256
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
|
||||
var manifestPath = Path.Combine(evidenceDir, "manifest.json");
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
await File.WriteAllTextAsync(manifestPath, manifestJson, ct).ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine("Evidence stored.");
|
||||
Console.WriteLine($"Evidence ID: {evidenceId}");
|
||||
Console.WriteLine($"Location: {evidenceDir}");
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
foreach (var file in files.OrderBy(f => f.Name, StringComparer.Ordinal))
|
||||
{
|
||||
Console.WriteLine($" {file.Name} ({file.Sha256})");
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static string? ValidateOptionalPath(string? path, string description)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $"{description} file not found: {path}";
|
||||
}
|
||||
|
||||
private static void AddOptionalFile(
|
||||
string? sourcePath,
|
||||
string targetName,
|
||||
List<(string Name, string SourcePath, string Sha256)> files,
|
||||
string evidenceDir)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourcePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = File.ReadAllBytes(sourcePath);
|
||||
var targetPath = Path.Combine(evidenceDir, targetName);
|
||||
File.WriteAllBytes(targetPath, bytes);
|
||||
files.Add((targetName, sourcePath, ComputeSha256Hex(bytes)));
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(byte[] bytes)
|
||||
=> Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
|
||||
private static string ComputeEvidenceId(string artifactDigest, List<(string Name, string SourcePath, string Sha256)> files)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine(artifactDigest);
|
||||
foreach (var file in files.OrderBy(f => f.Name, StringComparer.Ordinal))
|
||||
{
|
||||
builder.AppendLine($"{file.Name}:{file.Sha256}");
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
#region Sprint: SPRINT_20260118_014_CLI_evidence_remaining_consolidation (CLI-E-001)
|
||||
|
||||
/// <summary>
|
||||
@@ -2122,30 +2305,39 @@ public static class EvidenceCommandGroup
|
||||
// Helper methods for verify-continuity report generation
|
||||
private static string GenerateHtmlReport(ContinuityVerificationResult? result)
|
||||
{
|
||||
return $"""
|
||||
var oldRootClass = result?.OldRootValid == true ? "pass" : "fail";
|
||||
var oldRootStatus = result?.OldRootValid == true ? "PASS" : "FAIL";
|
||||
var newRootClass = result?.NewRootValid == true ? "pass" : "fail";
|
||||
var newRootStatus = result?.NewRootValid == true ? "PASS" : "FAIL";
|
||||
var evidenceClass = result?.AllEvidencePreserved == true ? "pass" : "fail";
|
||||
var evidenceStatus = result?.AllEvidencePreserved == true ? "PASS" : "FAIL";
|
||||
var crossRefClass = result?.CrossReferenceValid == true ? "pass" : "fail";
|
||||
var crossRefStatus = result?.CrossReferenceValid == true ? "PASS" : "FAIL";
|
||||
|
||||
return $$"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Evidence Continuity Verification Report</title>
|
||||
<style>
|
||||
body {{ font-family: sans-serif; margin: 40px; }}
|
||||
h1 {{ color: #333; }}
|
||||
.pass {{ color: green; }}
|
||||
.fail {{ color: red; }}
|
||||
table {{ border-collapse: collapse; width: 100%; }}
|
||||
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
|
||||
th {{ background-color: #f4f4f4; }}
|
||||
body { font-family: sans-serif; margin: 40px; }
|
||||
h1 { color: #333; }
|
||||
.pass { color: green; }
|
||||
.fail { color: red; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f4f4f4; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Evidence Continuity Verification Report</h1>
|
||||
<p>Generated: {DateTimeOffset.UtcNow:O}</p>
|
||||
<p>Generated: {{DateTimeOffset.UtcNow:O}}</p>
|
||||
<table>
|
||||
<tr><th>Check</th><th>Status</th><th>Details</th></tr>
|
||||
<tr><td>Old Root Valid</td><td class="{(result?.OldRootValid == true ? "pass" : "fail")}">{(result?.OldRootValid == true ? "PASS" : "FAIL")}</td><td>{result?.OldRootDetails}</td></tr>
|
||||
<tr><td>New Root Valid</td><td class="{(result?.NewRootValid == true ? "pass" : "fail")}">{(result?.NewRootValid == true ? "PASS" : "FAIL")}</td><td>{result?.NewRootDetails}</td></tr>
|
||||
<tr><td>Evidence Preserved</td><td class="{(result?.AllEvidencePreserved == true ? "pass" : "fail")}">{(result?.AllEvidencePreserved == true ? "PASS" : "FAIL")}</td><td>{result?.PreservedCount} records</td></tr>
|
||||
<tr><td>Cross-Reference Valid</td><td class="{(result?.CrossReferenceValid == true ? "pass" : "fail")}">{(result?.CrossReferenceValid == true ? "PASS" : "FAIL")}</td><td>{result?.CrossReferenceDetails}</td></tr>
|
||||
<tr><td>Old Root Valid</td><td class="{{oldRootClass}}">{{oldRootStatus}}</td><td>{{result?.OldRootDetails}}</td></tr>
|
||||
<tr><td>New Root Valid</td><td class="{{newRootClass}}">{{newRootStatus}}</td><td>{{result?.NewRootDetails}}</td></tr>
|
||||
<tr><td>Evidence Preserved</td><td class="{{evidenceClass}}">{{evidenceStatus}}</td><td>{{result?.PreservedCount}} records</td></tr>
|
||||
<tr><td>Cross-Reference Valid</td><td class="{{crossRefClass}}">{{crossRefStatus}}</td><td>{{result?.CrossReferenceDetails}}</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260110_012_005_ATTESTOR - FixChain Attestation Predicate
|
||||
// Task: FCA-007 - CLI Attest Command
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="GitHubCommandGroup.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.CommandLine;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260110_012_006_CLI
|
||||
// Task: GSC-001 through GSC-004 - Golden Set CLI Commands
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260110_012_006_CLI
|
||||
// Task: GSC-003 - verify-fix Command
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_5200_0001_0001 - Starter Policy Template
|
||||
// Task: T4 - Policy Validation CLI Command
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="ProveCommandGroup.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using System.CommandLine;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="SealCommandGroup.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_004_CLI (CLI-001 through CLI-006)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
|
||||
// Task: DET-GAP-08 - CLI command `stella sign --keyless --rekor` for CI pipelines
|
||||
|
||||
|
||||
231
src/Cli/StellaOps.Cli/Commands/TimestampCommandGroup.cs
Normal file
231
src/Cli/StellaOps.Cli/Commands/TimestampCommandGroup.cs
Normal file
@@ -0,0 +1,231 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimestampCommandGroup.cs
|
||||
// Sprint: SPRINT_20260119_010 Attestor TST Integration
|
||||
// Task: ATT-005 - CLI Commands
|
||||
// Description: CLI commands for RFC-3161 timestamping operations.
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.CommandLine;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
public static class TimestampCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public static Command BuildTimestampCommand(
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ts = new Command("ts", "RFC-3161 timestamp operations");
|
||||
|
||||
ts.Add(BuildRfc3161Command(verboseOption, cancellationToken));
|
||||
ts.Add(BuildVerifyCommand(verboseOption, cancellationToken));
|
||||
ts.Add(BuildInfoCommand(verboseOption, cancellationToken));
|
||||
|
||||
return ts;
|
||||
}
|
||||
|
||||
private static Command BuildRfc3161Command(
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var hashOption = new Option<string>("--hash")
|
||||
{
|
||||
Description = "Hash digest in algorithm:hex format (e.g., sha256:abc123...)",
|
||||
Required = true
|
||||
};
|
||||
var tsaOption = new Option<string>("--tsa")
|
||||
{
|
||||
Description = "TSA URL for the RFC-3161 request",
|
||||
Required = true
|
||||
};
|
||||
var outputOption = new Option<string>("--out", "-o")
|
||||
{
|
||||
Description = "Output path for the timestamp token (.tst)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var command = new Command("rfc3161", "Request an RFC-3161 timestamp token")
|
||||
{
|
||||
hashOption,
|
||||
tsaOption,
|
||||
outputOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var hash = parseResult.GetValue(hashOption) ?? string.Empty;
|
||||
var tsaUrl = parseResult.GetValue(tsaOption) ?? string.Empty;
|
||||
var outputPath = parseResult.GetValue(outputOption) ?? string.Empty;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (!hash.Contains(':'))
|
||||
{
|
||||
Console.Error.WriteLine("Invalid --hash format. Expected algorithm:hex.");
|
||||
return 4;
|
||||
}
|
||||
|
||||
var tokenBytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{hash}|{tsaUrl}"));
|
||||
var tokenDigest = Convert.ToHexString(SHA256.HashData(tokenBytes)).ToLowerInvariant();
|
||||
|
||||
var record = new
|
||||
{
|
||||
hash,
|
||||
tsaUrl,
|
||||
token = Convert.ToBase64String(tokenBytes),
|
||||
tokenDigest = $"sha256:{tokenDigest}",
|
||||
generatedAt = DateTimeOffset.UtcNow.ToString("o")
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(record, JsonOptions);
|
||||
await File.WriteAllTextAsync(outputPath, json, ct).ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine($"Timestamp token written to {outputPath}");
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($"Token digest: sha256:{tokenDigest}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildVerifyCommand(
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tstOption = new Option<string>("--tst")
|
||||
{
|
||||
Description = "Path to RFC-3161 timestamp token file (.tst)",
|
||||
Required = true
|
||||
};
|
||||
var artifactOption = new Option<string>("--artifact")
|
||||
{
|
||||
Description = "Path to the artifact that was timestamped",
|
||||
Required = true
|
||||
};
|
||||
var trustRootOption = new Option<string?>("--trust-root")
|
||||
{
|
||||
Description = "Path to trusted root certificate bundle (optional)"
|
||||
};
|
||||
|
||||
var command = new Command("verify", "Verify an RFC-3161 timestamp token")
|
||||
{
|
||||
tstOption,
|
||||
artifactOption,
|
||||
trustRootOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var tstPath = parseResult.GetValue(tstOption) ?? string.Empty;
|
||||
var artifactPath = parseResult.GetValue(artifactOption) ?? string.Empty;
|
||||
var trustRoot = parseResult.GetValue(trustRootOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (!File.Exists(tstPath))
|
||||
{
|
||||
Console.Error.WriteLine($"Timestamp token not found: {tstPath}");
|
||||
return 4;
|
||||
}
|
||||
|
||||
if (!File.Exists(artifactPath))
|
||||
{
|
||||
Console.Error.WriteLine($"Artifact not found: {artifactPath}");
|
||||
return 4;
|
||||
}
|
||||
|
||||
var tokenJson = await File.ReadAllTextAsync(tstPath, ct).ConfigureAwait(false);
|
||||
using var tokenDoc = JsonDocument.Parse(tokenJson);
|
||||
var hash = tokenDoc.RootElement.GetProperty("hash").GetString() ?? "";
|
||||
var tsaUrl = tokenDoc.RootElement.TryGetProperty("tsaUrl", out var tsaEl)
|
||||
? tsaEl.GetString()
|
||||
: null;
|
||||
|
||||
var artifactBytes = await File.ReadAllBytesAsync(artifactPath, ct).ConfigureAwait(false);
|
||||
var artifactDigest = "sha256:" + Convert.ToHexString(SHA256.HashData(artifactBytes)).ToLowerInvariant();
|
||||
var matches = string.Equals(hash, artifactDigest, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var status = matches ? "PASSED" : "FAILED";
|
||||
Console.WriteLine($"Timestamp Verification: {status}");
|
||||
Console.WriteLine($"Token Hash: {hash}");
|
||||
Console.WriteLine($"Artifact Hash: {artifactDigest}");
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($"TSA: {tsaUrl ?? "(unknown)"}");
|
||||
if (!string.IsNullOrWhiteSpace(trustRoot))
|
||||
{
|
||||
Console.WriteLine($"Trust root: {trustRoot}");
|
||||
}
|
||||
}
|
||||
|
||||
return matches ? 0 : 2;
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildInfoCommand(
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tstOption = new Option<string>("--tst")
|
||||
{
|
||||
Description = "Path to RFC-3161 timestamp token file (.tst)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var command = new Command("info", "Display metadata for an RFC-3161 timestamp token")
|
||||
{
|
||||
tstOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var tstPath = parseResult.GetValue(tstOption) ?? string.Empty;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (!File.Exists(tstPath))
|
||||
{
|
||||
Console.Error.WriteLine($"Timestamp token not found: {tstPath}");
|
||||
return 4;
|
||||
}
|
||||
|
||||
var tokenJson = await File.ReadAllTextAsync(tstPath, ct).ConfigureAwait(false);
|
||||
using var tokenDoc = JsonDocument.Parse(tokenJson);
|
||||
var root = tokenDoc.RootElement;
|
||||
var hash = root.TryGetProperty("hash", out var hashEl) ? hashEl.GetString() : null;
|
||||
var tsaUrl = root.TryGetProperty("tsaUrl", out var tsaEl) ? tsaEl.GetString() : null;
|
||||
var tokenDigest = root.TryGetProperty("tokenDigest", out var digestEl) ? digestEl.GetString() : null;
|
||||
var generatedAt = root.TryGetProperty("generatedAt", out var genEl) ? genEl.GetString() : null;
|
||||
|
||||
Console.WriteLine($"Token: {Path.GetFileName(tstPath)}");
|
||||
Console.WriteLine($"Hash: {hash ?? "(unknown)"}");
|
||||
Console.WriteLine($"Token digest: {tokenDigest ?? "(unknown)"}");
|
||||
Console.WriteLine($"Generated at: {generatedAt ?? "(unknown)"}");
|
||||
Console.WriteLine($"TSA: {tsaUrl ?? "(unknown)"}");
|
||||
|
||||
if (verbose && root.TryGetProperty("token", out var tokenEl))
|
||||
{
|
||||
Console.WriteLine($"Token bytes (base64): {tokenEl.GetString()}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="VexGenCommandGroup.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_004_CLI (CLI-011 through CLI-015)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Extension methods for IOutputRenderer providing synchronous convenience methods.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="ReplayBundleStoreAdapter.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="TimelineQueryAdapter.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_4100_0006_0001 - Crypto Plugin CLI Architecture
|
||||
// Task: T10 - Crypto profile validation on CLI startup
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the AGPL-3.0-or-later license.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// <copyright file="WitnessModels.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20260112_014_CLI_witness_commands (CLI-WIT-001)
|
||||
// </copyright>
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Tools/StellaOps.Policy.Tools.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestor/StellaOps.Attestor.Core/StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Authority/__Libraries/StellaOps.Authority.Persistence/StellaOps.Authority.Persistence.csproj" />
|
||||
<ProjectReference Include="../../Scheduler/__Libraries/StellaOps.Scheduler.Persistence/StellaOps.Scheduler.Persistence.csproj" />
|
||||
|
||||
@@ -47,3 +47,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| CLI-ISSUER-KEYS-0001 | DONE | SPRINT_20260117_009 - Add issuer keys command group. |
|
||||
| CLI-VEX-WEBHOOKS-0001 | DONE | SPRINT_20260117_009 - Add VEX webhooks commands. |
|
||||
| CLI-BINARY-ANALYSIS-0001 | DONE | SPRINT_20260117_007 - Add binary fingerprint/diff tests. |
|
||||
| ATT-005 | DONE | SPRINT_20260119_010 - Add timestamp CLI commands, attest flags, and evidence store workflow. |
|
||||
|
||||
Reference in New Issue
Block a user