tests fixes and some product advisories tunes ups
This commit is contained in:
@@ -95,6 +95,14 @@ public sealed record ReleaseEvidencePackManifest
|
||||
[JsonPropertyName("manifestHash")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ManifestHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the verification replay log for deterministic offline replay.
|
||||
/// Advisory: EU CRA/NIS2 compliance - Sealed Audit-Pack replay_log.json
|
||||
/// </summary>
|
||||
[JsonPropertyName("replayLogPath")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ReplayLogPath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
// Advisory: Sealed Audit-Pack replay_log.json format per EU CRA/NIS2 compliance
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.EvidencePack.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Verification replay log for deterministic offline proof replay.
|
||||
/// Captures step-by-step verification operations that auditors can replay
|
||||
/// to recompute canonical_digest → DSSE subject digest → signature verify → Rekor inclusion verify.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This format satisfies the EU CRA (Regulation 2024/2847) and NIS2 (Directive 2022/2555)
|
||||
/// requirements for verifiable supply-chain evidence in procurement scenarios.
|
||||
/// </remarks>
|
||||
public sealed record VerificationReplayLog
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version for the replay log format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schema_version")]
|
||||
public required string SchemaVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this replay log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("replay_id")]
|
||||
public required string ReplayId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the artifact being verified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifact_ref")]
|
||||
public required string ArtifactRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when verification was performed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verified_at")]
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the verifier tool used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verifier_version")]
|
||||
public required string VerifierVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall verification result.
|
||||
/// </summary>
|
||||
[JsonPropertyName("result")]
|
||||
public required string Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of verification steps for replay.
|
||||
/// </summary>
|
||||
[JsonPropertyName("steps")]
|
||||
public required ImmutableArray<VerificationReplayStep> Steps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Public keys used for verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verification_keys")]
|
||||
public required ImmutableArray<VerificationKeyRef> VerificationKeys { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekor")]
|
||||
public RekorVerificationInfo? Rekor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata for the replay log.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single verification step in the replay log.
|
||||
/// </summary>
|
||||
public sealed record VerificationReplayStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Step number (1-indexed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("step")]
|
||||
public required int Step { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action performed (e.g., "compute_canonical_sbom_digest", "verify_dsse_signature").
|
||||
/// </summary>
|
||||
[JsonPropertyName("action")]
|
||||
public required string Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the action for human readers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input file or value for this step.
|
||||
/// </summary>
|
||||
[JsonPropertyName("input")]
|
||||
public string? Input { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output/computed value from this step.
|
||||
/// </summary>
|
||||
[JsonPropertyName("output")]
|
||||
public string? Output { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected value (for comparison steps).
|
||||
/// </summary>
|
||||
[JsonPropertyName("expected")]
|
||||
public string? Expected { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actual computed value (for comparison steps).
|
||||
/// </summary>
|
||||
[JsonPropertyName("actual")]
|
||||
public string? Actual { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Result of this step: "pass", "fail", or "skip".
|
||||
/// </summary>
|
||||
[JsonPropertyName("result")]
|
||||
public required string Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of this step in milliseconds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("duration_ms")]
|
||||
public double? DurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the step failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used if this was a signature verification step.
|
||||
/// </summary>
|
||||
[JsonPropertyName("key_id")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Algorithm used (e.g., "sha256", "ecdsa-p256").
|
||||
/// </summary>
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string? Algorithm { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a verification key.
|
||||
/// </summary>
|
||||
public sealed record VerificationKeyRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("key_id")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key type (e.g., "cosign", "rekor", "fulcio").
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the public key file in the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 fingerprint of the public key.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fingerprint")]
|
||||
public string? Fingerprint { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log verification information.
|
||||
/// </summary>
|
||||
public sealed record RekorVerificationInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Rekor log ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("log_id")]
|
||||
public required string LogId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Log index of the entry.
|
||||
/// </summary>
|
||||
[JsonPropertyName("log_index")]
|
||||
public required long LogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tree size at time of inclusion.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tree_size")]
|
||||
public required long TreeSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Root hash of the Merkle tree.
|
||||
/// </summary>
|
||||
[JsonPropertyName("root_hash")]
|
||||
public required string RootHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the inclusion proof file.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inclusion_proof_path")]
|
||||
public string? InclusionProofPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the signed checkpoint file.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checkpoint_path")]
|
||||
public string? CheckpointPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Integrated time (Unix timestamp).
|
||||
/// </summary>
|
||||
[JsonPropertyName("integrated_time")]
|
||||
public long? IntegratedTime { get; init; }
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.EvidencePack.Models;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.EvidencePack.Models;
|
||||
using StellaOps.Attestor.EvidencePack.Services;
|
||||
|
||||
namespace StellaOps.Attestor.EvidencePack;
|
||||
|
||||
@@ -104,16 +105,11 @@ public sealed class ReleaseEvidencePackSerializer
|
||||
verifyMd,
|
||||
cancellationToken);
|
||||
|
||||
// Write verify.sh
|
||||
var verifyShContent = await LoadTemplateAsync("verify.sh.template");
|
||||
// Write verify.sh (template renamed to avoid MSBuild treating .sh as culture code)
|
||||
var verifyShContent = await LoadTemplateAsync("verify-unix.template");
|
||||
var verifyShPath = Path.Combine(bundleDir, "verify.sh");
|
||||
await File.WriteAllTextAsync(verifyShPath, verifyShContent, cancellationToken);
|
||||
#if !WINDOWS
|
||||
// Make executable on Unix
|
||||
File.SetUnixFileMode(verifyShPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
|
||||
#endif
|
||||
SetUnixExecutableIfSupported(verifyShPath);
|
||||
|
||||
// Write verify.ps1
|
||||
var verifyPs1Content = await LoadTemplateAsync("verify.ps1.template");
|
||||
@@ -125,6 +121,40 @@ public sealed class ReleaseEvidencePackSerializer
|
||||
_logger.LogInformation("Evidence pack written to: {Path}", bundleDir);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the evidence pack to a directory structure with optional replay log.
|
||||
/// Advisory: EU CRA/NIS2 compliance - includes replay_log.json for deterministic offline verification.
|
||||
/// </summary>
|
||||
public async Task SerializeToDirectoryAsync(
|
||||
ReleaseEvidencePackManifest manifest,
|
||||
string outputPath,
|
||||
string artifactsSourcePath,
|
||||
string publicKeyPath,
|
||||
string? rekorPublicKeyPath,
|
||||
VerificationReplayLog? replayLog,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Update manifest with replay log path if provided
|
||||
var manifestWithReplayLog = replayLog is not null
|
||||
? manifest with { ReplayLogPath = "replay_log.json" }
|
||||
: manifest;
|
||||
|
||||
await SerializeToDirectoryAsync(
|
||||
manifestWithReplayLog,
|
||||
outputPath,
|
||||
artifactsSourcePath,
|
||||
publicKeyPath,
|
||||
rekorPublicKeyPath,
|
||||
cancellationToken);
|
||||
|
||||
// Write replay_log.json if provided
|
||||
if (replayLog is not null)
|
||||
{
|
||||
var bundleDir = Path.Combine(outputPath, $"stella-release-{manifest.ReleaseVersion}-evidence-pack");
|
||||
await WriteReplayLogAsync(bundleDir, replayLog, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the evidence pack to a directory structure without copying artifacts.
|
||||
/// This overload is useful for testing and scenarios where artifacts are referenced but not bundled.
|
||||
@@ -172,16 +202,11 @@ public sealed class ReleaseEvidencePackSerializer
|
||||
verifyMd,
|
||||
cancellationToken);
|
||||
|
||||
// Write verify.sh
|
||||
var verifyShContent = await LoadTemplateAsync("verify.sh.template");
|
||||
// Write verify.sh (template renamed to avoid MSBuild treating .sh as culture code)
|
||||
var verifyShContent = await LoadTemplateAsync("verify-unix.template");
|
||||
var verifyShPath = Path.Combine(outputPath, "verify.sh");
|
||||
await File.WriteAllTextAsync(verifyShPath, verifyShContent, cancellationToken);
|
||||
#if !WINDOWS
|
||||
// Make executable on Unix
|
||||
File.SetUnixFileMode(verifyShPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
|
||||
#endif
|
||||
SetUnixExecutableIfSupported(verifyShPath);
|
||||
|
||||
// Write verify.ps1
|
||||
var verifyPs1Content = await LoadTemplateAsync("verify.ps1.template");
|
||||
@@ -193,6 +218,30 @@ public sealed class ReleaseEvidencePackSerializer
|
||||
_logger.LogInformation("Evidence pack written to: {Path}", outputPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the evidence pack to a directory structure without copying artifacts, with optional replay log.
|
||||
/// Advisory: EU CRA/NIS2 compliance - includes replay_log.json for deterministic offline verification.
|
||||
/// </summary>
|
||||
public async Task SerializeToDirectoryAsync(
|
||||
ReleaseEvidencePackManifest manifest,
|
||||
string outputPath,
|
||||
VerificationReplayLog? replayLog,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Update manifest with replay log path if provided
|
||||
var manifestWithReplayLog = replayLog is not null
|
||||
? manifest with { ReplayLogPath = "replay_log.json" }
|
||||
: manifest;
|
||||
|
||||
await SerializeToDirectoryAsync(manifestWithReplayLog, outputPath, cancellationToken);
|
||||
|
||||
// Write replay_log.json if provided
|
||||
if (replayLog is not null)
|
||||
{
|
||||
await WriteReplayLogAsync(outputPath, replayLog, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the evidence pack as a .tar.gz archive.
|
||||
/// </summary>
|
||||
@@ -337,6 +386,100 @@ public sealed class ReleaseEvidencePackSerializer
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the evidence pack as a .tar.gz archive with replay log.
|
||||
/// Advisory: EU CRA/NIS2 compliance - includes replay_log.json for deterministic offline verification.
|
||||
/// </summary>
|
||||
public async Task SerializeToTarGzAsync(
|
||||
ReleaseEvidencePackManifest manifest,
|
||||
Stream outputStream,
|
||||
string bundleName,
|
||||
VerificationReplayLog? replayLog,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Create temp directory, serialize, then create tar.gz
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"evidence-pack-{Guid.NewGuid():N}");
|
||||
var bundleDir = Path.Combine(tempDir, bundleName);
|
||||
try
|
||||
{
|
||||
await SerializeToDirectoryAsync(manifest, bundleDir, replayLog, cancellationToken);
|
||||
|
||||
// Create tar.gz using GZipStream
|
||||
await using var gzipStream = new GZipStream(outputStream, CompressionLevel.Optimal, leaveOpen: true);
|
||||
await CreateTarFromDirectoryAsync(bundleDir, gzipStream, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Evidence pack archived as tar.gz with replay_log.json");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the evidence pack as a .zip archive with replay log.
|
||||
/// Advisory: EU CRA/NIS2 compliance - includes replay_log.json for deterministic offline verification.
|
||||
/// </summary>
|
||||
public async Task SerializeToZipAsync(
|
||||
ReleaseEvidencePackManifest manifest,
|
||||
Stream outputStream,
|
||||
string bundleName,
|
||||
VerificationReplayLog? replayLog,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Create temp directory, serialize, then create zip
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"evidence-pack-{Guid.NewGuid():N}");
|
||||
var bundleDir = Path.Combine(tempDir, bundleName);
|
||||
try
|
||||
{
|
||||
await SerializeToDirectoryAsync(manifest, bundleDir, replayLog, cancellationToken);
|
||||
|
||||
using var archive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true);
|
||||
await AddDirectoryToZipAsync(archive, bundleDir, bundleName, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Evidence pack archived as zip with replay_log.json");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the replay_log.json file to the bundle directory.
|
||||
/// Advisory: EU CRA/NIS2 compliance - Sealed Audit-Pack replay_log.json
|
||||
/// </summary>
|
||||
private async Task WriteReplayLogAsync(
|
||||
string bundleDir,
|
||||
VerificationReplayLog replayLog,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var replayLogJson = JsonSerializer.Serialize(replayLog, ReplayLogSerializerContext.Default.VerificationReplayLog);
|
||||
var replayLogPath = Path.Combine(bundleDir, "replay_log.json");
|
||||
await File.WriteAllTextAsync(replayLogPath, replayLogJson, cancellationToken);
|
||||
_logger.LogDebug("Wrote replay_log.json for deterministic verification replay");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets Unix executable permissions on a file if running on a Unix-like OS.
|
||||
/// </summary>
|
||||
private static void SetUnixExecutableIfSupported(string filePath)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
File.SetUnixFileMode(filePath,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateChecksumsFilesAsync(
|
||||
ReleaseEvidencePackManifest manifest,
|
||||
string bundleDir,
|
||||
@@ -447,6 +590,31 @@ public sealed class ReleaseEvidencePackSerializer
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Deterministic Replay Verification (CRA/NIS2 compliance)
|
||||
if (manifest.ReplayLogPath is not null)
|
||||
{
|
||||
sb.AppendLine("## Deterministic Replay Verification (EU CRA/NIS2)");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("This bundle includes `replay_log.json` for offline deterministic verification.");
|
||||
sb.AppendLine("The replay log documents each verification step for auditor replay:");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("```bash");
|
||||
sb.AppendLine("# View verification steps");
|
||||
sb.AppendLine("cat replay_log.json | jq '.steps[]'");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("# Verify all steps passed");
|
||||
sb.AppendLine("cat replay_log.json | jq '.result'");
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Steps typically include:");
|
||||
sb.AppendLine("1. `compute_canonical_sbom_digest` - RFC 8785 JCS canonicalization");
|
||||
sb.AppendLine("2. `verify_dsse_subject_match` - SBOM digest matches DSSE subject");
|
||||
sb.AppendLine("3. `verify_dsse_signature` - DSSE envelope signature validation");
|
||||
sb.AppendLine("4. `verify_rekor_inclusion` - Merkle proof against transparency log");
|
||||
sb.AppendLine("5. `verify_rekor_checkpoint` - Signed checkpoint validation (optional)");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine("## Bundle Contents");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| File | SHA-256 | Description |");
|
||||
@@ -488,7 +656,7 @@ public sealed class ReleaseEvidencePackSerializer
|
||||
|
||||
private static async Task<string> LoadTemplateAsync(string templateName)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var assembly = typeof(ReleaseEvidencePackSerializer).Assembly;
|
||||
var resourceName = $"StellaOps.Attestor.EvidencePack.Templates.{templateName}";
|
||||
|
||||
await using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.EvidencePack.Models;
|
||||
|
||||
namespace StellaOps.Attestor.EvidencePack.Services;
|
||||
|
||||
/// <summary>
|
||||
/// JSON serialization context for verification replay logs.
|
||||
/// </summary>
|
||||
[JsonSerializable(typeof(VerificationReplayLog))]
|
||||
[JsonSerializable(typeof(VerificationReplayStep))]
|
||||
[JsonSerializable(typeof(VerificationKeyRef))]
|
||||
[JsonSerializable(typeof(RekorVerificationInfo))]
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
internal partial class ReplayLogSerializerContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
// Advisory: Sealed Audit-Pack replay_log.json generation per EU CRA/NIS2 compliance
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.EvidencePack.Models;
|
||||
|
||||
namespace StellaOps.Attestor.EvidencePack.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Builds verification replay logs for deterministic offline proof replay.
|
||||
/// </summary>
|
||||
public sealed class VerificationReplayLogBuilder : IVerificationReplayLogBuilder
|
||||
{
|
||||
private const string SchemaVersion = "1.0.0";
|
||||
private const string VerifierVersion = "stellaops-attestor/1.0.0";
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public VerificationReplayLogBuilder(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a verification replay log from SBOM verification results.
|
||||
/// </summary>
|
||||
public VerificationReplayLog Build(VerificationReplayLogRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var replayId = GenerateReplayId(request.ArtifactRef, now);
|
||||
|
||||
var steps = new List<VerificationReplayStep>();
|
||||
var stepNumber = 1;
|
||||
|
||||
// Step 1: Compute canonical SBOM digest
|
||||
if (request.SbomPath is not null)
|
||||
{
|
||||
steps.Add(new VerificationReplayStep
|
||||
{
|
||||
Step = stepNumber++,
|
||||
Action = "compute_canonical_sbom_digest",
|
||||
Description = "Compute SHA-256 hash of the canonicalized SBOM (RFC 8785 JCS)",
|
||||
Input = request.SbomPath,
|
||||
Output = request.CanonicalSbomDigest,
|
||||
Result = "pass",
|
||||
Algorithm = "sha256"
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Verify DSSE subject match
|
||||
if (request.DsseSubjectDigest is not null)
|
||||
{
|
||||
var subjectMatch = string.Equals(
|
||||
request.CanonicalSbomDigest,
|
||||
request.DsseSubjectDigest,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
steps.Add(new VerificationReplayStep
|
||||
{
|
||||
Step = stepNumber++,
|
||||
Action = "verify_dsse_subject_match",
|
||||
Description = "Verify SBOM digest matches DSSE envelope subject[].digest",
|
||||
Expected = request.DsseSubjectDigest,
|
||||
Actual = request.CanonicalSbomDigest,
|
||||
Result = subjectMatch ? "pass" : "fail",
|
||||
Error = subjectMatch ? null : "SBOM digest does not match DSSE subject digest"
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Verify DSSE signature
|
||||
if (request.DsseEnvelopePath is not null)
|
||||
{
|
||||
steps.Add(new VerificationReplayStep
|
||||
{
|
||||
Step = stepNumber++,
|
||||
Action = "verify_dsse_signature",
|
||||
Description = "Verify DSSE envelope signature using supplier public key",
|
||||
Input = request.DsseEnvelopePath,
|
||||
KeyId = request.SigningKeyId,
|
||||
Result = request.DsseSignatureValid ? "pass" : "fail",
|
||||
Error = request.DsseSignatureValid ? null : request.DsseSignatureError,
|
||||
Algorithm = request.SignatureAlgorithm ?? "ecdsa-p256"
|
||||
});
|
||||
}
|
||||
|
||||
// Step 4: Verify Rekor inclusion
|
||||
if (request.RekorLogIndex is not null)
|
||||
{
|
||||
steps.Add(new VerificationReplayStep
|
||||
{
|
||||
Step = stepNumber++,
|
||||
Action = "verify_rekor_inclusion",
|
||||
Description = "Verify Merkle inclusion proof against Rekor transparency log",
|
||||
Input = request.InclusionProofPath,
|
||||
Output = $"log_index={request.RekorLogIndex}",
|
||||
Result = request.RekorInclusionValid ? "pass" : "fail",
|
||||
Error = request.RekorInclusionValid ? null : request.RekorInclusionError
|
||||
});
|
||||
}
|
||||
|
||||
// Step 5: Verify Rekor checkpoint signature (if provided)
|
||||
if (request.CheckpointPath is not null)
|
||||
{
|
||||
steps.Add(new VerificationReplayStep
|
||||
{
|
||||
Step = stepNumber++,
|
||||
Action = "verify_rekor_checkpoint",
|
||||
Description = "Verify signed Rekor checkpoint (tile head)",
|
||||
Input = request.CheckpointPath,
|
||||
KeyId = request.RekorPublicKeyId,
|
||||
Result = request.CheckpointValid ? "pass" : "fail",
|
||||
Error = request.CheckpointValid ? null : "Checkpoint signature verification failed"
|
||||
});
|
||||
}
|
||||
|
||||
// Build verification keys list
|
||||
var keys = new List<VerificationKeyRef>();
|
||||
if (request.CosignPublicKeyPath is not null)
|
||||
{
|
||||
keys.Add(new VerificationKeyRef
|
||||
{
|
||||
KeyId = request.SigningKeyId ?? "cosign-key",
|
||||
Type = "cosign",
|
||||
Path = request.CosignPublicKeyPath,
|
||||
Fingerprint = request.SigningKeyFingerprint
|
||||
});
|
||||
}
|
||||
if (request.RekorPublicKeyPath is not null)
|
||||
{
|
||||
keys.Add(new VerificationKeyRef
|
||||
{
|
||||
KeyId = request.RekorPublicKeyId ?? "rekor-key",
|
||||
Type = "rekor",
|
||||
Path = request.RekorPublicKeyPath
|
||||
});
|
||||
}
|
||||
|
||||
// Build Rekor info
|
||||
RekorVerificationInfo? rekorInfo = null;
|
||||
if (request.RekorLogIndex is not null && request.RekorLogId is not null)
|
||||
{
|
||||
rekorInfo = new RekorVerificationInfo
|
||||
{
|
||||
LogId = request.RekorLogId,
|
||||
LogIndex = request.RekorLogIndex.Value,
|
||||
TreeSize = request.RekorTreeSize ?? 0,
|
||||
RootHash = request.RekorRootHash ?? string.Empty,
|
||||
InclusionProofPath = request.InclusionProofPath,
|
||||
CheckpointPath = request.CheckpointPath,
|
||||
IntegratedTime = request.RekorIntegratedTime
|
||||
};
|
||||
}
|
||||
|
||||
// Determine overall result
|
||||
var overallResult = steps.All(s => s.Result == "pass" || s.Result == "skip") ? "pass" : "fail";
|
||||
|
||||
return new VerificationReplayLog
|
||||
{
|
||||
SchemaVersion = SchemaVersion,
|
||||
ReplayId = replayId,
|
||||
ArtifactRef = request.ArtifactRef,
|
||||
VerifiedAt = now,
|
||||
VerifierVersion = VerifierVersion,
|
||||
Result = overallResult,
|
||||
Steps = steps.ToImmutableArray(),
|
||||
VerificationKeys = keys.ToImmutableArray(),
|
||||
Rekor = rekorInfo,
|
||||
Metadata = request.Metadata
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the replay log to JSON.
|
||||
/// </summary>
|
||||
public string Serialize(VerificationReplayLog log)
|
||||
{
|
||||
return JsonSerializer.Serialize(log, ReplayLogSerializerContext.Default.VerificationReplayLog);
|
||||
}
|
||||
|
||||
private static string GenerateReplayId(string artifactRef, DateTimeOffset timestamp)
|
||||
{
|
||||
var input = $"{artifactRef}:{timestamp:O}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"replay_{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for building a verification replay log.
|
||||
/// </summary>
|
||||
public sealed record VerificationReplayLogRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Reference to the artifact being verified (e.g., OCI reference, file path).
|
||||
/// </summary>
|
||||
public required string ArtifactRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the SBOM file in the bundle.
|
||||
/// </summary>
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the canonicalized SBOM.
|
||||
/// </summary>
|
||||
public string? CanonicalSbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the DSSE envelope file in the bundle.
|
||||
/// </summary>
|
||||
public string? DsseEnvelopePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest from DSSE envelope subject[].digest.
|
||||
/// </summary>
|
||||
public string? DsseSubjectDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether DSSE signature verification passed.
|
||||
/// </summary>
|
||||
public bool DsseSignatureValid { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Error message if DSSE signature verification failed.
|
||||
/// </summary>
|
||||
public string? DsseSignatureError { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used for signing.
|
||||
/// </summary>
|
||||
public string? SigningKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature algorithm used.
|
||||
/// </summary>
|
||||
public string? SignatureAlgorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 fingerprint of the signing public key.
|
||||
/// </summary>
|
||||
public string? SigningKeyFingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the cosign public key in the bundle.
|
||||
/// </summary>
|
||||
public string? CosignPublicKeyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log ID.
|
||||
/// </summary>
|
||||
public string? RekorLogId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index.
|
||||
/// </summary>
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor tree size at time of inclusion.
|
||||
/// </summary>
|
||||
public long? RekorTreeSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor root hash.
|
||||
/// </summary>
|
||||
public string? RekorRootHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor integrated time (Unix timestamp).
|
||||
/// </summary>
|
||||
public long? RekorIntegratedTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the inclusion proof file.
|
||||
/// </summary>
|
||||
public string? InclusionProofPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether Rekor inclusion proof verification passed.
|
||||
/// </summary>
|
||||
public bool RekorInclusionValid { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Error message if Rekor inclusion verification failed.
|
||||
/// </summary>
|
||||
public string? RekorInclusionError { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the signed checkpoint file.
|
||||
/// </summary>
|
||||
public string? CheckpointPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether checkpoint signature verification passed.
|
||||
/// </summary>
|
||||
public bool CheckpointValid { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Path to the Rekor public key in the bundle.
|
||||
/// </summary>
|
||||
public string? RekorPublicKeyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor public key ID.
|
||||
/// </summary>
|
||||
public string? RekorPublicKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for building verification replay logs.
|
||||
/// </summary>
|
||||
public interface IVerificationReplayLogBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a verification replay log from the request.
|
||||
/// </summary>
|
||||
VerificationReplayLog Build(VerificationReplayLogRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the replay log to JSON.
|
||||
/// </summary>
|
||||
string Serialize(VerificationReplayLog log);
|
||||
}
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="System.IO.Compression" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -21,7 +20,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Templates\VERIFY.md.template" />
|
||||
<EmbeddedResource Include="Templates\verify.sh.template" />
|
||||
<EmbeddedResource Include="Templates\verify-unix.template" />
|
||||
<EmbeddedResource Include="Templates\verify.ps1.template" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user